Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] ea76f7501d Add cold-load case: an 80 MiB provider history of ordinary rows opens 2026-09-24 00:48:28 +02:00
paseo-bot[bot] daaffe6109 Reproduce 1006 disconnect when an agent's latest timeline page exceeds the socket bound (#2610)
The first case passes: an 80 MiB history of ordinary rows opens with a
40-row page. The second fails: 40 file writes of 2 MiB each make one
fetch_agent_timeline_response larger than the 64 MiB physical socket
bound, and the daemon closes the connection (1006).
2026-09-24 00:46:49 +02:00
paseo-bot[bot] f4efdbded7 Start the daemon when the schedules directory holds a file that is not a schedule (#5301)
* Start the daemon when the schedules directory holds a file that is not a schedule

One unreadable .json file in the schedules directory rejected
ScheduleStore.list(), which ScheduleService.start() awaits during daemon
bootstrap, so the daemon never started. The store now skips a file it
cannot read as a schedule and logs its path once, the way agent storage
treats an invalid agent record.

Closes #4745

* Skip only schedule files with invalid content

A schedule file that cannot be read now fails the listing as before,
instead of being skipped as if it were not a schedule. An invalid file
is reported again when it reappears after being removed or fixed.

* Retrigger CI after an unrelated macOS observation-test flake

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 23:49:18 +02:00
Mohamed Boudra c976e2e5a5 test(server): wait for repository observation before asserting ref refresh (#5303)
* test(server): wait for repository observation before asserting ref refresh

The first checkout facts read can begin before the metadata watcher registers. The test sent an event through an optional watcher lookup at that point and silently dropped it, so a slow setup left only the initial snapshot. Hold the first read to reproduce the ordering and wait for observation setup and the initial refresh before emitting the ref event.

* test(server): isolate ref refresh from background fetch

An asynchronous background fetch could also schedule a structural refresh or populate the known-ref set. Give this test an empty fetch result and wait for that fetch to complete so the second facts read proves the injected watcher event caused it.

* test(server): require the registered repository watcher in the assertion
2026-09-23 23:25:30 +02:00
paseo-bot[bot]andMohamed Boudra b2bb512b31 Let a plugin reload finish without crashing the plugin subprocess (#5231)
* Keep the plugin IPC channel open until provider closes report

closeProviderConnection removed the connection from the registry before
awaiting the provider's close, so shutdown's sweep could not see a close
already in flight. With a provider whose close does real work, the
subprocess finished shutting down and called process.disconnect() while
that close was still running, and the trailing provider.closed send hit a
closed channel and took the subprocess down with an unhandled
ERR_IPC_CHANNEL_CLOSED.

Keep the connection registered until its close has reported, and share
one close promise per connection, so shutdown waits for an in-flight
close instead of disconnecting underneath it. The close now also owns
reporting its own outcome, so it never rejects into teardown.

* Harden the reload teardown test for Windows and clean shutdown

Normalize line endings before patching the example provider, so the
fixture also builds from a CRLF checkout. Wait for the in-flight turn and
archive the agent before stopping the daemon, so teardown no longer
rejects a pending provider request into an unhandled rejection.

* Verify plugin reload teardown and reject sends during close

* Synchronize plugin reload teardown test with close lifecycle

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-23 23:24:53 +02:00
Mohamed Boudra 067937bace Let Hub title workspaces created by its executions (#5302)
* fix(server): let Hub title created workspaces

Agent creation can provision a workspace under hub.execute, but a separate title request required workspace.manage. Permit the same execution authority for title changes and their responses, and lock the Hub request contract with socket and permission tests.

* test(server): keep Hub title repro free of conditional flow
2026-09-23 23:15:06 +02:00
paseo-bot[bot] d615d3d424 Cut the cost of a directory-suggestion scan so Add Project search stops timing out (#5190)
* fix(server): stop re-deriving path state for every scanned directory

Directory suggestion scans were CPU-bound in path bookkeeping rather than
in reading the tree. A 20,000-entry scan of a 197,000-directory home took
6.9s, of which readdir accounted for 50ms; the rest went to repeated path
normalization.

Three redundant passes are removed:

- Discovery re-derived Git-ignore state by walking every ancestor of every
  entry. Traversal only descends through entries that already passed that
  check, so only the entry itself can be newly ignored.
- Discovery re-derived root containment for every child. Only a symlink can
  resolve outside the tree being walked; every other child is its parent's
  path plus a name, and the parent was already proved inside.
- The directory listing cache swept itself for expired keys on every miss
  once it reached its cap, an O(cache size) pass per directory read. Reads
  already reject stale entries by expiry and by directory metadata.

The same scan now takes 1.1s. The new test expresses the budget against a
plain readdir walk of the same tree, so it means the same thing on CI as on
a developer machine: the ratio was 12x and is now under 3x.

Refs #4879

* test(server): skip the scan-cost budget on Windows

The tree it needs is deeper than MAX_PATH allows, and the directory
listing cache it measures is disabled on Windows because directory
metadata cannot validate it there.

* test(server): count containment derivations instead of timing the scan

The previous budget compared wall-clock scan time against a readdir walk
of the same tree. packages/server/AGENTS.md requires tests to be
deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan
moves with filesystem caching and runner load.

isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics,
matching the existing git command metrics. Scanning a symlink-free tree of
320 entries derived containment 1,760 times before this branch and derives
it none now, which is the defect stated exactly rather than inferred from a
stopwatch.

A second test covers the other direction: a symlink that leaves the root is
still checked, and still excluded from results.

* fix(server): keep the Git-ignore ancestor walk for symlinked entries

A symlink resolves to a path whose ancestors were never checked during
traversal, so a link into an ignored directory was discovered. Named
children keep the single-entry check.

* test(server): declare the scan helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:55:13 +02:00
paseo-bot[bot] fbe5005aa1 Allow Backspace in custom keyboard shortcuts (#5224)
Capture modified Backspace shortcuts in the editor while keeping bare Backspace as the erase-last-combo key. Cover the reported Mac chord, its Windows Ctrl equivalent, and the erase behavior in the desktop renderer suite.

Closes #5215

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:39:00 +02:00
Mohamed Boudra e998e0a082 Stop the daemon holding a CPU core on a repository the watcher can't observe (#5170)
* fix(server): back off degraded git polling on an unchanged workspace

When the recursive watcher declines a repository (over 250,000 entries on
macOS/Windows, or any watcher setup failure), WorkspaceGit falls back to
polling. That fallback rescheduled itself every 5 seconds for as long as the
workspace existed, with no client or agent involved. On a repository large
enough to defeat the watcher each tick is an expensive `git status`, so the
daemon held about a core indefinitely.

Double the gap after every tick whose workspace snapshot came back identical,
up to a 60-second ceiling, and reset to the 5-second base on the first change.
Both degraded loops — working tree and repository metadata — share the rule.
Explicit reads still force a refresh, so the ceiling only bounds how long an
edit made outside Paseo stays undiscovered.

Fixes #5102

* test(server): cover repository-metadata degraded backoff, document failed-tick semantics

* test(server): drive the recovery-refresh assertion off the fake clock, not waitFor

* test(server): declare metadata poll counter as a function
2026-09-23 22:20:17 +02:00
paseo-bot[bot] ae42b0afb0 fix(omp): wait for terminal event after custom messages (#3258)
Custom messages may precede the prompt user message during an active OMP turn. Treating them as completion terminalized the turn while the provider continued working.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:06:53 +02:00
paseo-bot[bot] 829cc5e173 Let Claude sessions pass structured launch arguments (#5206)
Expose Claude Agent SDK's documented extraArgs map through providerOptions so Claude-specific flags such as --chrome reach the CLI.

Document boolean and valued flags, link the upstream contract, and validate through schema tests, the public session interface, and a real Claude launch.
2026-09-23 22:04:20 +02:00
paseo-bot[bot] 48384cafdf Create new branches without an upstream so the first push cannot land on main (#5249)
* fix(server): create new branches without an upstream

A new branch Paseo creates in place ran `git checkout -b <new> <base>`,
which lets git decide the upstream. Git sets `origin/<base>` whenever the
base is a remote-tracking ref (what the default-branch resolver returns
when the local default branch is absent) or when `branch.autoSetupMerge`
is `inherit`. Every later push reads that upstream, so the work lands on
the default branch: `git push -u origin <branch>` under
`push.default=tracking` pushes to `main`, and Paseo's own push resolves
`branch.<name>.merge` and pushes `HEAD:refs/heads/main`.

Pass `--no-track`, the same promise the worktree path already makes with
`git worktree add -b <branch> --no-track <base>`.

* test(server): read the upstream with for-each-ref so a git failure cannot read as absent

* test(server): use a function declaration for the clone fixture git runner

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 22:03:55 +02:00
paseo-bot[bot]andGurvan Campion 2c7b38bc7e Run OpenCode agents under the permission rules they were given (#5296)
* fix(opencode): set permission rules on the OpenCode session

OpenCode stores permission rules on the session and its prompt_async
request schema has no permission field, so rules built from provider
options and Hub tool grants were dropped on every turn: an agent
configured with external_directory: allow still asked before reading
outside its worktree.

Send the rules where OpenCode reads them — on session.create, and on
session.update when resuming a session Paseo did not just create — and
stop attaching them to prompts. A resume that cannot apply its rules now
fails instead of running under a more permissive policy than configured.

The real-server test is taken from Gurvan Campion's #5295, which reached
the same diagnosis independently.

Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>

* test(opencode): use function declarations for the permission test helpers

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
2026-09-23 21:56:19 +02:00
Mohamed Boudra 222d45a2fc fix(plugin-provider): keep request failures from crashing the daemon (#5298)
Observe provider response rejection before transport acceptance completes, while preserving the original promise and errors returned to callers. Cover early failure, send rejection, closure, cancellation, and successful retries through the public provider interface.

Closes #4781.
2026-09-23 21:54:46 +02:00
Mohamed Boudra 373da70694 Present company sponsorship as support instead of ad space (#5297)
* feat(website): present company sponsorship as support, not ad space

The homepage showed four empty "Sponsor / $500 a month" boxes under a
"New" badge, right below a note about staying independent. They read as
ad inventory for sale. The price now lives only on /sponsor, next to the
checkout. The homepage shows a Sponsors row only once a company has
sponsored, and the READMEs link to /sponsor instead of straight to
Stripe checkout.

* feat(website): drop subtitles from the sponsor options

Each option carried a line like "From $5 a month" or "with an invoice" that tried to set it apart, but the differences are not real: GitHub Sponsors invoices too and any channel takes small amounts. Showing the names lets people pick the platform they already use.
2026-09-23 21:25:09 +02:00
Mohamed BoudraandMarek Rom c3e1e084a0 fix(server): bound watcher work for late ignored directories
Refresh Git exclusions when ignored directories appear after subscription and bound Linux and native classification work. Preserve tracked-file notifications, ignore-update barriers, and watcher recovery.

Fixes #4558. Supersedes #4808 by @Marcus172; this change incorporates six commits originally authored by Marek Rom and adds verified follow-up fixes. The independent session-context leak is fixed by #5208. Combined isolated daemon verification passed.

Co-authored-by: Marek Rom <marek.marcus.rom@gmail.com>
2026-09-23 20:55:30 +02:00
Mohamed Boudra 9978988e35 fix(server): prevent daemon heap growth after sessions close
Keep AsyncLocalStorage registrations fixed across SessionDelivery lifetimes, preserving request isolation and nonblocking close.\n\nVerified by failing-first regression, isolated memory reproduction, and combined daemon OOM verification with #5222.
2026-09-23 20:54:51 +02:00
paseo-bot[bot] 8768500fc0 Stop a replica cache read from spinning when the store rejects its writes (#5290)
* Stop a replica cache read from spinning when the store rejects its writes

A replica cache read waits for accepted commits to reach the row store
before it answers, and retries until the store agrees. When the store
rejects the write deterministically, that retry never makes progress: the
read loops on flush and read with no delay, burning a core and never
returning. The read now fails closed on a rejected write, so callers fall
back to the daemon instead of the loop saturating the renderer.

* Fail a read closed only when its own host has unwritten changes

A rejected write carries whatever was pending across every host, so
giving up the read on any rejection hid stored rows from hosts that had
nothing pending. A host whose rows are already in the store stays
readable while another host's write keeps failing.

* Bound the read count in the storage fake instead of the test body

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 20:45:06 +02:00
paseo-bot[bot] 89073d4c5c Keep a Claude rewind anchor off subagent messages (#5289)
Task subagent frames stream on the same query as the conversation, and
rememberTranscriptProgress recorded any assistant frame as the current turn's
rewind anchor. When a turn's last assistant frame came from a subagent, the
anchor held a uuid that lives on the subagent's sidechain rather than on the
session's own chain, so forkSession rejected it with "Message <uuid> not found
in session" and every later rewind in that conversation failed.

Skip frames that carry a parent_tool_use_id when recording anchors. The
persisted history path already skips sidechain entries; the live stream now
applies the same rule.

Closes #5284

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 19:45:11 +02:00
paseo-bot[bot] ec43e90670 Let a rebound pane-focus shortcut fire while typing (#5287)
The pane-focus defaults carry `editable: false` so that Cmd+Shift+Arrow
keeps selecting text in a field instead of moving pane focus. That guard
describes the default combo, not the action, but `buildEffectiveBindings`
carried it onto a user's override: rebinding focus-pane to Alt+H/J/K/L in
Settings produced a shortcut that fired everywhere except the composer,
and since switching panes focuses that pane's composer, navigation
stopped after one hop.

Drop the guard when an override replaces the combo, the same way the
default display keys are dropped. The default Cmd+Shift+Arrow still
yields to text selection.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:26 +02:00
paseo-bot[bot] 897a0abb1a Keep one copy of the timeline when an agent is refreshed (#5286)
* Clear the committed timeline when an agent refresh rehydrates from disk

Refreshing a loaded agent reloads it with `rehydrateFromDisk`, which wipes
the in-memory timeline so provider history can be re-streamed. It left the
committed timeline in place, and that is what `getTimelineRows` reads, so
every refresh appended a second full copy of the history: N refreshes, N
copies of every message.

Wipe both timelines on that path, the way the force-hydrate path already
does, so the re-streamed history replaces the old rows instead of stacking
on them.

* Replace the committed timeline from the replay instead of wiping it first

Deleting the committed rows on the reload path meant a history replay that
failed halfway left the agent with no transcript, and a retry appended a
fresh replay to whatever partial rows it had written.

Collect the whole replay before touching either store, then drop the rows a
previous hydration committed and write the new ones. A failed stream now
leaves the committed timeline exactly as it was, and this covers the replay
that runs when an agent is loaded after a daemon restart, which duplicated
the same way a refresh did.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 18:46:20 +02:00
paseo-bot[bot] c4771ca467 Rewind a Claude conversation past a turn that got no response (#5285)
A turn that dies before the model emits its first token leaves its rewind
anchor without an assistant message id. resolveConversationRewindTarget read
only the anchor immediately before the target, so that one turn made every
later rewind in the conversation throw "assistant response id was not
observed" for good.

Such a turn carries no conversation state, so walk back to the most recent
turn that did answer and fork there, falling back to a fresh session when
none did.

The rewind SDK now reaches the session as an option so the regression test
can drive revertConversation without forking a real Claude session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 17:46:00 +02:00
paseo-bot[bot] 05bfffad41 Keep the voice thinking tone out of the gaps inside a spoken reply (#5281)
Closes #5061
2026-09-23 16:49:10 +02:00
paseo-bot[bot] faee1cd952 Start the daemon after a reboot hands its old PID to another process (#5277)
Closes #5257
2026-09-23 16:49:05 +02:00
paseo-bot[bot]andgengjiawen ba4595d4e8 Start a Cursor agent on a model that has no Fast variant (#5274)
* Start an ACP session when a stored feature value no longer applies

Replaying stored config onto a new ACP session already tolerates a model
the session cannot select, warning and keeping the provider default.
Feature values were the exception: they were written unconditionally, and
either Paseo's own "does not expose ACP feature" guard or the provider's
-32602 rejection escaped out of initializeNewSession, so no agent was
created.

Cursor's `fast` is a per-model option, so switching a draft from a
fast-capable model to one without a fast variant left a stored value the
new session cannot take, and agent creation failed.

Apply stored feature values best-effort at session start, matching the
model path. A user-initiated setFeature still reports the failure.

Co-authored-by: gengjiawen <technicalcute@gmail.com>

* Tolerate a provider's invalid-params only after a model switch

A stale option snapshot is only possible when the replay switched the
model, since the model write answers with an empty response. Without a
switch the options came straight from session/new, so a rejected write
is a real failure the user needs to see.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: gengjiawen <technicalcute@gmail.com>
2026-09-23 15:45:26 +02:00
34c9fd03c9 fix(codex): expose Fast for GPT-6 Sol and GPT-6 Luna (#5273)
The Codex Fast allowlist covered only gpt-6-astra in the GPT-6 family, so
selecting GPT-6 Sol or GPT-6 Luna published no fast_mode feature and the
composer had no Fast control to render. OpenAI documents Fast for Astra,
Sol, and Luna, and the app server's model/list advertises the fast speed
tier for all three.

Add both model IDs to the allowlist and to the parameterized feature test
covering the toggle, the restore path, and the serviceTier sent on turn
start.

The same change was proposed independently in #5220 and #5268.

Closes #5271

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: basilkhowaja <bk08432@st.habib.edu.pk>
2026-09-23 14:46:37 +02:00
paseo-bot[bot] 9a3f3a0dc6 fix(app): complete a multi-step shortcut whose second step holds a modifier (#5272)
A recorded sequence like Ctrl+K Ctrl+J never fired. Pressing a modifier
emits a keydown for the modifier key itself before the combo that holds
it, so a chord waiting on its second step saw a bare `Control` first.
That keydown matches no combo, and the resolver's fall-through reset the
chord to step 0, leaving the following Ctrl+J to resolve as a first step
and match nothing. A second step with no modifier was unaffected, which
is why Ctrl+K J worked.

A modifier keydown is the prefix of a combo, never a combo of its own, so
the resolver now ignores it and leaves the chord where it is. The capture
path already held that rule in `keyboardEventToComboString`; this exports
the predicate behind it rather than adding a second notion of what a
modifier key is.

Closes #5265
2026-09-23 14:46:33 +02:00
paseo-bot[bot]andIvan Malison d6861f81e5 Keep a multi-key shortcut alive when the app re-renders between its keys (#5255)
### Linked issue

Closes #4821

### Type of change

- [x] Bug fix

### Reasoning

A key sequence recorded in Settings, for example `Ctrl+K` then `J`, silently did nothing whenever the app re-rendered between the two keys. Resizing the window, toggling the sidebar, or an agent turn starting or finishing is enough, so a rebound sequence looks broken at random and the second key lands in the composer as text.

The global `keydown`/`keyup`/`blur`/`visibilitychange` listeners, and on desktop the `browser-shortcut-input` subscription, were registered by an effect that depended on eighteen render-scoped values. One of them, `useActiveWorkspaceSelection()`, returns a new object every render, so the effect tore down and re-subscribed on every render of `AppContainer`. Its cleanup resets the in-progress sequence, so the second key was resolved from step 0, matched nothing, and fell through as ordinary input.

The key handlers now read the latest render through `useStableEvent`, so the listener effect re-subscribes only when shortcuts are enabled or disabled. Clearing held badge modifiers when the workspace-index jump binding changes moved to its own effect keyed on that modifier. The cleanup still resets the sequence, which is now correct, because it only runs when shortcuts genuinely go away.

### Goals

- A recorded key sequence completes across a root re-render between its keys.
- The global shortcut listeners are registered once while shortcuts are enabled, instead of on every render.
- A regression spec that fails on the current `main` and passes with this change.

### Non-goals

- `useActiveWorkspaceSelection()` still returns a new object per render. This PR stops the shortcut listeners from depending on that identity, it does not change the hook.
- A sequence whose second step also carries a modifier (`Ctrl+K` `Ctrl+J`) is still cancelled when the modifier is re-pressed, because the bare `Ctrl` keydown does not match the next step. That is a separate defect and is not fixed here.
- The Electron `browser-shortcut-input` path is unchanged in shape and was not exercised in a desktop build.

### QA

New spec `packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts`. It seeds `Ctrl+K J` as the "Toggle command center" binding, opens a workspace, and presses the sequence twice: once with nothing in between, once with a viewport resize settled over two frames between the keys.

Before, on `main`, run twice:

```
✘ multi-key shortcut sequences › a sequence completes when the window resizes between its keys
  expect(getByTestId('command-center-panel')).toBeVisible() failed
```

The control assertion in the same test passed both times, so the sequence itself works and only the re-render breaks it. Instrumenting the hook locally showed the mechanism directly:

```
key Control -> chord step 0
key K       -> chord step 1
effect cleanup at chord step 1      <- the resize re-render
effect setup; changed deps: activeWorkspaceSelection
key j       -> chord step 0, match null
```

After, the same spec passes, and `activeWorkspaceSelection` was the only dependency that changed on those renders.

Also run locally on this branch:

- `packages/app` unit suite: 603 files, 5319 tests, all passing.
- Playwright `browser` project, keyboard and command-center specs: `keyboard-shortcut-sequence`, `empty-pane-keyboard`, `changes-shortcut-placement`, `sidebar-workspace-pin-shortcut`, `workspace-archive-shortcut`, `command-center-agent-controls`. 17 passed.
- `npm run typecheck --workspace=@getpaseo/app`, `oxlint`, and `oxfmt --check` on the changed files: clean.

Web only, in Chromium on Linux. Keyboard shortcuts are off on mobile, and the desktop build was not exercised.

### Supersedes

- [PR #4834 — fix(app): keep multi-key shortcuts alive across root re-renders](https://github.com/getpaseo/paseo/pull/4834) — same fix for the same bug. I diagnosed #4821 independently, reached the same cause, and built on that commit, so its author is credited with a `Co-authored-by` trailer. This PR replaces its spec with one that drives the app through the keyboard only, without patching `window.addEventListener` to count registrations.

### Checklist

- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-23 12:47:26 +02:00
paseo-bot[bot]andMatteo Pietro Dazzi 3054f80050 Show MiniMax usage as unavailable when the account has no token plan (#5258)
### Linked issue

Closes #5247

### Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactor
- [ ] Docs

### Reasoning

A user whose MiniMax account has no active token plan opened the Usage page and saw a red **Error** badge on the MiniMax card with a block of raw JSON as its body: `[{"expected":"array","code":"invalid_type","path":["model_remains"],...}]`.

MiniMax reports application-level failures inside an HTTP 200 body — `base_resp.status_code` is non-zero and `model_remains` comes back as `null`. Two things went wrong with that response:

- `MiniMaxQuotaResponseSchema` declared `model_remains` as `z.array(...).optional()`, which admits a *missing* key but not `null`, so `.parse()` threw on a perfectly ordinary no-subscription response.
- `fetchUsage()` only checked `res.ok`, so an API-level error returned with HTTP 200 was never inspected in the first place.

The rejection propagated to `ProviderUsageService`, which stores `reason.message` as the card's error text. For a `ZodError`, that message *is* the serialized issue list — which is how the JSON blob reached the card.

This accepts `null` for `model_remains`, parses `base_resp`, and returns the plain unavailable result when the status code is non-zero, matching how a failed HTTP response is already handled in the same method. The API's own `status_msg` goes to the debug log rather than to the card: an account without a token plan is a normal state, not an error, so it should read as "Unavailable" like an unconfigured provider does — not as a red Error.

### Goals

- A MiniMax account with no active token plan renders as **Unavailable** with no error text.
- `model_remains: null` is accepted wherever it appears, with or without a `base_resp`.
- Application-level errors carried in an HTTP 200 body are detected rather than treated as success.
- Accounts that do have a plan keep rendering their interval and weekly windows unchanged.

### Non-goals

- **Generic error rendering in the Usage card.** The issue also asks that structured errors (ZodError and friends) be mapped to a concise message in the UI. That changes what every provider's card shows on schema drift, so it is a design decision for the maintainer rather than part of this fix. With the producer corrected, no ZodError arises on this path; if another provider's response shape drifts, raw issue JSON can still reach the card. Left open for the maintainer.
- Surfacing MiniMax's `status_msg` in the UI. It is logged at debug instead, so the card does not turn red for a normal no-subscription account.

### QA

Reproduced and verified through the real `ProviderUsageService` with the exact payload from the report, in `packages/server/src/services/quota-fetcher/service.test.ts`.

**Before** — `reports MiniMax accounts with no active token plan as unavailable` fails, and the failure output is the reported symptom verbatim:

```
- Expected                          + Received
-   "status": "unavailable",        +   "status": "error",
-   "error": null,                  +   "error": "[
                                    +   {
                                    +     \"expected\": \"array\",
                                    +     \"code\": \"invalid_type\",
                                    +     \"path\": [ \"model_remains\" ],
                                    +     \"message\": \"Invalid input: expected array, received null\"
                                    +   }
                                    + ]",
```

**After** — the full quota-fetcher suite passes: `Tests 71 passed (71)`.

Three tests were added, two of which are red without the provider change (confirmed by stashing only `minimax.ts`):

- `reports MiniMax accounts with no active token plan as unavailable` — the reported response (`model_remains: null` + `base_resp` 2062) → `status: "unavailable"`, `error: null`.
- `reports a null MiniMax token plan as unavailable even without base_resp` — `model_remains: null` alone.
- `still reads MiniMax windows when base_resp reports success` — guards against the new status check rejecting healthy accounts.

Also run locally: `npm run typecheck --workspace=@getpaseo/server`, `npx oxlint packages/server/src/services/quota-fetcher/`, `npx oxfmt --check packages/server/src/services/quota-fetcher/` — all clean.

No UI change, so no screenshots: with the provider fixed, the card takes its existing "Unavailable" rendering, the same one an unconfigured provider already gets.

### Checklist

- [x] Plugin changes follow the SDK import boundaries (if applicable)
- [x] One focused change
- [x] `npm run typecheck` passes
- [x] `npm run lint` passes
- [x] `npm run format` passes
- [x] QA evidence
- [x] Tests added or updated where it made sense

## Supersedes

- [PR #5252 — fix(quota): handle MiniMax accounts with no active token plan (#5247)](https://github.com/getpaseo/paseo/pull/5252) — Same diagnosis and same reported workflow. That PR returns `status: "error"` carrying MiniMax's `status_msg`, which keeps the red Error badge on the card for an account that is simply unsubscribed; this branch returns the plain unavailable result the issue asks for. It also adds cross-provider `ZodError` summarizing in `ProviderUsageService`, which is the design decision left as a non-goal above.

Co-authored-by: Matteo Pietro Dazzi <ilteoood@gmail.com>
2026-09-23 12:46:21 +02:00
paseo-bot[bot] 6d37f7fd9f Stop plugins after the agents they serve are closed (#5253)
* Close agents before stopping the plugins that serve them

A clean daemon shutdown stopped every plugin before it closed any agent.
Stopping a plugin withdraws its provider registrations, which pushes an
empty registration set through the agent provider registry, shuts the
plugin provider client down, and closes each still-open session with
`connectionClosed()`. That is reported as `session.runtime_failed` and
translated to `turn_failed`, so an agent whose last turn had completed
successfully was persisted with `lastError: "Provider connection closed"`
and came back after a restart carrying a failure it never had.

Stop tracking plugin provider registrations first so plugin teardown can
no longer withdraw a provider from under an open agent, and stop the
plugins themselves after `closeAllAgents()` and the provider runtime
shutdown have already made every session terminal. Closing the sessions
while their plugin is still running also means `session.close` now
reaches the provider.

Fixes #4877

* Bound each agent close so a silent provider cannot stall shutdown

Closing an agent asks its provider to close the session and waits for the
answer, and that request is only rejected when the provider runtime shuts
down. With agents now closing before the runtime does, a provider that
never answers `session.close` would leave `closeAllAgents()` waiting with
nothing left to unblock it.

Give each close a deadline. Whatever outlives it is abandoned and logged,
and the provider runtime shutdown that runs next rejects the request that
was still pending.

Also type the test fixture's `send` input as `ProviderInput`.

* Move the shutdown tests into the plugin lifecycle suite

Both tests exercise plugin lifecycle against a real daemon, which
lifecycle.e2e.test.ts already covers, so they go there rather than into a
suite of their own.

Drop the wall-clock assertion on how long stop() took. What the test
establishes is that stop() returns at all, and the suite timeout is what
enforces that; measuring elapsed time only added a second bound that a
loaded machine could trip.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 11:47:59 +02:00
paseo-bot[bot] 94ab368e4e Give daemon-spawned ACP terminals the agent's identity (#5248)
* fix(acp): give daemon-spawned terminals the agent's launch environment

A terminal the daemon opens for an ACP agent is a sibling of the agent
process, not a child, so it inherits nothing from it. createTerminal built
its environment from the requested terminal env alone, while the transport
spawn a hundred lines down already overlays the session's launchEnv. The
terminal therefore ran without PASEO_AGENT_ID or PASEO_AGENT_CWD, so
`paseo heartbeat create` refused to run and `paseo run` lost the caller,
creating child agents with no parent. When the daemon itself was started
from inside another Paseo agent, the terminal saw that agent's id instead.

Overlay launchEnv beneath the requested terminal env in both the argv and
the single-string shell branch, matching the transport spawn's precedence.

Fixes #5016

* test(acp): take createSession options as an object

The helper had grown a second optional positional parameter, making
callers read createSession(undefined, launchEnv).

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 09:44:02 +02:00
paseo-bot[bot] d161b5987d Dismiss an Android bottom sheet with Back instead of the screen (#5245)
* Dismiss an Android bottom sheet with Back instead of the screen

Bottom sheets render through a portal inside the app's own view tree
rather than a native modal window, so Android offered Back to the
navigator underneath them. On the model selector that meant one Back
press left the chat and the app, with the sheet still open.

`IsolatedBottomSheetModal` now claims `hardwareBackPress` for as long as
the sheet is on screen and dismisses itself instead. Listeners are
offered the press newest-first, so a stacked sheet answers before the one
below it, and Back falls back through to the navigator once no sheet is
left.

* Move the Back press onto the sheet visibility tracker

The first version held the press in `IsolatedBottomSheetModal` with its own
`isOnScreen` state and a merged ref, duplicating the phase the visibility
tracker already keeps, and it could only be tested through JSDOM with
`vi.mock` and `vi.hoisted`, which docs/testing.md rules out.

The tracker already owns presentation state and the sheet controller, so it
takes a `BackPressSource` port and holds the press for exactly the window it
is in the `presented` phase. `IsolatedBottomSheetModal` goes back to what it
was on main. Every sheet call site reaches the tracker through
`useIsolatedBottomSheetVisibility`, so coverage is unchanged.

The real source registers nothing off Android, where there is no such press
and `react-native-web`'s `BackHandler` logs an error on subscribe.

The test is now a plain unit test against a fake adapter, with no mocks,
no JSDOM, and no component mounting.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 08:45:00 +02:00
paseo-bot[bot] 90737e1dec fix(omp): show a stopped OMP turn as canceled, not failed (#5243)
Pressing Stop on an OMP turn could land in the thread as a failed turn
with OMP's interrupt text as the error, because OMP ends the turn with a
terminal response that carries "Interrupted by user" in errorMessage and
"aborted" in stopReason, and completeTurn classified any errorMessage as
a failure.

A terminal response whose stop reason is aborted now ends the turn as
turn_canceled and terminalizes the turn's in-flight tool calls and
subagents, matching what interrupt() already emits when it wins the race
with the provider's teardown.

A provider-idle state check that resolves after the turn already settled
no longer completes that turn, so a stop can no longer emit a second
lifecycle event behind the cancellation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 07:45:48 +02:00
3eaf0be0b2 Send a Claude slash command as the last content block (#5240)
Claude Code expands a slash command only when it is the last content
block of the user message. buildAgentPrompt orders blocks as chat
history, typed text, images, then rendered attachments, so a "/command"
sent alongside a pasted issue link or a screenshot reached the model as
literal text: the command never ran, and the model either called the
Skill tool on its own or answered without it.

toSdkUserMessage now moves the typed block to the end when it parses as
a slash command. Attachments rendered as text always carry a mimeType,
so a quoted "/command" inside chat history is never mistaken for the
invocation. Message order is unchanged when the typed text is not a
slash command.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Joe Corkerton <joecorkerton@users.noreply.github.com>
2026-09-23 06:45:57 +02:00
paseo-bot[bot]andHMWCS 8269a0ea78 Keep approvals with the user in every Codex mode but Auto-review (#5239)
A Codex mode preset named its approvals reviewer only for Auto-review.
Every other mode left the field off the thread/start and turn/start
request, so the Codex app-server fell back to `approvals_reviewer` from
the user's global config, or to whatever reviewer the live thread was
already using. Default Permissions and Read-only then behaved like
Auto-review: escalations were approved by Codex, with no approval card
and no notification.

The reviewer belongs to the Paseo mode, so the preset now always names
it and the request always carries it. An agent with no Paseo mode still
inherits the Codex configuration, unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HMWCS <121323913+HMWCS@users.noreply.github.com>
2026-09-23 05:46:12 +02:00
paseo-bot[bot] 5303313632 Keep an archived worktree restorable after a failed re-import (#5238)
* fix(server): keep a workspace's placement when its directory is gone

Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.

That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.

Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.

Closes #5111

* fix(server): treat a directory lost mid-read as an absence

A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 05:46:08 +02:00
paseo-bot[bot]andpaseo-bot[bot] 81ad00790f Let Stop settle an agent whose Pi or OMP runtime has exited (#5235)
* Let Stop settle an agent whose Pi or OMP runtime has exited

AgentSession.interrupt() is documented to reject "only when foreground
ownership is still uncertain", and to be a successful no-op when the
session is already idle. The Pi and OMP sessions broke that: interrupt()
issues `abort` (and `clear_queue` for Pi) over the JSONL RPC transport,
and once the child process is gone every request rejects with
"<name> process is closed".

AgentManager reads that rejection as "the provider still owns the turn",
returns `refused`, and leaves the tracked run in place. The user gets
"Cannot stop agent <id> because its active run cancellation was not
acknowledged", the agent stays pinned at `running`, and pressing Stop
again repeats it forever — only a daemon restart clears it.

A dead runtime owns nothing: no turn is running and no prompt is queued,
which is exactly the postcondition abort and clear_queue promise. Route
both through JsonlRpcProcess.requestStopWork, which resolves when the
process is gone and still surfaces real failures from a live process.
The check covers the process dying while the request is in flight, so a
race cannot reintroduce the wedge.

* Require an observed child exit before treating stop work as done

`disposed` marks the transport finished, not the child gone: `close()`
disposes before termination completes, a spawn error disposes without
touching the child, and a child can outlive SIGKILL. Keying
requestStopWork on it could report a turn canceled while the child was
still working.

Track the child's exit event instead, and resolve only on that. A
disposed transport whose child has not exited still rejects, so the
caller keeps refusing a cancellation it cannot prove.

Also move the in-flight tests' command detection into named
`exitOnCommand` helpers, and reuse the extracted `onOmpCommand` for
`replyToCommands` so the OMP file matches the Pi one.

* Let a stop survive an stdin failure that precedes the child's exit

A dying child breaks its stdin pipe a fraction before it emits `exit`.
The broken pipe fails the in-flight abort and starts a termination, and
deciding at that moment saw `exited` still false, so the stop was
refused for a runtime that was already on its way out. That is the
reported wedge again, reached through a narrower race.

close() now records the termination it starts, and requestStopWork waits
for it before deciding. A child that goes on to exit resolves the stop; a
child that outlives SIGKILL still rejects it.

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-23 04:45:08 +02:00
paseo-bot[bot] 290306fd16 Read an archived agent's logs after its worktree is removed (#5229)
* Read an archived agent's history without its working directory

Loading an archived agent for history rejected when its working
directory no longer existed, so `paseo logs <archived-agent>` and
`get_agent_activity` failed with "Working directory does not exist"
once the workspace archive removed the worktree the agent ran in.

Resuming a persisted session already distinguishes an interactive
resume from a history load, but residency was settled after the
session config was prepared, so the directory check ran for both.
Settle residency first and require the directory only for a session
that will actually run in it.

* Assert the archived transcript is readable, not just loadable

* Let archived timeline reads succeed without resurrecting the workspace

The auto-archive test asserted that history reads for an archived agent
in a removed worktree are rejected. That was the old behavior, not the
invariant its comment describes. Assert the invariant instead: the
reads succeed, the removed worktree stays removed, no workspace
observation comes back, and the next agent lifecycle is unaffected.

* Archive the agent only after its turn is finalized

The regression test dispatched a run and archived immediately. Dispatch
resolves before the provider appends the reply to its history, so the
transcript the test reads back could be written after the archive, and
the read could find nothing.

Wait for the turn to be finalized first. The provider appends the
assistant message before it emits turn_completed, so once the turn is
finalized the transcript is already on disk.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 02:45:28 +02:00
paseo-bot[bot] 3a36cc33c6 Keep workspaces on a disk or share that is not mounted (#5227)
* fix(server): keep workspaces whose project root is unreachable

Reconciliation archived every active workspace whose cwd returned ENOENT on
a single stat, including the pass that runs at daemon startup. An external
disk that has not mounted yet, or a network share that is briefly down, made
every workspace on it disappear from the sidebar along with its sessions, and
nothing brought them back when the directory returned.

A missing path is only evidence that a workspace was removed when the project
it belongs to is still readable. When the project root is missing too, the
whole location is unavailable, and projects already persist through that.
Workspaces now follow the same rule.

Closes #4982

* fix(server): read project roots after workspace directories

Reading the project roots first left a window where a volume could go away
between the two reads, marking a project reachable while its workspace looked
missing. Reading them after inverts the skew: a volume that disappears mid-pass
makes the project unreachable, which withholds an archive instead of causing
one.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-23 00:45:12 +02:00
paseo-bot[bot] 17c5050216 Show a fork checkout's pull request in the workspace (#5221)
* Resolve a fork checkout's pull request by its head repository

A fork checkout's pull request lives in the parent repository, and its
head repository is the only thing that tells it apart from every other
fork's pull request on the same branch name. The per-checkout lookup
asked `gh pr list --repo <parent> --head <forkOwner>:<branch>`, which
`gh` cannot answer: it forwards the whole value as a head branch name,
so the query always came back empty. Every synchronous refresh then
overwrote the status the background poll had resolved, and the workspace
showed no pull request.

Fork checkouts now resolve through the REST pulls endpoint, the one
lookup that takes a head repository, and read each resulting pull
request through the ordinary view path. That also removes the
disambiguation the bare branch name would have needed: `--head main`
already returns thirty pull requests upstream, far past the ten-item
candidate page, so a fork checkout on a common branch name would still
have lost its own pull request.

The three existing fork tests asserted the unanswerable invocation while
their fake runner answered it regardless of the head value. They now run
against a runner that matches head refs the way `gh` does.

* Let a failed fork lookup surface, and keep the checked-out attempt

Two corrections to the fork pull request lookup.

An operational failure on the REST lookup, a rate limit, a timeout, a
DNS failure, was caught and returned as an empty list, so a refresh
reported "no pull request" for a checkout that has one. The lookup now
only handles what it can act on and lets the rest reach the caller, the
same way the non-fork list path does.

A fork branch reused for several attempts can carry the checkout's head
commit on an older pull request than the three the lookup reads. The
page is now requested in full and the attempts carrying the checked-out
commit are read first, so the bound on full reads no longer decides
which attempt the workspace can see.

* Use a function declaration for the head-commit predicate

* Use a function declaration for the test pull request helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-22 23:44:49 +02:00
morvenandMohamed Boudra 5db3bc82fd fix(app): reattach workspace labels when a connection is restored (#5079)
* fix(app): reattach workspace labels when a connection is restored

connectionChanged() drops the workspace label replica on every connection
change but never puts it back: the only caller of connectWorkspaceLabels()
is refreshAll(), which is one of the two branches of requestDemandRefresh().
Both branches mark the connection epoch as satisfied, so when route-only
demand refreshes first, the later full-directory demand early-returns and the
label channel stays detached for the whole connection.

The picker then renders rows merged from other online hosts, every row
disabled and no explanation, since the offline case has no MenuHint.

Attach the replica where the subscriptions are restored, so it no longer
depends on which demand branch wins the epoch.

* fix(app): keep the label replica attached across refreshes

Reattaching on every online connection meant refreshAll() could reconnect a
replica that was already attached, and connect() begins by disconnecting,
which publishes an offline status. The picker disables every row while a host
is offline, so a plain refresh made the menu flicker disabled.

Reuse the live connection when the same client is already attached and just
refresh the catalog. Cover both the flicker and the reconnect path, which is
the case the fix is named after.

* test(app): verify workspace labels after route refresh

The route-first regression must expose the restored label catalog used by sidebar chips, and the test client must accept the directory query passed by the public observer.

* fix(app): show label catalog load errors in picker

An attachment failure reaches the label host snapshot, but the picker only showed mutation errors. Surface the host error beside the disabled or empty label list so the failure is visible in the affected workspace.

* test(app): keep label attachment coverage in directory suite

The directory fake already owns the connection and demand setup. Reuse it for the route-first, full-demand, refresh, and reconnect cases so this regression follows the existing suite and cleanup.

* fix(app): localize label catalog load errors in picker

The host snapshot retains diagnostic details, while the workspace picker gives users the translated catalog-load message.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-22 23:38:50 +02:00
Mohamed Boudra 3dbbbb535e Sponsor page rework with paid homepage sponsor spots (#5219)
* feat(website): rework sponsorship with homepage sponsor spots

The founder note now states how the project is funded (sponsorship and
Paseo Hub) instead of describing the product, and the same note and
backing links render on the homepage and /sponsor from one module so the
two cannot drift.

Companies can sponsor one of four homepage logo spots at $500 a month
through Stripe rather than GitHub Sponsors, because they need invoices
and a vendor relationship. The spots render as placeholders until sold;
approving a sponsor is an entry in src/data/sponsors.ts. The checkout
URL is a placeholder until the Stripe Payment Link exists.

* feat(website): point sponsor spot checkout at the Stripe payment link

* docs(readme): add a sponsors section

* docs(readme): add the sponsors section to the translated READMEs

* docs(website): document the sponsor approval and Stripe cap workflow
2026-09-22 22:54:11 +02:00
a3fc59e818 Keep a project in the sidebar when its folder name ends in a space (#5205)
* fix(app): keep sidebar order keys that end in whitespace

A project view key embeds the project's path, so a directory whose name
ends in a space produces a key that ends in a space. The sidebar order
store trimmed every key on write, so the stored key could never match
the key the sidebar looks up. computeSidebarOrderUpdates saw the project
as missing from the order on every pass, appended it, and the store
trimmed it away again, setting a fresh array each time. The effect that
applies those updates re-ran on every render until React gave up with
error #185, and the app failed to load until the folder was renamed.

Keep keys verbatim on the runtime setters, dropping only blank keys and
duplicates. Migration of persisted state still trims, where a stray
space is an artifact of an older format rather than part of the key.

Fixes #4880

* test(app): give AsyncStorage a real in-memory stub instead of mocking it

`docs/testing.md` bans `vi.mock`, and the test added here mocked
`@react-native-async-storage/async-storage` only because the module has no
Node implementation: its default export reaches for the native module, so
every write from the persist middleware rejects and a test that touches a
persisted store drowns in unhandled rejections.

That is exactly the case `test-stubs/` exists for, reached through the
resolve.alias in `vitest.config.ts` the same way react-native-unistyles,
react-native-svg and expo-linking are. The stub is a real in-memory
key-value store with the same contract, so a test can read back what a
store wrote rather than inspect a recording double.

`sidebar-view-store.test.ts` carried the same mock for the same reason and
no longer needs it either.

* test(app): give the AsyncStorage stub the multi-key calls the app uses

`provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and
is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove`
during reconciliation and cleanup. The stub stopped at the single-key calls,
which is fine until a test reaches the provider cache and then fails with
`storage.multiGet is not a function`.

`multiSet` comes along with them so the three multi-key calls behave as a set
rather than leaving the next caller to notice the gap the same way.

* test(app): cover a project folder name ending in a space end to end

Adds a browser regression for #4880: the daemon accepts a directory whose
name ends in a space, and the sidebar has to render its project row instead
of tearing the app down with "Maximum update depth exceeded" on every load.

* test(app): move the trailing-space spec setup behind a seeded-project helper

docs/testing.md asks for E2E bodies that read like plain English with the
setup in helpers, and for helpers that own the project record until cleanup.
The spec did its directory creation, daemon seeding, and removal inline.

seedProjectNamedWithTrailingSpace owns the directory and the daemon project
and returns a cleanup, the same shape seedWorkspace returns, which cannot be
used here because mkdtemp appends the random suffix and so cannot produce a
name that ends in a space. The body is now goto, assert the row, assert no
error screen.

The row is still located by its sidebar-project-row test id, which is how
every other sidebar spec and the shared sidebar helpers address a project row.

---------

Co-authored-by: L4XB <lukas.buck@e-mail.de>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-09-22 22:06:24 +02:00
paseo-ai[bot] bc5bc969c8 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 18:44:27 +00:00
Mohamed Boudra 8186585201 chore(release): cut 0.9.1 2026-09-22 20:34:13 +02:00
Mohamed Boudra eb665a55a8 docs(changelog): add 0.9.1 entry 2026-09-22 20:30:04 +02:00
Mohamed Boudra d22737c77a chore: keep heap snapshots out of git
The daemon dumps a heap snapshot next to the server package when it nears
its heap limit. Those files are multi-gigabyte debugging artifacts we want
to keep on disk, not stage.
2026-09-22 20:30:04 +02:00
aeb98f813a feat(claude): add Opus 5.5 with its model capabilities (#5200)
feat(claude): add Opus 5.5 with its model capabilities

Combine the catalog and runtime identity work from #5191 with the
per-model effort default from #5197. Claude Code 2.1.280 introduces
Opus 5.5 with 1M context, medium effort, and always-on thinking.
Resolve prefixed minor model releases before their major version so
Opus 5.5 retains its identity and capabilities after a turn.

Co-authored-by: Leonardo G. Urci <leonardoguimaraesurci@gmail.com>
Co-authored-by: Sebastian Galindo <seb@jexhq.com>
Co-authored-by: Rodrigo <rodrigo.parisusao25201@gmail.com>
2026-09-22 20:18:00 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
paseo-ai[bot] 91d9cf1dbd fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 10:36:31 +00:00
Mohamed Boudra 7f7e60bcbb chore(release): cut 0.9.0 2026-09-22 12:25:40 +02:00
Mohamed Boudra 8b6e9447f4 docs(changelog): promote 0.9.0 to stable
Collapses the 0.9.0-beta.1 and 0.9.0-beta.2 entries into one stable entry
covering v0.8.0..83f9fba20. Adds #4413; the other post-beta.2 fixes correct
regressions introduced inside this same range.
2026-09-22 12:20:28 +02:00
Mohamed Boudra 83f9fba209 Keep the draft-create handoff until the new chat is current (#5168)
* fix(app): keep the draft-create handoff until the new chat is current

Creating an agent from the draft composer on web flashed the "Updating
messages" panel toast for 40-200ms. That toast means "this chat may be out
of date, we are catching up" — a chat this client just created cannot be.

The timeline reducer released the create handoff at the first synchronized
page, but the visibility catch-up flag only clears once catch-up settles,
and a fresh chat always has a parked follow-up "after" page because live
rows arrive during the tail fetch. Between the two releases the state
machine saw a hydrated chat with no handoff and a pending catch-up, which
resolves to the resume toast. #4863 turned that branch from silent to
status, which is what made the window visible.

Both signals now flip in one synchronous call: createViewedTimelineSync
gains an onTimelineCurrent port, called from setVisibilityCatchUpReady —
the single place the sync declares a chat current, already covering the
parked follow-up and the fetchLatestTail overflow path. The owner
implements it by clearing the handoff. The optimistic "running" override
therefore lasts one extra round trip.

The state machine and sync-status rules are unchanged; they were correct.

* fix(app): release the create handoff when a chat leaves timeline demand

Closing a new chat's tab before catch-up settled left its sent attempt in
useCreateFlowStore forever. Reopening it in the same session then supplied
optimistic-create continuity for an authoritative chat: the panel showed it
as running and suppressed "Updating messages" while the real catch-up ran —
the state this fix exists to protect. An offline or slow host widens that
window from milliseconds to minutes.

Leaving the demanded set ends the owner's obligation to a chat exactly as
reaching current does, so onTimelineCurrent becomes onCatchUpEnded and fires
on both. It reports after the membership commit, so the sync never describes
itself mid-transition. Disconnect and backgrounding preserve demand and
therefore preserve the handoff.
2026-09-22 11:57:45 +02:00
Mohamed Boudra b2ce2bcb83 Count chat Find matches across the whole chat (#5167)
* fix(app): count chat Find matches across the whole chat

Chat Find searched the whole chat but its counter read "1 of N in
message", where N was the occurrences inside the selected message. The
per-message split is an implementation detail of how the client verifies
and highlights a hit; a reader counting matches is counting the scope
they searched. The counter now reads "3 of 7" for the position across
every message that holds a hit, and Next/Previous walk that sequence,
crossing message boundaries and wrapping at the ends. Per-message
occurrence stays inside the model as navigation state.

The total needs a per-message count for messages that are not on screen,
so the daemon now reports one. It counts with the same matcher the
browser uses on rendered text — query words joined by any whitespace,
case-insensitive, never across a Markdown block boundary — which also
stops it from reporting locations the client would reject. The client
replaces the estimate with the rendered count as each message is
revealed, so the total is exact for everything that has been on screen.

`count` is optional on the wire and tagged COMPAT(timelineSearchCount):
a host that omits it is worth one match per located message until a
reveal corrects it.

* fix(server): stop chat search reporting matches the browser cannot show

Falling back to the whole Markdown source when the per-block scan found
nothing reported a match whenever the query words straddled a block
boundary — "one target" across "target one\n\ntarget two" — and on
syntax the reader never sees. The client always rejected those
locations, so before the whole-chat counter the cost was a wasted round
trip. Now that the total sums the host's per-message counts, a rejected
location inflates the number the reader sees until navigation reaches
that message and drops it. The block scan alone is the whole rule.

The model's Location derives from the wire type again, with count
narrowed to required: the search boundary fills in the COMPAT default,
so the interior never re-checks it.
2026-09-22 10:36:13 +02:00
Mohamed Boudra 135a3b4c9e Find text in assistant messages that streamed in the open chat (#5146)
* fix(app): find text in assistant messages that streamed in the open chat

Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.

The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.

* fix(app): let the Markdown parser decide which blocks are link definitions

A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
2026-09-21 22:31:08 +02:00
Mohamed Boudra 2c8e8a8268 fix(app): use Command+F for Find on macOS across chat, file, and terminal (#5129)
Control+F moves the caret forward on macOS, so chat Find and file pane Find
were stealing a keystroke the text editor owns. Chat Find listens on
`document` in the capture phase, so the composer received an already-cancelled
Control+F and the caret never moved.

Each Find surface carried its own copy of the shortcut predicate and only the
terminal's was platform-aware. There is now one predicate, `isFindShortcut` in
`@/pane-find`, that takes the platform as a parameter: Command without Control
on macOS, Control without Command elsewhere, no Shift or Alt. Chat Find, file
pane Find, the Find widget's own query box, terminal Find, and the terminal
emulator runtime all call it, and `terminal-find-shortcut.ts` is gone.

App surfaces source the platform from `findShortcutPlatform()`, which wraps the
app's existing `getShortcutOs()`. The predicate stays free of React Native
imports because the terminal emulator runtime is bundled on its own into the
terminal WebView; its default platform and `getShortcutOs()`'s browser branch
now share one user-agent read in `@/utils/mac-user-agent`.

Closes #5097
2026-09-21 12:34:41 +02:00
ee7949ae25 fix(pi): honor per-model thinking configuration (#4413)
Derive Pi thinking options from each model's thinkingLevelMap, read Pi's effective level back after create, resume, set_model and set_thinking_level, surface failed state reads instead of persisting the requested level, and show the thinking control for single-level models.

Closes #4382

Co-authored-by: therainisme <therainisme@qq.com>
Co-authored-by: trim21 <i@trim21.me>
2026-09-21 11:53:52 +02:00
paseo-ai[bot] d636abd7a4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-18 09:49:37 +00:00
Mohamed Boudra e9d32a17d6 chore(release): cut 0.9.0-beta.2 2026-09-18 11:41:13 +02:00
Mohamed Boudra 0fd1db5748 docs(changelog): add 0.9.0-beta.2 entry
Covers v0.9.0-beta.1..9696f4226. Only #5040 is user-facing; the other
commits in range are CI and Nix hash changes.
2026-09-18 11:36:22 +02:00
Mohamed Boudra 9696f42264 Subscribe only the chats this session opened (#5040)
* fix(app): subscribe only the chats this session opened

Open-chat membership was sourced from the persisted workspace layout, which is
restored from disk at launch and carries a tab for every chat the user has ever
opened on the host. Every member got an authoritative timeline catch-up at
session start, and each of those fetches resumed its agent on the daemon: one
provider session per historical tab, every launch.

Two things followed from each resume. Registration stamped `updatedAt` over the
restored timestamp, and the resumed agent started with no attention because the
resume path never read the stored flag. Both were persisted, so restarting Paseo
rewrote every workspace's last-used time in the sidebar and cleared Ready to
review on chats the user never looked at. The timestamp loss was permanent:
workspace `statusEnteredAt` is re-derived from `updatedAt` on every daemon start.

Keep the open set on the session instead. A chat joins it the first time the user
opens it and stays until its tab closes or the session ends, so hiding, switching
workspaces, evicting a retained view, backgrounding and reconnect all preserve the
subscription, with no recent-agent limit. Layout keeps its release role and loses
its source role.

Harden the producer as well, so a resume from any caller stays inert: registration
adopts restored timestamps rather than stamping now, and resume carries the stored
attention through.

* fix(agents): keep a resumed agent's updatedAt from moving backwards

Dropping the registration stamp exposed a skew the stamp had been erasing.
`extractTimestamps` read `lastActivityAt ?? updatedAt`, but the two diverge:
renaming, labelling, restoring from archive, marking unread, and clearing
attention all move `updatedAt` on an unloaded agent without touching
`lastActivityAt`. Eight of 854 agent records on one dev machine carry that skew,
up to eight days of it.

A resume then handed clients a timestamp older than one they already held, and
`acceptAgentDirectoryUpdate` drops any state update that goes backwards. Marking
a chat unread without opening it, then opening it, left the unread dot stuck in
History, the agent list, Command Center and the tab: the daemon cleared attention
and the client refused the update.

Read both fields and take the newest, which is already the rule
`resolveStoredAgentPayloadUpdatedAt` applies to the list payload. Move that
function beside the other stored-record readers as `resolveStoredAgentUpdatedAt`
so one rule covers both paths.

Also replace the `!options?.updatedAt` inference in `registerSession` with an
explicit `restoring` flag. The inference was correct for all four call sites, but
it made a second meaning out of a field, and the next caller to seed a timestamp
would have opted out of activity stamping silently.

* test(app): assert a reloaded layout subscribes only the chat it restores into

No e2e reloaded the page with a populated workspace layout and asserted which
agents the daemon acknowledged, so the regression this branch fixes had no
end-to-end guard. Opening four chats across two workspaces leaves a tab per chat
in persisted layout; after a reload only the restored chat may be subscribed,
because subscribing resumes that agent on the daemon.

Verified it fails when session-context's layout callback is pointed back at
replaceVisibleAgentIds: the three restored tabs nobody opened join the
acknowledged set.
2026-09-18 09:12:43 +00:00
Mohamed Boudra 3cc4ae2866 ci(desktop): retry release asset uploads
uploads.github.com returned HTTP 500 five times during the v0.9.0-beta.1
release, each costing a ~50 minute rebuild. The upload step passed every
artifact to one gh invocation, so a single failed asset discarded the rest
and failed the job.

Upload each asset separately with bounded retries, shared by the macOS,
Linux and Windows jobs.
2026-09-17 22:50:13 +02:00
Mohamed Boudra 0e965bcd7a ci(desktop): raise Node heap for macOS release builds
Metro's jest workers OOM transforming the generated ws-outbound.aot.js
validator, the last module in the web bundle. Two consecutive v0.9.0-beta.1
macOS x64 builds died at 5081/5082 modules on that file.

Set max-old-space-size per runner: 8192 on the 14 GB Intel runner, 4096 on
the 7 GB arm64 runner.
2026-09-17 21:58:02 +02:00
paseo-ai[bot] 2f3272490e fix: update lockfile signatures and Nix hash [skip ci] 2026-09-17 17:49:56 +00:00
Mohamed Boudra 7c1958f5b0 chore(release): cut 0.9.0-beta.1 2026-09-17 19:39:33 +02:00
Mohamed Boudra ed9b51f94e docs(changelog): add 0.9.0-beta.1 entry
Covers v0.8.0..5d70ab2ab (57 PRs). Streaming-fade work (#4859) is omitted:
it landed and was reverted by #5013 within this range.
2026-09-17 19:30:13 +02:00
Mohamed Boudra 5d70ab2ab8 fix(app): restore character-paced streaming replies (#5013)
* fix(app): restore character-paced streaming reveal

Restore the v0.8 character reveal model and remove the word-level fade implementation so streamed assistant and reasoning text remains opaque, grapheme-safe, and immediately completes when a turn ends.

* fix(android): preserve inverted timeline selection

React Native's prebuilt Android library does not consume source patches under node_modules. Register the compiled scroll manager before the core package so inverted child-reveal calculations leave focus and text selection in place while normal and explicit scrolling remain unchanged.
2026-09-17 18:32:12 +02:00
paseo-ai[bot] dc682adb2a fix: update lockfile signatures and Nix hash [skip ci] 2026-09-17 13:27:48 +00:00
Mohamed Boudra 92b92a81c8 perf(desktop): reduce daemon management memory overhead (#5007)
Desktop and CLI helpers imported daemon bootstrap through the server root, while supervisor configuration retained the full wire schema tree. Expose existing capabilities through narrow package entrypoints and share configuration schemas without loading message definitions. Guard the dependency trees without tree-shaking so unused exports cannot hide the runtime cost.
2026-09-17 15:18:12 +02:00
Mohamed Boudra bb4763b38c fix(app): open chat find from the focused composer (#4991)
* fix(app): open chat find from the focused composer

Listen during capture so the composer text input cannot swallow the find shortcut before it reaches chat search.

* test(app): verify draft retention on first chat find
2026-09-17 10:30:26 +02:00
Mohamed Boudra 047f40e623 Install npm plugins and review source updates (#4975)
* feat(plugins): unify source installation and simplify plugin settings

* feat(plugins): install npm sources and review CLI updates

Treat install selectors as one-time choices. Keep source identity separate from the installed revision, and resolve ordinary updates from the current upstream default. npm only acquires directories; plugin loading remains independent of npm.

* fix(plugins): preserve CLI diagnostics and exercise reviewed updates

Move new source capability coverage to real browser journeys. Update the requirements regression to exercise preview and apply, and preserve the existing duplicate-selector diagnostic across positional option scopes.

* test(plugins): remove unused pending helper

* test(plugins): pack registry fixtures with the Windows command runner

* test(plugins): assert failed client contribution setup

* docs(plugins): consolidate guides and document npm publishing

* docs(plugins): introduce publishing choices before npm instructions

* test(plugins): own browser journey cleanup in a fixture

* feat(plugins): prepare scaffolded packages for publishing
2026-09-17 10:22:20 +02:00
Mohamed Boudra 1e4ba65c6d fix(website): render phone mockups correctly in Safari (#4990)
* fix(website): render phone mockups correctly in Safari

Replace trigonometric CSS scaling with measured numeric ratios after Safari placed phone content outside its clipped frames. Scale desktop mockup corners with the frame so they remain proportional on mobile.

* test(website): expose the website browser test suite

* test(website): run mockup regressions in CI
2026-09-17 10:15:15 +02:00
Mohamed Boudra 6830c46e9a fix(app): keep composer layout stable across chat and workspace screens (#4973)
* fix(app): dock the New workspace composer like a chat on phones

Only the composer is bounded by the viewport; the setup fields sit above it in a
bottom-anchored stack that moves up as the draft grows. The keyboard reservation
is retained on every screen, so the composer keeps one height across keyboard
transitions. Removes the per-screen reserve policy.

* fix(app): share composer docking across mobile hosts

Extract chat's retained capacity and platform keyboard motion so chat,
workspace drafts, and New workspace share one sizing and dismissal boundary.
Keep the native drivers and settled portal reconciliation unchanged.

* fix(app): preserve overlays in centered composer docks

Honor the optional overlay child in both layouts so changing dock placement does not silently discard part of its content.

* test(app): resolve keyboard controller through its web entry

The shared keyboard entry point makes stream tests load the provider. Keep its real ESM dependencies inside the Vitest resolver so CommonJS does not bypass the React Native web alias and parse native Flow sources.

* fix(app): keep native chat scrolling responsive

Place keyboard dismissal behind scrollable content so an idle Android Fabric JS responder cannot intercept native drags. Keep decorative form padding transparent to iOS hit testing and exercise idle native scrolling after submission from every composer host.
2026-09-16 23:43:15 +02:00
Mohamed Boudra c7db3c5a8c Let plugins open external links and workspace browsers (#4972)
* feat(plugins): open external links and workspace browsers

* fix(plugins): refuse invalid links and unavailable browser workspaces

* docs(plugins): clarify external links and browser navigation
2026-09-16 23:25:17 +02:00
Mohamed Boudra a8a8da0472 fix(plugins): preserve nested provider subagent links (#4970)
* fix(plugins): preserve nested provider subagent links

The plugin adapter routed descendants to their managed root but discarded their direct parent when publishing descriptors. Preserve runtime-to-public child identities and forward optional spawning tool IDs.

* test(plugins): separate opening and live subagent scenarios

* test(server): retry terminal fixture directory cleanup

Match the daemon harness cleanup policy so transient Windows EBUSY locks do not fail terminal ownership tests after their assertions complete.
2026-09-16 22:50:58 +02:00
Mohamed Boudra 6ce6f4c961 Let plugins discover hosts and use host-targeted SDK clients (#4971)
* feat(plugins): expose hosts and host-targeted SDK clients

* test(plugins): verify exact cross-host observation cleanup

* fix(plugins): reacquire explicitly disposed host clients

* docs(plugins): clarify host client usage and lifecycle

* docs(plugins): use theme colors in host discovery example
2026-09-16 22:45:18 +02:00
paseo-bot[bot] 72e3d6957a Let plugins navigate across hosts (#4942)
* feat(plugin): support cross-host navigation

Keep route construction inside Paseo while letting plugin surfaces target a host other than the currently selected one. Calls without a server ID retain selected-host behavior.

* test(plugin): cover cross-host navigation

Exercise workspace and agent navigation against a second live daemon so the SDK contract is verified through the caller-visible route instead of mocked helper forwarding.

* test(plugin): clarify cross-host navigation journey

Keep the scenario at the caller-visible intent level while shared helpers own route waits and visibility mechanics.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-16 20:27:15 +02:00
Mohamed Boudra 46529146ac Show pending file attachments and keep uploads responsive (#4958)
Show a spinner attachment while a selected file is read and uploaded, yield between 128 KiB chunks so the UI stays responsive, validate every file in a batch before sending any of them, and leave one SelectedFile shape across the picker, drag-and-drop, and the upload path.
2026-09-16 20:06:02 +02:00
Mohamed Boudra 4f710d9930 feat(app): auto-open the pull request tab once when a PR is detected (#4956)
* feat(app): auto-open the pull request tab once when a PR is detected

* docs(app): clarify automatic pull request tab policy
2026-09-16 18:28:14 +02:00
Mohamed Boudra 0f20e6dfe4 Keep History search chronological and make matches clear (#4945)
* fix(history): preserve chronology and clarify search matches

Keep workspace names primary and highlight each searched field so results explain why they matched. Filter before cursor pagination and retain date buckets.

Constrain shared subsequence matching to each whitespace-delimited word while preserving fuzzy abbreviations in Command Center and other consumers.

* test(history): reuse response helper in pagination regression

* test(history): express browser journey through named helpers

* fix(app): start menu entering animation only once the surface is placed

Reanimated's web entering path snapshots the element rect at animationend and
writes top/left back when it cleans up the keyframe 750ms later. When the
150ms animation ends before anchor and content measurement resolve, that
snapshot is the off-screen measuring position and the open menu jumps back to
-9999. Remount the surface when its position resolves so the animation starts
from the final position.
2026-09-16 16:17:46 +02:00
Mohamed Boudra 0e8a71a5fe fix(app): stabilize native composer sizing after edits (#4946)
* fix(app): stabilize native composer sizing after edits

* test(app): verify workspace composer space after keyboard dismissal

* test(app): start the hold-to-delete regression mid-draft

From the end of the draft the pre-fix input also returns to baseline. Placing the cursor
eight lines into the draft before the first hold-delete leaves the old build at the
capped height with the placeholder visible, and the fixed build at one line.
2026-09-16 15:51:22 +02:00
Mohamed Boudra a78949d1d4 Preserve committed changes when restoring archived workspaces (#4926)
* fix(server): preserve comparison base when restoring worktrees

Restore archived worktrees from the workspace's saved comparison base instead of using the feature branch as its own base. Preserve the branch HEAD and allow restore when an exact comparison ref no longer exists.

* test(app): isolate draft preference checks from menu animation
2026-09-16 14:13:14 +02:00
Mohamed Boudra 64b1a62ed3 Fix voice reply latency and hide internal prompt wrappers (#4927)
* fix(voice): speed up replies and hide prompt wrappers

Keep voice prompt cleanup in the client presentation path so provider history and live messages render consistently without rewriting canonical input. Correct native TTS scheduling and threading, and reconnect interruption to speech detection and session cancellation.

* test(voice): clarify playback and timeline regression journeys

* test(voice): centralize native worker cleanup and repair fixture path

* test(app): exercise workspace eviction without reloading the app

* test(app): preserve streamed output during workspace eviction

Hold the turn until the workspace is evicted instead of dropping all stream events during navigation. Slow CI runners can finish the entire response before navigation ends; waiting for producer completion before release makes that ordering deterministic.

* test(app): stabilize subscriptions during workspace eviction

Opening another chat replaces the timeline subscription, making buffered frames from earlier generations unroutable. Preopen all chats before holding the stream, finish production before eviction, and assert both early and final response content with no subscription replacement.
2026-09-16 13:36:44 +02:00
0eac75be7d fix(server): stop an expired application lease from permanently killing a plugin session (#4912)
* fix(server): keep plugin sockets out of the application socket lease

A plugin's IPC socket is claimed by the application socket lease as soon as
its client sends its first ping. If that client's heartbeat is then starved
for longer than APPLICATION_SOCKET_LEASE_MS — a blocked event loop, a
SIGSTOP, a sleeping machine — the daemon reaps the socket with "Closing
physical WebSocket with expired application lease".

The plugin never comes back. plugin-process.ts builds its DaemonClient with
`reconnect: { enabled: false }`, so unlike an app or menubar client it cannot
redial, and there is no "Client disconnected; waiting for reconnect" because
no reconnect is ever scheduled. Every later `paseo.*` host API call then
fails with "Transport not connected (status: disconnected)" while
`paseo plugin ls` still reports the plugin as running. Only
`paseo plugin reload <id>` recovers it.

The lease is a backstop for an abandoned application socket. A plugin socket
is IPC to a child this daemon spawned and already supervises through the
child's own close, so heartbeat silence there is not evidence of abandonment
— it only means the child is busy or paused. Reaping it strands a plugin that
was about to resume, which is strictly worse than leaving it attached.

Ordinary sockets are unaffected and still reaped on lease expiry; the second
test pins that down so the exemption cannot widen unnoticed.

Fixes #4883

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): let a plugin session recover when the daemon closes its socket

The lease exemption stops the common cause, but any daemon-side close still
stranded a plugin for good: the child's DaemonClient was built with
`reconnect: { enabled: false }`, and even with it enabled the child had
nothing to redial onto, because the runtime attached exactly one
PluginSessionSocket per load and routed frames to that captured const.

Enable reconnect on the plugin client, and have the runtime stand up a
replacement session when a socket closes underneath a live child. Frames now
follow the current socket, and `loaded.sessionSocket` / `sessionClosed` track
the replacement so stop and teardown still await the right attachment. The
session host already supported this — attaching a second socket for the same
plugin id gives it a second, independently owned session.

A plugin on its way out must not be resurrected. Both stop paths
(`stopPluginById`, `stopAll`) clear the catalog entry before they close
anything, so the catalog check is what distinguishes a shutdown from a socket
worth replacing; a dead child is caught by `child.connected`.

The client waits DEFAULT_RECONNECT_BASE_DELAY_MS (1500ms) before redialling
and gives up after DEFAULT_CONNECT_TIMEOUT_MS, so the replacement is attached
long before the first attempt arrives, and a lost race would retry anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(server): trim the comments on the plugin session fix

The reasoning lives in the commit messages and the PR. Leave only what the
code cannot say for itself: why a plugin socket is not an application socket,
and why the catalog check is what distinguishes a shutdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): re-attach a plugin session on the child's redial, not on close

Attaching eagerly when the socket closed put a socket in place that nobody
was speaking to yet. Two ways that goes wrong, both reachable under exactly
the conditions #4883 describes - a child whose event loop is starved:

- Nothing sends a hello on the replacement, so the host closes it after
  HELLO_TIMEOUT_MS (websocket-server.ts:1310), and that close attached
  another. A wedged child drives that cycle until it exits, filling the
  plugin's 500-entry log ring with re-attach lines.
- Frames the child had already queued arrive after the close and land on the
  replacement while it is still pending, which closes it with
  WS_CLOSE_INVALID_HELLO (websocket-server.ts:2151) and attaches another.

Route frames through the current socket instead, and stand a replacement up
only when a hello arrives while the old one is closed - the one point that
can tell leftovers of the dead session from a genuine redial, since the IPC
channel carries no session id. A starved child now attaches nothing until it
wakes up and redials.

Also re-check the guards after the attach await and close the replacement if
they no longer hold, and surface a failed re-attach through appendLog and
notify rather than only a daemon log line, so a plugin left without a session
is visible instead of silently repeating the original bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(server): follow the server style guide and drop test timing

Two review findings against packages/server/CLAUDE.md.

Helper shape (CLAUDE.md: `function` declarations over arrow assignments,
object parameters when a function takes more than one argument). The
re-attach helpers were arrow assignments closing over `loadPlugin` locals, and
two took `(frame, isBinary)` positionally. They are now private methods taking
a single object. The mutable session state they shared - the current socket,
the in-flight flag, the loaded plugin - moves into a PluginSessionBinding the
methods read, which is what made the closures hard to lift out before.

Test determinism (CLAUDE.md: no timing/randomness). The reconnect tests waited
on fixed 500ms sleeps and 15s vi.waitFor polling windows, so they inferred
lifecycle state from elapsed time. The tracked session host now exposes
waitForHellos, resolving on the handshake itself, and the lazy-attach case
runs against a stub child that is driven frame by frame: close, then a stale
frame that must not attach, then a hello that must. No sleeps and no polling
remain in these tests, and the file's suite drops from ~40s to ~11s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(plugins): verify host API and observation recovery

* test(plugins): await connection and catalog events

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-16 00:48:18 +02:00
Mohamed Boudra 7557992dff fix(app): stabilize composer height and isolate typing updates (#4902)
* fix(app): preserve composer capacity when the keyboard closes

Releasing the keyboard reservation enlarged an unchanged capped draft. Retain the last editing reservation while still recalculating capacity for viewport changes and subsequent keyboard openings.

* perf(app): isolate composer editing and resize updates

Draft and cursor subscriptions rerendered the chat and full composer on every native edit. Give editing consumers a live text source, preserve attachment identity on text updates, and read viewport settling state without a render.

Read live input text for web resize measurement so removing per-keystroke props does not collapse a draft when its width changes.

* fix(app): restore current draft when the composer remounts

The last replacement command does not include subsequent edits. Seed remounted editors from the live draft snapshot so switching launch modes restores the saved text without subscribing the composer to every edit.

* fix(app): synchronize composer resolution state before paint

Avoid an extra passive-effect delay when forwarding forge resolution state from the isolated binding. Exercise live-text width measurement through the actual composer E2E journey instead of a hook-only test component.
2026-09-16 00:05:19 +02:00
Mohamed Boudra 4251575950 Keep reconnect toast transitions continuous (#4925)
* fix(app): keep reconnect toast transitions continuous

Preserve the toast surface when its content changes, and apply initial appearance before mounting screens so settings hydration cannot restart a visible toast.

* test(app): use supported appearance QA coverage
2026-09-15 23:30:52 +02:00
Aditya BorakatiandABorakati f9fb992dcb fix(plugins): use spawnProcess for plugin build commands on Windows (#4776)
runPluginBuild called node:child_process spawn with shell: false, so bare
commands like `npm` failed on Windows with `spawn npm ENOENT` because npm
is a .cmd script. Route through the shared spawnProcess helper, which
already selects shell: true for bare commands and .cmd/.bat scripts on
Windows and quotes arguments for cmd.exe.

Side effects inherited from spawnProcess, on every platform:
- Child env is sanitized via createExternalCommandProcessEnv, stripping
  ELECTRON_RUN_AS_NODE, ESBUILD_BINARY_PATH and PASEO_* control keys that
  previously leaked from the daemon into plugin builds.
- windowsHide: true, so builds no longer flash a console window on Windows.

Adds behavioral tests for preparation.ts. Windows cases use it.runIf and
run only on Windows hosts; CI is ubuntu-only.

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
2026-09-15 22:51:23 +02:00
Mohamed Boudra 5ae0c5b83b fix(client): preserve workspace and agent creation on older hosts (#4895)
* fix(client): preserve creation on older hosts

Creation keys must not disable workspace and agent creation when the host lacks request receipts. Keep capability selection inside the client and degrade to the original RPCs with the initial prompt intact.

* test(creation): own legacy daemon cleanup in fixtures

* fix(client): preserve original prompted creation on legacy hosts

Legacy receipt support does not preserve create-and-prompt semantics when split into a later send. Reuse the original creation RPC so naming, creation context and startup receive the same input as before.
2026-09-15 22:49:06 +02:00
Lukas BuckandMohamed Boudra 4be34f05ef fix(plugin): continue one timeline item across ACP chunks without messageId (#4701)
* fix(plugin): continue one timeline item across ACP chunks without messageId

The plugin ACP reducer minted a new fallback id for every
agent_message_chunk / agent_thought_chunk that carried no messageId, so
a streamed reply rendered as one block per chunk, splitting words and
Markdown delimiters (#4699). Keep one fallback id per chunk kind and end
it on a chunk of the other kind, a tool call, a plan, a user message, or
a turn boundary, matching the server-side ACP provider.

* fix(plugin): end the other chunk kind's item on any chunk

A chunk of one kind now ends the other kind's fallback item before the
content or id is looked at, so an explicit-id text chunk between two
id-less thoughts yields three items instead of merging the thoughts.
The test declares the streamed updates through the connector harness
instead of handling protocol frames itself.

* test(plugin): verify ACP message boundaries and rendered Markdown

* test(app): synchronize rewind setup and menu selection

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-15 21:25:27 +02:00
paseo-ai[bot] 7d74916f8e fix: update lockfile signatures and Nix hash [skip ci] 2026-09-15 16:06:06 +00:00
Mohamed Boudra 80b4387bc6 Find text throughout chat history (#4765)
* feat(app): add pane Find to file source

* fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.

* fix(app): keep the active match visible and drop the Find button

The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.

Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.

* fix(app): stop the Find placeholder from hiding Go to line

Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.

* feat(app): find text throughout chat history

Discover candidate transcript locations on the daemon, then reuse prompt navigation to load history and verify rendered occurrences on the client. Keep search state inside its own modules without changing timeline or task presentation contracts.

* fix(app): show actionable chat search failures

Keep transport error details in the search model while showing localized retry guidance. Use named client request options for timeline search.
2026-09-15 17:54:01 +02:00
Mohamed Boudra 326a37cc89 Find text in terminal scrollback (#4650)
* feat(app): add pane Find to file source

* fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.

* fix(app): keep the active match visible and drop the Find button

The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.

Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.

* fix(app): stop the Find placeholder from hiding Go to line

Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.

* feat(app): find text in terminal scrollback

* test(app): account for terminal write completion before barriers

* fix(terminal): only Cmd+F opens Find on macOS; Ctrl+F reaches the shell

* test(app): exercise terminal find shortcuts against real ptys

* test(app): wait for visible vim lines before terminal page down

* test(app): account for vim page overlap in terminal shortcut journey
2026-09-15 17:46:42 +02:00
Mohamed Boudra 26e0273acb Add pane Find to editable and read-only file source (#4589)
* feat(app): add pane Find to file source

* fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.

* fix(app): keep the active match visible and drop the Find button

The floating widget covered the active match whenever the match sat under
it and the document was too short to scroll clear. CodeMirror's own panel
scroll margin cannot reveal a first line, so the file integration now owns
placement: the widget renders as an overlay beside the editor, the model
measures it against the active match, and flips it to the opposite corner
only when the current corner collides. Hysteresis keeps navigation from
bouncing the widget between corners.

Also removes the standalone Find button shown while Find is closed, per
user direction. Cmd/Ctrl+F is the entry point.

* fix(app): stop the Find placeholder from hiding Go to line

Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
2026-09-15 17:20:27 +02:00
Mohamed Boudra cb90806045 fix(app): soften the mobile diff tree button and remove row shadows (#4898)
Use a neutral surface for diff navigation and restrict Android elevation to document headers so shared tree rows stay flat after scrolling.
2026-09-15 17:06:21 +02:00
Mohamed Boudra d8dd189b94 ci: simplify and de-flake the test suite (#4868)
* chore(ci): audit packaging costs and repeated test execution

Read-only audit: Linux packaging and smokes consume 12-14 minutes; all three CLI shards repeat the 294-test source suite and build the server twice. Preserve the 17 required contexts, move packaging to main, run CLI source tests once, and consolidate repeated E2E setup into journeys. Historical logs and the complete audit were reported outside the repository.

* ci: move packaging to main and remove repeated CLI suites

PR runners were rebuilding distribution artifacts and executing the entire CLI source suite on all three E2E shards. Keep packaging coverage after merge, let the local E2E runner own its dependency build, and execute source tests once in an existing required check. Root typecheck already validates plugin examples.

* test(ci): consolidate journeys and remove repeated startup work

Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries.

* test(hub): remove incidental observation and speculative shutdown changes

Hub control RPCs need a connected client without an agent observation. Preserve the failed-create subscription contract and remove product shutdown changes whose Windows cleanup cause was not reproduced.

* fix(client): clear the scheduled retry when a connect attempt opens a transport

An explicit connect() during reconnect backoff opened a new transport but
left the scheduled retry armed. When it fired on the healthy connection it
disposed the transport and reconnected, dropping requests in flight. This
is why owned-subscriptions-old-daemon could still fail after #4858 added
the explicit reconnect. attemptConnect now owns the rule ensureConnected
already applied; the new unit test fails on the old client.

* test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys

- agent-stream-ui: the auto-scroll journey anchors on the completed intro
  block by content and requires its Markdown root to stay the same mounted
  node while later blocks stream (#1989 guard, reshaped off the tail block).
- changes-pane: the code-typography journey holds font loads and requires
  no diff geometry before they resolve, then applies the font change.
- agent-timeline-pagination: the live-output page load runs against a
  thirty-minute mock stream and gates the release on the running turn, so
  the status assertion no longer races a ten-second turn.

* test(desktop): install the Electron binary once before the unit suite

Electron 44 downloads its binary on the first Node require("electron")
instead of at npm ci. Vitest workers importing electron-log/main raced
that download and extraction (File exists on dist/electron, then
"Electron failed to install correctly" in a sibling worker) on both
Ubuntu and Windows desktop-tests jobs. Run the package's idempotent
install-electron in one process before vitest forks.

* ci(desktop-packages): pin actions and smoke packaging-path pull requests

Pin checkout, setup-node, and upload-artifact to full commit SHAs. Keep
packaging off ordinary PR CI, but run the Linux package smoke on pull
requests that touch packages/desktop, nix, or the workflow so a packaging
or sandbox regression cannot land on main unchecked. The desktop release
workflow already builds and smokes its own artifacts on all platforms.
2026-09-15 13:26:41 +02:00
Mohamed Boudra 0474c3e0af fix(app): stop the streaming Markdown spec at a word boundary (#4875)
The mock provider splits "**Bold" into two frames, "**Bo" and "ld". The
helper forwarded both back-to-back and asserted the strong span reads
"Bold", which only holds when the browser commits both frames to the
store in one reducer flush and the message first mounts with "**Bold".
When a frame boundary lands between the two tasks the message mounts
with "**Bo" and word pacing holds "ld" until the whitespace after it
arrives, so the span reads "Bo" until the gate releases. That is the
designed behaviour of the reveal, and the batching is up to the
browser's task scheduling.

Stop after "**Bold text" instead: the space after "Bold" ends the word,
so every batching reveals "Bold" and never more than "text". Forcing the
frames into separate commits reproduced the CI failure before the change
and passes after it.
2026-09-15 04:45:17 +02:00
paseo-ai[bot] c5c0536b46 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-15 02:39:32 +00:00
Mohamed Boudra b21c004ffe Keep open chats current and show reconnection progress (#4863)
* fix(app): keep open chats current and show resume progress

Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume.

* fix(app): prevent sync status from overlapping action errors

Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze.

* test(hub): expose complete creation and message retry failures

Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload.

* fix(app): dismiss retained chat menus before freezing

An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension.

Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion.

* test(app): expect the reloaded plugin to contribute its pill again

A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then.

* fix(app): suspend hidden chats on web only

react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten.

* fix(creation): never read a receipt while replacing it

Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated.

Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
2026-09-15 04:30:25 +02:00
Mohamed Boudra 6215e08efe Smooth streaming replies with a continuous left-to-right fade (#4859)
* feat(app): reveal streaming text as one continuous fade front

Streaming assistant text is released by word and every character fades in
over the same 150 ms envelope from a start time owned by the message. Start
times never decrease in reading order, so a line paints as a single front
moving left to right, however the text is chunked. Per-word envelopes with an
internal stagger let the next word's first letter overtake the previous word's
last letter, which read as holes and flashing words at the real frame rate;
100 ms frame strips hid it.

Native renderers receive the fade ranges as a prop on a host view wrapping the
root Text, so ranges and text land in the same Fabric mount transaction. The
earlier binding sent ranges through a separate async module call keyed by view
tag and had to reconcile them against the view's text; on a fast phone the UI
thread lags JS by several commits, mounts and calls interleave, and every
reconciliation heuristic guessed wrong. Native code no longer infers newness
or creates start times.

The mock load-test provider now separates cycles with a blank line so a cycle
heading is not appended to the previous paragraph.

* fix(ci): refresh streaming dependencies and isolate E2E setup

* test(app): distinguish fade cleanup from Markdown replacement

* test(app): detect restarted fades during Markdown updates

* docs(app): keep streaming guidance focused on constraints and QA

* test(app): control animation time in Android fade assertions

Fixed wall-clock sleeps let emulator scheduling select the sampled animation frame. Inject the animator time source and drive paint at exact timestamps within one UI-thread operation; production keeps the system clock. Verify the mid-fade sample and the exact cleanup boundary without sleeps, polling, or retries.

* fix(test): compile the desktop renderer before lifecycle checks

The lifecycle fixture treated Metro /status as app readiness. Its first
Settings click therefore spent its 90-second UI budget compiling the cold
bundle. An isolated cold-cache run restricted to one CPU reproduced the
click timeout before the app runtime had loaded.

Reuse the browser suite's existing bundle warmup before launching Electron,
and preserve Metro output in the lifecycle artifacts. The UI timeout,
build-request timeout, lifecycle assertions and product behavior are unchanged.

CI did not preserve Metro output, so its precise startup timing is not
recoverable from the failed run. The retained log makes subsequent startup
failures diagnosable without relying only on an empty renderer text dump.

Validation: three complete lifecycle E2E runs under private Xvfb displays;
existing Metro readiness tests; app typecheck; targeted lint; root format.

* test(file-observer): await burst delivery through native recovery

The deletion phase required every event within 10 seconds, but native
coalescing recovery uses a 30-second safety audit followed by the existing
reconciliation debounce. An idle diagnostic excludes that delayed audit
and therefore cannot act as a completion barrier.

Await the required delivered events instead, preserving every deletion
assertion and the existing overall test watchdog. A real-filesystem test
with controlled native notifications reproduces 95 outstanding paths at
the old deadline and verifies completion when the safety audit delivers
them. No production observer behavior changes.

* test(file-observer): verify burst recovery through public events

The controlled-clock regression constructed a private native backend,
bypassing the interface used by consumers. Consolidate recovery coverage
into the public observer integration test instead: drive real writes,
deletes and directory renames, then await every required public event,
including the 450 surviving files at their renamed paths.

No public capability was added: subscribe callbacks already express
completion. No production behavior or test timeout changes.

* test(app): drive Android fade tests through the native host

The instrumentation suite constructed TailFadeInAnimator directly, advanced
its private clock, and counted FadeInSpan instances. It now mounts the
WordFadeHost that React mounts, feeds it the ranges prop the bridge sends,
and asserts rendered pixels: hidden before a start time, a monotone front
inside and across words, settled frames equal to the baseline, and no frame
callbacks left after settling. Span counts and internal timing state are no
longer asserted.

Choreographer cannot be advanced deterministically from a test and the fade
epoch is the message's wall-clock start time, so the host takes a
WordFadeFrameClock (time plus frame callbacks) as its smallest public seam;
production keeps the Choreographer-backed default.

Fixture text is sized in pixels so every sample stays on one line at any
density; at 420 dpi the 32sp fixture wrapped and the column helper summed
glyphs from the first line.
2026-09-15 03:15:24 +02:00
Mohamed Boudra c3bcad687d fix(app): prevent and recover workspace layout crash loops (#4844)
* fix(app): prevent and recover workspace layout crash loops

* fix(desktop): capture resized browser tabs without hanging

A resident guest can paint fresh pixels after resize while capturePage's
native surface-copy request remains pending. The same failed guest returns
a fresh frame through frame subscription, so waiting for resize or another
animation frame does not correct the failing copy request.

Capture one subscribed frame within the existing five-second budget and
release the subscription on completion or cancellation. Preserve temporary
frame production and its original throttling policy. Remove viewport copy
retries and E2E screenshot polling; assert the resized image dimensions.

Refs #4866. Three full real-Electron browser journeys passed under Xvfb,
including physical clicks and fresh screenshots while the window is hidden.

* test(app): choose Agent before exercising the runtime catalog

The newly merged catalog journey assumed an empty workspace opened an agent draft tab. This branch opens the New launcher, so create the agent draft tab explicitly before checking its composer and model choices.

* fix: settle closed browser captures and validate persisted layout fixtures

Destroying a guest during frame subscription left its screenshot pending until the capture deadline and blocked queued captures. Reject at destruction, remove the listener on every exit, and return browser_tab_closed. The regression proves immediate settlement and queue release.

Reuse the unchanged persisted layout schema from a UI-independent module so the recovery fixture validates storage before editing it.

* test(app): encapsulate Explorer layout setup and cleanup

Keep persisted layout schemas and corrupted-state construction inside the seedCorruptedWorkspaceLayout helper. Use the existing withWorkspace fixture for cleanup in all four Explorer journeys so test bodies follow a straight setup, action and assertion flow.

* test(app): target the selected workspace in layout recovery

The corruption helper selected the first persisted entry, so a multi-workspace journey could leave the intended workspace untouched. Resolve its exact persistence key from the requested workspace ID and fail setup when absent. The failing-first journey now preserves an earlier workspace with an agent draft tab while recovering the selected workspace.
2026-09-15 02:30:27 +02:00
Mohamed Boudra 174055a63c feat(app): give compact Changes an overview and a straight rail (#4861)
* feat(app): give compact Changes an overview and a straight rail

Three fixes to the Explorer's Changes tab on phones, where there is no
tree rail and the diff is the only view of what changed.

No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery`
reported `isLoading` only when the query was enabled and connected, so an
inactive retained panel settled on `files: []`. The comparison toolbar
summed that into "+0 -0" and the body rendered "No changes" for a diff
nobody had fetched yet, both replaced a moment later by real numbers. The
result now derives from the payload alone: no payload means loading,
whatever the query is doing. Callers already handle loading correctly, so
nothing downstream needed a special case.

Jump to file. A floating action button over the diff opens a sheet with
the same changed-files tree the desktop rail renders; tapping a file
closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out
of `diff-pane.tsx` into its own module so both surfaces own one tree, and
its list is now the sheet-aware one so it scrolls with the sheet gesture
on native. `DiffDocument` takes a `contentInsetBottom` so the button
never traps the last line.

Rails on the compact header. The tab labels sat at 20dp and the close X
at 16dp while every toolbar row below them used 12dp leading and 8dp
trailing. The close button was also a hand-rolled Pressable rather than a
ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the
row's last control is a bare glyph or a framed one: a glyph's hitbox
overhangs its ink so the row pads by less than the rail, but the compact
git-actions trigger paints its own frame and was landing flush against
the screen edge. The commits header chevron moved to the depth-0 tree
rail. Desktop geometry is unchanged.

* fix(app): preserve Explorer touch targets and reset file overview

* fix(app): preserve diff selections across repaints

Header typography refreshes and height resizes changed the repaint callback
and inadvertently cleared active source selections. Keep selection updates
stable while retaining explicit source and selection-layout invalidation.

* test(app): express diff copy regression as user actions

* test(app): express file overview flows as user actions
2026-09-15 00:30:35 +02:00
0aca3b605a fix(plugins): transform whole timeline items before grouping and Markdown splitting (#4675)
Send every original tool call and each whole accumulated assistant message through plugin transformers before display grouping or Markdown splitting. Preserve streaming callbacks, original payloads, native Markdown block identities, and chat outline reading position. Reject legacy fragment caches and retain failing-first plugin-interface and browser regressions.

Fixes #4673. Fixes #4789. Consolidates #4820.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-14 23:03:38 +02:00
Matt CowgerandMohamed Boudra 0c5f472da1 fix(plugins): expose server settings access (#4674)
* fix(plugins): expose server settings access

* fix(plugins): isolate settings subscriber state

* test(plugins): read persisted settings before clients connect

* test(app): reconnect seed client after old daemon restart

* test(server): drain file creation events before deletion assertions

* test(creation): expose errors when completion fails

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-14 23:03:20 +02:00
Mohamed Boudra 13979e9c38 fix(server): discover fork PRs with URL-valued branch remotes (#4862)
Resolve direct repository URLs as well as named remotes when deriving checkout PR identity, preserving fork-owner filtering and batched polling.
2026-09-14 23:02:45 +02:00
Mohamed Boudra 77c5c8f17e Prevent duplicate catalog choices from crashing agent drafts (#4839)
* fix(providers): keep duplicate catalogs from breaking agent drafts

Normalize model identities before publication and when materializing older
catalogs, preserving the first definition and configured overrides. Keep
explicit default variants separate from the base model selection.

* fix(providers): preserve saved variant identities across resumes

Persist the selection encoding so legacy names remain literal. Refresh provider persistence when a selection changes, before another prompt is sent.

* test(app): let fixtures own catalog journey cleanup

* fix(providers): preserve the existing default variant contract

Keep one Default catalog choice and let the provider resolve an omitted variant. Remove the encoding and persistence machinery; other variant IDs retain their literal meaning.

* test(creation): expose failed completion after conflicting requests

Creation returns a failed snapshot instead of rejecting. Assert the result before its side effects so platform failures report the underlying error.

* test(app): wait for the seed client after daemon restart

The browser and seed client reconnect independently. Wait for the seed connection before issuing post-restart RPCs instead of assuming browser readiness covers both clients.

* test(app): isolate visible prompts and checkout diff fixtures

Inactive tabs retain message DOM, so prompt assertions must inspect visible rows. Ignore the local fixture remote before observation rather than deleting it after the daemon has started tracking the checkout.

* fix(file-observer): preserve final deletions when batching events

A duplicate creation notification followed by deletion was collapsed into an update, hiding final absence from consumers that distinguish deletes. Keep final deletes while retaining update semantics for atomic replacement.

* fix(desktop): wait for Metro HTTP readiness before launching Electron

Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status.

* test(ci): consolidate fixture and readiness corrections

Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed.
2026-09-14 22:25:31 +02:00
Mohamed Boudra 836f1a9c23 fix(ci): consolidate E2E repairs and wait for Metro readiness (#4858)
* fix(desktop): wait for Metro HTTP readiness before launching Electron

Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status.

* test(ci): consolidate fixture and readiness corrections

Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed.

* test(desktop): describe Metro readiness through HTTP states

Keep protocol mechanics in the fixture and assert readiness only after a successful packager response. Cover non-success status and unrelated HTTP content without inline synchronization in the test body.
2026-09-14 21:25:38 +02:00
Mohamed Boudra fdb5540a94 docs(skills): clarify workspace comparison base selection (#4860) 2026-09-14 21:24:51 +02:00
799c91b39d fix(app): restore Android tablet model picker interactions (#4845)
Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior.

Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771.

Co-authored-by: Mike P. Kuhl <mkuhl@softmachine.at>
Co-authored-by: Cameron Chin <cjcrjc@users.noreply.github.com>
2026-09-14 19:10:32 +02:00
Mohamed Boudra d63132a37d fix(creation): prevent duplicate workspaces and agents on retries (#4442)
* fix(creation): prevent duplicate workspaces and agents on retries

* test(creation): cover plugin hooks across receipt replays

* fix(creation): recover draft retries without accepting partial workspaces

* refactor(creation): name receipt inputs and tag compatibility gate

* fix(file-observer): track files announced by native change events

* fix(creation): keep initial prompts in daemon-owned creation requests

* fix(creation): reuse connection-owned progress and recovery

* test(creation): adapt handoff gates to combined requests

* ci(desktop): avoid xz compression for smoke packages

* ci(desktop): pass valid RPM compression through FPM config

* refactor(creation): give legacy and modern requests one identity owner

* fix(creation): log observer failures without interrupting creation
2026-09-14 16:55:42 +02:00
Mohamed Boudra cdc669fde6 fix(agents): prevent OOM from cumulative tool history (#4838)
* fix(agents): prevent OOM from cumulative tool history

Repeated progress updates retained complete historical copies of growing tool output. Retain projected items on ingestion and serve them for every history fetch, preserving source sequence coverage for catch-up. Keep intermediate payloads only in streaming; confine compatibility degradation to child transcripts and overwritten fork checkpoints.

* test(client): expect projected subagent capability in hello

* fix(app): report unexpected child history failures

Keep connection failures recoverable while forwarding unexpected fetch and reconciliation errors to diagnostics. Name observer arguments to keep server and agent identities explicit.

* test(agents): assert projected history after rewind

* docs(protocol): tag projected child compatibility gates
2026-09-14 16:51:10 +02:00
Mohamed Boudra d1a53255fc fix(app): keep growing composers below the header (#4824)
* fix(app): keep growing composers below the header

Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation.

* docs: reuse existing test suites and automation
2026-09-14 12:25:29 +02:00
Mohamed Boudra d1b705a0cd fix(app): restore Android clipboard image pasting (#4758)
Decode clipboard image data URLs locally because native fetch rejects them. Add native-runtime and byte-integrity regression coverage, and clarify empty-clipboard guidance.
2026-09-12 09:50:25 +02:00
Mohamed Boudra 0d7760e68b fix(agents): keep rejected plans collapsed in conversation order (#4756)
Keep plan proposals at their original timeline position through approval, rejection, and cancellation. Make plan cards collapsible while retaining readable history after follow-up prompts and daemon restart.
2026-09-12 09:33:26 +02:00
Mohamed Boudra 03a54a171d Keep older daemons usable and let clients choose timeline recovery (#4737)
* fix(subscriptions): preserve older-host workflows and explicit timeline recovery

Keep established app and SDK workflows available on older daemons without emulating independent directory filters. Restore live timeline membership without choosing the consumer's history recovery policy. Preserve daemon filesystem paths separately from legacy workspace IDs.

* fix(client): keep legacy workspace replies scoped to the requested page
2026-09-12 08:50:16 +02:00
d0e1edca7f fix(server): show the right thinking options for each Cursor model (#4180)
* fix(server): probe Cursor thinking options per model

Cursor ACP only reports thought_level for the current model, so the catalog was copying Haiku's Off/On onto Grok 4.6 until the next probe happened to land on Grok.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): drop inherited thinking options when an ACP catalog probe fails

A rejected model switch was leaving the probe session's thinking list on the failed model, so Grok could still advertise Haiku's Off/On.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): probe per-model thinking when session/new has no thought_level

Composer/Auto as the Cursor ACP default omits thought_level, so the catalog helper skipped every model switch and Grok kept an empty thinking list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): discover model options without changing preferences

Use the provider model catalog extension instead of probing model switches that persist user preferences. Keep provider-specific discovery in each shim and validate extension responses at the boundary.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-12 08:50:09 +02:00
Mohamed Boudra 2d7733cd65 fix(app): keep incomplete Markdown formatted while streaming (#4742)
* fix(app): keep incomplete Markdown formatted while streaming

Treat unfinished formatting at the growing assistant-message tail as provisional so delimiters and link destinations do not flash while tokens arrive. Completed messages retain ordinary Markdown parsing.

* test(app): express streaming Markdown checks as user actions
2026-09-12 08:49:25 +02:00
Mohamed Boudra a7ce602c83 fix(agents): restore archived Codex workspaces without losing focus (#4736)
* fix(agents): restore archived Codex workspaces without losing focus

History selection must survive without cached agent records, and reading an archived Codex thread must not acquire an interactive writer. Keep workspace restore explicit, serialize native lifecycle transitions, and leave transcript lifetime with its owner.

* test(app): express Codex restore specs as user journeys

* test(app): finish restore fixture cleanup after teardown errors

* test(app): align restore coverage and await fixture readiness

Workspace restore leaves agents archived until explicit Unarchive. Keep that distinction covered for multiple agents. Compile the recovery fixture before its interaction test starts, and wait for sidebar agent hydration before asserting tab presence.
2026-09-12 08:47:44 +02:00
paseo-bot[bot] fa93c4290e docs(changelog): update generated release notes (#4728)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-11 18:58:13 +02:00
paseo-bot[bot] 192c517835 docs(changelog): clarify macOS version requirement (#4726)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-11 18:53:27 +02:00
Mohamed Boudra f22a37e613 fix(app): let touch workspace titles use the available width (#4698)
* fix(app): let touch workspace titles use the available width

Omit trailing stats when the permanent touch menu replaces them. Keep concealed stats in layout only for temporary desktop shortcut hints, preserving hover and shortcut geometry.

* test(app): express sidebar width checks as user journeys
2026-09-11 10:16:33 +02:00
Mohamed Boudra 772ff8d6f3 fix(desktop): preserve Linux sandboxing wherever the host allows it (#4447)
* fix(desktop): preserve Linux sandboxing wherever the host allows it

Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness.

* fix(desktop): retain the Linux release build baseline

* fix(ci): allow canceled desktop smoke uploads to stop

* test(desktop): verify the installed RPM sandbox under AppArmor

* fix(ci): resolve packaged desktop dependencies before installation

* chore(desktop): keep QA evidence out of the repository
2026-09-11 09:05:26 +02:00
Mohamed Boudra 0110302b6f fix(daemon): keep lifecycle operations on the selected instance (#4575)
* fix(daemon): keep lifecycle operations on the selected instance

Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop.

* test(daemon): verify bounded desktop readiness and cancellable artifacts

* test(cli): verify reload target hints and durable offline pairing

* test(daemon): align lifecycle fixtures with ownership and targeting

* fix(daemon): preserve reachability when status details time out

Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome.

Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows.

* test(daemon): observe restart through the worker status RPC

The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction.
2026-09-11 08:33:22 +02:00
Mohamed Boudra 4006f974fb Reload to the project picker after an app error (#4598)
* fix(app): reload to the project picker after a render error

Keep the recovery boundary above the router so Reload starts a new navigation tree at the picker instead of remounting the failed workspace. Preserve saved layouts and normal startup restoration.

* test(app): exercise reload through fixture routes
2026-09-11 08:09:05 +02:00
Mohamed Boudra 6f2195904b fix(subscriptions): isolate observers and silence unrequested feeds (#4596)
Give each observation a server-issued ID owned by its physical connection. Preserve snapshot-only reads and combined list-and-subscribe, source-bound replies, lifecycle cleanup and legacy wire compatibility.
2026-09-11 08:07:35 +02:00
Mohamed Boudra 33a52d0ae7 Speed up cold diffs and wrapped text measurement (#4676)
* perf(app): optimize wrapped measurements and checkpoint native layout experiment

Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished.

* perf(server): reduce cold diff generation work

Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides.

* test(server): exercise oversized highlighted diff rejection

The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability.

* fix(server): queue fresh diffs for forced reads

A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type.

* test(server): share deferred diff loader setup

* test(server): name completed diff cache setup

* refactor(server): use aliases for new diff imports

* fix(server): keep shipped diff imports resolvable by Node

The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression.
2026-09-11 08:07:01 +02:00
Mohamed Boudra 16d932651c fix(desktop): throttle hidden browsers between screenshots (#4646)
* fix(desktop): throttle hidden browsers between screenshots

Keep Chromium background throttling enabled outside serialized captures. Temporarily enable frame production and wait for fresh paint so hidden-window screenshots remain usable without continuously animating idle guests.

* fix(desktop): share screenshot deadline across paint and capture

* test(desktop): wait for browser fixture readiness before sampling

* test(desktop): exit Electron after screenshot verification

CI completed every screenshot assertion but remained alive after app.quit. Exit the standalone test explicitly and keep its deadline active until process shutdown.

* test(desktop): clean browser profile after Electron exits

Explicit Electron exit skips quit handlers, and removing the profile before shutdown lets Chromium recreate it. The launcher owns the temporary profile and removes it after the child exits.

* test(desktop): reuse browser E2E for hidden screenshot regression

Exercise the running app and browser MCP tools within the existing browser journey. Remove the separate Electron fixtures, npm script, and CI step.
2026-09-11 08:05:45 +02:00
paseo-ai[bot] d7c7044dfc fix: update lockfile signatures and Nix hash [skip ci] 2026-09-10 10:26:51 +00:00
Mohamed Boudra b8e24677e1 chore(release): cut 0.8.0 2026-09-10 12:17:08 +02:00
Mohamed Boudra 1f3bb0239b docs(changelog): prepare 0.8.0 stable release 2026-09-10 12:14:08 +02:00
Mohamed Boudra f7e68e32d1 fix(desktop): restore Linux launcher identity (#4649)
* fix(desktop): restore Linux launcher identity

Electron now derives Linux window identity from the scoped package name before app startup. Pin the existing Paseo.desktop identity in packaged metadata and check the built archive in the packaged-app smoke test.

* test(desktop): declare packaged smoke archive dependency

* fix(desktop): update Nix hash for smoke dependency
2026-09-10 11:42:09 +02:00
Mohamed Boudra 94fc4e5d49 docs(release): document temporary next and hotfix branches (#4651) 2026-09-10 11:10:21 +02:00
Mohamed Boudra 7bcf167862 Document scoped credentials during Hub continuation (#4607)
* docs(hub): explain scoped credentials during agent continuation

* docs(hub): align credential lifetime across trigger guides

* docs(hub): centralize continuation credential lifecycle

* docs(hub): keep continuation guidance focused on agent reuse

* docs(hub): state when shared tokens are revoked
2026-09-10 10:08:54 +02:00
Matt Cowgerandpaseo-bot[bot] 8598b6aab9 fix(plugin-provider): recover stale sessions after reload (#4629)
* fix(plugin-provider): recover stale sessions after reload

* fix(plugin-provider): auto-recover stale sessions on next prompt

* fix(plugin-provider): retry stale prompt streams

* fix(plugin-provider): recover replacement prompts

* test(plugin-provider): await runtime retirement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-10 09:20:25 +02:00
Mohamed Boudra ae1cf642bb docs(hub): remove navigation implementation FAQ (#4645)
* docs(hub): explain pending dashboard navigation

* docs(hub): remove navigation implementation FAQ
2026-09-10 09:05:43 +02:00
Mohamed Boudra 2ee0191ea1 docs(hub): explain pending dashboard navigation (#4608) 2026-09-10 09:00:33 +02:00
Mohamed Boudra f3917173a9 fix(server): align Codex Fast with documented model support (#4640)
The prefix allowlist hid Fast for GPT-6 Astra and exposed it for models outside documented Codex Fast support. Use explicit model IDs and avoid claiming a universal usage multiplier.
2026-09-10 08:42:50 +02:00
1084 changed files with 58982 additions and 22220 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ app:
sdk:
- "packages/plugin/**"
- "plugin-examples/**"
- "public-docs/plugins/v0.8/**"
- "public-docs/plugins/**"
- "packages/client/**"
- "packages/highlight/**"
- "packages/protocol/**"
+76 -29
View File
@@ -52,7 +52,7 @@ jobs:
filters: .github/ci-paths.yml
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
format:
name: format
@@ -120,6 +120,12 @@ jobs:
- name: Typecheck all packages
run: npm run typecheck
- name: Install website test browsers
run: npx playwright install --with-deps chromium webkit
- name: Verify website mockups
run: npm run test:e2e --workspace=@getpaseo/website
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
@@ -174,11 +180,42 @@ jobs:
runs-on: windows-latest
steps: *server_test_steps
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
# bound runtime.
server-tests-macos:
name: server-tests (macos-14, file observation)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server dependencies
run: npm run build:server-deps
- name: Run file observation tests
run: |
npm run test:unit --workspace=@getpaseo/server -- \
src/server/file-observer \
src/server/workspace-git-service.observation.test.ts \
src/server/workspace-git-service.observation.integration.test.ts \
--bail=1
desktop-tests-ubuntu:
name: desktop-tests (ubuntu-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
@@ -201,12 +238,35 @@ jobs:
- name: Install virtual display
if: ${{ runner.os == 'Linux' }}
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
- name: Install Playwright browser
if: ${{ runner.os == 'Linux' }}
run: npx playwright install chromium
- name: Verify Windows daemon process control
if: ${{ runner.os == 'Windows' }}
run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home"
- name: Run desktop lifecycle E2E (Linux)
if: ${{ runner.os == 'Linux' }}
run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
env:
PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle
- name: Upload desktop lifecycle screenshots
uses: actions/upload-artifact@v4
if: ${{ !cancelled() && runner.os == 'Linux' }}
with:
name: desktop-lifecycle
path: ${{ runner.temp }}/desktop-lifecycle
if-no-files-found: ignore
retention-days: 7
- name: Run desktop lifecycle E2E (Windows)
if: ${{ runner.os == 'Windows' }}
run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop
- name: Run desktop renderer E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
@@ -226,23 +286,6 @@ jobs:
if-no-files-found: ignore
retention-days: 7
- name: Build and smoke unpacked desktop app
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
uses: actions/upload-artifact@v4
if: ${{ failure() && runner.os == 'Linux' }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
desktop-tests-windows:
name: desktop-tests (windows-latest)
needs: changes
@@ -296,7 +339,7 @@ jobs:
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:client
run: npm run build:relay && npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
@@ -304,9 +347,6 @@ jobs:
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Typecheck plugin examples
run: npm run typecheck --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
@@ -355,6 +395,9 @@ jobs:
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Weight file groups by observed duration; raw test counts overload
# early agent/Changes specs. Required check names remain unchanged.
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -440,14 +483,18 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
# The local runner builds its dependencies and shards its own E2E files.
- name: Run CLI E2E tests
run: npm run test:local --workspace=@getpaseo/cli
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
# shortest E2E shard, after its runner has built the server stack.
- name: Run CLI source tests
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
run: npm run test:unit --workspace=@getpaseo/cli
cli-tests-2:
name: cli-tests (shard 2/3)
+90
View File
@@ -0,0 +1,90 @@
name: Desktop Packages
on:
push:
branches: [main]
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
# or how it is smoked still proves the real artifacts before merge.
pull_request:
branches: [main]
paths:
- "packages/desktop/**"
- "nix/**"
- ".github/workflows/desktop-packages.yml"
concurrency:
group: desktop-packages-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install package smoke dependencies
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
- name: Build Linux desktop artifacts
# Smoke package contents and installation without spending minutes on xz compression.
run: |
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Smoke installed artifacts with restricted user namespaces
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
if unshare --user --map-root-user --net true; then
echo "Expected the launching user's namespace probe to be denied"
exit 1
fi
sudo apt-get install -y ./packages/desktop/release/*.deb
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
- name: Smoke sandbox-preserving portable launches
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
unshare --user --map-root-user --net true
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
- name: Smoke installed RPM with restricted user namespaces
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
sudo dpkg --remove paseo
# Ubuntu supplies the runtime libraries under Debian package names.
# RPM still performs the real payload installation and postinstall.
sudo rpm --install --nodeps packages/desktop/release/*.rpm
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
env:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
- name: Upload packaged smoke diagnostics
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: desktop-packaged-smoke-linux-x64
path: ${{ runner.temp }}/desktop-smoke
if-no-files-found: ignore
retention-days: 7
+11 -29
View File
@@ -102,8 +102,10 @@ jobs:
include:
- runner: macos-14
electron_arch: arm64
node_heap_mb: 4096
- runner: macos-15-intel
electron_arch: x64
node_heap_mb: 8192
permissions:
contents: write
packages: read
@@ -161,6 +163,10 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
# Metro's jest workers OOM on the generated ws-outbound.aot.js validator,
# the last module in the web bundle. Node's default heap is sized from
# runner RAM, which is not enough on the Intel runner.
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
@@ -185,16 +191,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -261,7 +259,7 @@ jobs:
NODE
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
- name: Build desktop release
shell: bash
@@ -278,7 +276,7 @@ jobs:
npm run build:desktop "${build_args[@]}"
- name: Upload packaged smoke diagnostics
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: desktop-smoke-linux-x64
@@ -294,16 +292,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -400,16 +390,8 @@ jobs:
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
+1 -22
View File
@@ -1,27 +1,6 @@
name: Docker
on:
pull_request:
branches: [main]
paths:
- "docker/**"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
push:
branches: [main]
tags:
@@ -51,7 +30,7 @@ on:
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
+1 -24
View File
@@ -1,27 +1,8 @@
name: Nix
on:
pull_request:
push:
branches: [main]
paths:
- "nix/**"
- "flake.nix"
- "flake.lock"
- "package.json"
- "package-lock.json"
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
- "scripts/build-daemon-web-ui.mjs"
- "scripts/update-nix.sh"
- "scripts/fix-lockfile.mjs"
- ".github/workflows/nix.yml"
permissions:
contents: read
@@ -31,8 +12,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
@@ -110,8 +89,6 @@ jobs:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: cachix/install-nix-action@v31
with:
+3
View File
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
packages/desktop/.cache/
packages/desktop/src-tauri/resources/managed-runtime/
app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
+163 -63
View File
@@ -1,91 +1,191 @@
# Changelog
## 0.8.0-beta.1 - 2026-09-08
This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks.
The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8.
It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle.
### Plugins
- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431))
- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452))
- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206))
- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452))
## 0.9.1 - 2026-09-22
### Added
- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
### Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
## 0.9.0 - 2026-09-22
### Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589))
- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650))
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765))
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861))
- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956))
- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756))
- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958))
- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945))
### Plugins
- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975))
- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942))
- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971))
- Added `openExternalUrl()` and `<ExternalLink>` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972))
- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972))
- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger))
- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork))
- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675))
- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio))
- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati))
- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB))
- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970))
### Changed
- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736))
- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598))
- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844))
- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575))
### Improved
- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742))
- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676))
- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007))
- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863))
- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863))
- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958))
- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646))
### Fixed
- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838))
- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442))
- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844))
- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839))
- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824))
- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902))
- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946))
- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973))
- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013))
- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758))
- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl))
- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447))
- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575))
- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926))
- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756))
- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991))
- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862))
- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596))
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861))
- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925))
- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898))
## 0.8.0 - 2026-09-10
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API.
### Added
- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587))
- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj))
- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576))
- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203))
- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21))
- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229))
- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo))
- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup <id>` ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242))
- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger))
- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215))
- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242))
### Plugins
- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314))
- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger))
- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c))
- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357))
- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435))
- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392))
- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358))
- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet))
- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet))
- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov))
- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430))
- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal))
- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577))
- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265))
- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446))
### Changed
- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Changed `--host` to a global CLI option: `paseo --host <target> <command>` ([#4238](https://github.com/getpaseo/paseo/pull/4238))
- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517))
- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640))
- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
### Improved
- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214))
- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275))
- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574))
- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470))
- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322))
### Fixed
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322))
- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201))
- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207))
- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389))
- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469))
- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436))
- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160))
- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353))
- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10))
- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl))
- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter))
- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401))
- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332))
- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321))
- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413))
- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216))
- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335))
- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553))
- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240))
- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199))
- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210))
- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358))
- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506))
- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222))
- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354))
- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208))
- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan))
- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222))
## 0.7.2 - 2026-09-02
+8 -2
View File
@@ -106,9 +106,14 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Critical rules
## Release branches
- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries.
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
@@ -120,6 +125,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## スポンサー
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
+6
View File
@@ -161,6 +161,12 @@ npm run build:server
npm run typecheck
```
## 스폰서
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 관련 프로젝트
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
+8 -3
View File
@@ -50,10 +50,9 @@ Run agents in parallel on your own machines. Ship from your phone or your desk.
## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from a local directory or Git repository with `paseo plugin add <source>`.
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
See the [plugin docs](https://paseo.sh/docs/plugins) for your Paseo version, or start with the
[0.8 beta quickstart](https://paseo.sh/docs/plugins/v0.8). Plugins run with access to your daemon
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust.
## Getting Started
@@ -194,6 +193,12 @@ npm run build:server
npm run typecheck
```
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
+6
View File
@@ -151,6 +151,12 @@ npm run build:server
npm run typecheck
```
## 赞助
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
+2 -2
View File
@@ -51,7 +51,7 @@ const client = new DaemonClient({
appVersion: "0.1.70", // see gotcha #1
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
await client.fetchAgents({ subscribe: {} });
// ... do your testing ...
@@ -81,7 +81,7 @@ const client = new DaemonClient({
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
await client.fetchAgents({ subscribe: {} });
// ... test ...
+29 -10
View File
@@ -109,16 +109,28 @@ 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. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
Once the daemon reports the agent active, its tab follows normal archive propagation again.
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
not own selection or release the explicit open.
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
@@ -152,6 +164,13 @@ A finished workspace can be marked unread after it has been reviewed. The daemon
`finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start.
## The subagents track
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
+19 -1
View File
@@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
→ source-item plugin transforms → native Markdown blocks / tool grouping
→ paced reveal (app, per displayed item) → paint
```
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
@@ -28,6 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
occurrence keys.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
+11
View File
@@ -120,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
+60 -6
View File
@@ -54,6 +54,15 @@ The heart of Paseo. A Node.js process that:
All paths are under `packages/server/src/`.
Desktop and CLI import server capabilities through explicit package subpaths such as
`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export
loads daemon bootstrap eagerly, even when a caller only uses a path or process helper.
Keep process-management and configuration dependencies independent of bootstrap and
WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules;
`messages` imports and re-exports them. This prevents every supervising process from
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports.
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
@@ -145,7 +154,7 @@ traffic. Workspace assignments stay on the workspace directory sequence.
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
@@ -239,11 +248,22 @@ Server → Client: status message with payload { status: "server_info",
serverId, hostname, version, capabilities?, features }
```
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
There is no dedicated welcome message; the server emits one initial `status.server_info` after
accepting hello. Modern clients negotiate `owned_subscriptions` against
`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data
demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client
correlates the initial reply by request ID, then routes updates by subscription ID.
A logical Session can contain several physical sockets. Each socket owns its capabilities,
subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's
request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown.
The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges
its teardown; reconnect creates new server IDs for surviving client handles. See
[protocol compatibility](protocol-compatibility.md) for the legacy boundary.
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
@@ -297,7 +317,7 @@ messages or transfers.
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities.
Example: adding a new enum value
@@ -308,6 +328,36 @@ Example: adding a new enum value
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
sequence after the client process disappears. Explicitly requested IDs or receipts
that an old host cannot honor produce an unsupported error. Keep these adapters inside
the client package, as an exception to the default no-fallback feature policy.
Creation executes through the existing Session capabilities. Connection-owned delivery
controls observation only: detaching a socket or cleaning up its Session does not cancel
accepted creation. Updates require an explicit subscription and go only to that socket;
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
existing response contract.
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
@@ -418,12 +468,16 @@ $PASEO_HOME/
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
├── paseo.pid # Supervisor identity and published bound endpoint
└── daemon.log # Daemon trace logs (rotated)
```
The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock.
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`).
2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform.
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
+16 -8
View File
@@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see:
by retrying until the frame appears;
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination;
- parked guests remain capturable with Chromium background throttling enabled;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
@@ -73,11 +72,17 @@ The harness writes PNG evidence and `results.json` to:
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for the production P1 attach-off parking state,
A passing run prints `PASS` lines for the production P1 default-throttling parking state,
including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
and resumes animation after restoring the window. Its artifacts include the screenshot
and animation measurements. Full-page content correctness remains separately tracked in
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident
@@ -96,8 +101,11 @@ plane stays below the overlay plane regardless of body insertion order; menus ke
layering inside `overlay-root`. Activating a presented browser also focuses its registered guest
`WebContents` in main so macOS assigns keyboard first-responder ownership to the page.
There is no renderer prep/restore handshake. Main disables guest background throttling
once when the webview attaches, then screenshot capture uses the shared serialized queue,
invalidates before each attempt, and retries known first-frame failures within the
5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
There is no renderer prep/restore handshake or lifetime background-throttling override.
Screenshot capture temporarily enables frame production inside the shared serialized queue,
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
The browser tool takes one viewport frame through Electron's frame subscription and releases the
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
metrics and screenshot clip.
+1 -1
View File
@@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md).
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update.
- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work.
- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering.
- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second.
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
- Use stable ids for `key`, never array index for reorderable/filterable lists.
+10 -8
View File
@@ -64,8 +64,8 @@ $PASEO_HOME/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
├── plugins/
│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership
│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit
│ ├── sources.json # Managed kind and Git acquisition remote
│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree
└── push-tokens.json # Expo push notification tokens
```
@@ -254,11 +254,13 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
All fields are optional with sensible defaults.
Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin
runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the
Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root.
Paseo writes it atomically. An update creates and validates a new version directory before changing
the configured directory path; successful activation removes the old version.
Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled
state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition
remote. Installed revision, package/subdirectory identity and ownership root come from retained
artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership)
explains their authority. An update prepares a new directory before replacing the active path and
removing the previous version. Old record fields are accepted at the store boundary and ignored;
there is no startup migration or persistent update policy.
### Profile lists
@@ -488,7 +490,7 @@ Array of workspace records. A workspace is a specific working directory within a
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
+1 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
+5 -4
View File
@@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values
The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default.
Enable it for a running daemon with:
Enable it in persistent configuration:
```bash
paseo daemon start --web-ui
paseo daemon config set features.webUi.enabled true
paseo daemon start
```
Or set the environment variable:
```bash
PASEO_WEB_UI_ENABLED=true paseo daemon start
PASEO_WEB_UI_ENABLED=true paseo daemon run
```
Or persist it in `config.json`:
@@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a
```
Set `PASEO_HOST` to use the same target across invocations. An explicit
`--host` overrides the environment variable.
selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon).
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
+2
View File
@@ -22,6 +22,8 @@ the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement.
## Quick Start
```bash
+10 -4
View File
@@ -30,7 +30,10 @@ never read or modify that width.
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
Explorer sidebar setting. Callers request the content and never choose the shell.
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
even for a different PR; moving or reordering it remains the user's choice.
Callers request the content and never choose the shell.
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
routes later presses to the working diff through the shared diff preference.
@@ -45,7 +48,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
@@ -57,8 +60,11 @@ lifecycle.
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
right split around the workspace root; later side opens reuse it.
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
later side open creates a new pane. There is no hidden side-pane lifecycle.
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
not automatically create an agent draft tab; choosing Agent opens one. Explorer cannot replace the
workspace canvas, even when visible. Restoring a saved layout enforces the same rule while preserving Explorer and saved
tab content. There is no hidden side-pane lifecycle.
Placement intent still controls existing tabs:
+9
View File
@@ -47,6 +47,15 @@ state.
This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected.
## Error Recovery
The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
remounts the router at `/open-project`, preserving saved workspace layouts and
the remembered selection. Do not move the boundary inside the root layout:
catching a render error there unmounts the navigator, so the recovery button
cannot reliably dispatch a navigation reset. Starting recovery at `/` would
restore the same workspace and could repeat the crash.
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
+3 -3
View File
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+59 -5
View File
@@ -58,7 +58,10 @@ 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
hit-testing starts fresh in that window. Side effect: a Modal opening on
hit-testing starts fresh in that window. If its children use gesture-handler,
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
the app's root does not span Android windows. React context alone does not
establish a native gesture root. 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
(the composer's input must stay focused so the user keeps typing).
@@ -134,13 +137,38 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
## Gotcha 3 — Keyboard layout and portal anchors
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
The optional third child is an overlay that moves with the surface. New workspace
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
space or translate the composer themselves. Put the dismiss surface behind content
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
MOVE events before its native scroll view reaches touch slop. Streaming can hide
this failure by delaying the responder grant. Native scroll views retain their own
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
need `none`); otherwise their empty padding blocks hit testing of that sibling.
The shared screen header uses the same dismiss surface. Keep the
header escape available when a capped draft clips all content above it, since
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
and the stream keeps its existing flick-to-dismiss gesture.
The dock's private viewport and capacity model are extracted from chat. The
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
terminal, explorer, and dialogs. Import only its public entry point: the provider,
shift hooks, shift style, translate view, settled stream inset, and explorer padding
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
at its root mount point outside the portal host when changing dock hosts.
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
signal because the stock iOS Reanimated value changes at move start, while
writing a replacement Reanimated value every frame interrupts UIKit's hide
animation. Keep this platform choice inside `KeyboardTranslateView`.
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
`KeyboardDock` always keeps the chat surface at full height and wraps it in
`ComposerDock` always keeps the surface at full height and wraps it in
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
slide under the header on open and out from under it on close. Do not swap the
dock to keyboard padding at rest. Changing its height creates either a blank
@@ -154,7 +182,33 @@ layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
Move the stream and composer together through `KeyboardDock`. Do not translate
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header inside
`composer/dock`, including its controls and attachments. Reserve the
keyboard destination at move start on the UI thread. Waiting for a settled JS
update lets a long draft disappear behind the header during opening. This is a
boundary-time layout change, not an animated layout prop. Keep the input's
native intrinsic sizing and internal scrolling. Keep the editing input's
`defaultValue` current on every edit: Fabric re-measures the Android input from
the JS text whenever its frame changed, so a stale prop inflates or collapses
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
Closing the keyboard moves the composer; it does not enlarge its capacity. The
viewport keeps the last keyboard reservation until the next opening replaces
it, so a capped draft keeps one height through every keyboard transition
instead of expanding on close and shrinking again on open.
Keep setup fields out of the composer's shrink chain. New workspace supplies its
setup fields as the first dock child so they move up under the header as the draft
grows. Putting the fields and composer in one bounded, shrinkable column made
Yoga split the shortage between them: rows vanished behind the composer while
the input lost lines at the same time. Keep that separation inside the dock,
shared by every phone host. The centered tablet form caps the complete block
for header clearance, but reserves the composer's own capped height before
shrinking the setup scroll view. The form must not take editing space from the
composer.
Move the stream and composer together through `ComposerDock`. Do not translate
them independently.
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
+9 -2
View File
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
@@ -49,6 +52,10 @@ Provider controls remain provider-native; see [providers.md](providers.md).
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
for the lifetime of a draft and reuses the first message ID after failures.
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
+8
View File
@@ -179,6 +179,10 @@ its own.
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
@@ -187,6 +191,10 @@ its own.
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
+2
View File
@@ -116,6 +116,8 @@ definition, no longer eligible to begin.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM
nodes carry no Fabric ownership; the native file keeps the chat live.
## Tests
+27
View File
@@ -47,6 +47,33 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
to test an installed production build. It never submits a chat message.
`npm run test:e2e:composer-keyboard:android` preserves the chat control and
keyboard regression flow, then runs the same growth, retained-height, bounds, and
hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
tab, and New workspace. It checks empty-content taps and header taps at the cap
without changing the input height. Each
host also opens command/file autocomplete, the model selector, the attachment
menu, and the forge picker from both keyboard states. It then submits a long
fixture from each host and checks native stream displacement twice: after the
response settles, and after returning to the bottom and letting the scrollbar
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
does not prove that native scrolling receives touches.
Artifacts are grouped by host under
`.dev/agent-device-artifacts/composer-keyboard-android`. Configure its daemon,
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
screens launch on their own, take input focus, and the run fails with
`IME did not become visible` while the app still looks focused:
```bash
adb shell pm disable-user --user 0 com.google.android.gm
adb shell pm disable-user --user 0 com.google.android.calendar
```
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+69 -19
View File
@@ -4,7 +4,7 @@ Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Com
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
## Install a directory source
@@ -68,7 +68,7 @@ runtime modules, so consumers do not install these packages when adding the plug
```
Declare the supported Paseo range and keep it current when adopting newer APIs. See the
[requirements contract](../public-docs/plugins/v0.8/reference.md#requirements), including legacy
[requirements contract](../public-docs/plugins/reference.md#requirements), including legacy
manifests and prerelease matching.
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
@@ -103,10 +103,36 @@ paseo plugin update --all
Append `:relative/path` to the source when the plugin lives below the repository root.
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
Removing a Git source deletes Paseo's managed checkout.
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
because authenticated clones may have a rewritten placeholder origin. Fixed owned directory layout
provides the selected subdirectory, npm package name and cleanup root. Git HEAD and the installed
npm package.json checked against package-lock.json provide the current revision. Keep the complete
npm dependency tree and lockfile after staging activation. No install selector governs updates.
The loader sees only the configured directory and never invokes npm. npm lifecycle scripts are
disabled during acquisition; manifest preparation owns required commands before validation.
List responses retain the closed `source: directory | git` enum. The optional `installation`
projection carries identity and current revision. Old npm metadata remains accepted but is no longer
emitted. Installation uses `features.pluginSourceInstallation`; reviewed preview/apply uses
`features.pluginSourceUpdates`, gated by the SDK and CLI entry. The old immediate-update request
returns update-client guidance. Basic management remains independently available.
Preview returns exact targets and expected installed identity/root/revision. Apply validates those
values, acquires the displayed artifact or commit, then rechecks state before activation. No review
session is stored in the daemon. PluginService owns activation/recovery and independent bulk results.
### Declare Git preparation
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
@@ -162,10 +188,10 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
[host UI contract](../public-docs/plugins/reference.md#host-ui).
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/v0.8/reference.md`.
See `public-docs/plugins/reference.md`.
### SDK import boundaries
@@ -206,7 +232,7 @@ The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference
browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/v0.8/reference.md#works-on-mobile) for the complete
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
pattern.
```ts
@@ -253,6 +279,11 @@ selected host's existing connection; switching the screen's host changes both `u
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
@@ -261,7 +292,7 @@ catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
@@ -278,15 +309,16 @@ when moved between hosts. Explorer configuration can create workspace-context pa
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
Plugins do not receive Expo Router or workspace-layout store access.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
@@ -334,7 +366,7 @@ and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
daemon-appended plugin items. The direct example includes both sides. The renderer-only
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
provider implementation. The public [provider plugin guide](../public-docs/plugins/providers.md)
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
@@ -347,7 +379,7 @@ SVG or URL.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
@@ -355,12 +387,26 @@ Native sheets teleport their children. Button surfaces rebuild the installation'
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
built-in projection stay unchanged. The app transforms each source item while building the render
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
model, for both fetched history and live events, before native Markdown splitting and Overview
tool grouping. Assistant callbacks receive the accumulated source text, never display fragments.
Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`.
Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so
streaming updates preserve mounted component identity.
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
@@ -457,7 +503,7 @@ drops the optional presentation fields.
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
The host settings shell owns navigation and layout; plugin content must not add another page
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
scroll view or header. See the [author contract](../public-docs/plugins/reference.md#settings-screens)
and `plugin-examples/settings` for the named UI components and persistence API.
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
@@ -465,6 +511,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m
so updates and reloads retain values. Settings-change notifications must not enter the catalog
reload path: that path disposes the plugin and would destroy open drafts after every save.
`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current
`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The
subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
@@ -488,7 +538,7 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
+23 -1
View File
@@ -46,10 +46,32 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource
automation, or explicit overrides. A schema accepting a message does not establish support for
its delivery semantics.
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
behavior once at its connection boundary. App workflows use the same observation interface on both.
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
or multiplexing emulation are introduced.
Preserve established workflows when changing delivery internals. Independent filters and quiet
connections require a capable daemon; opening the app, reading history and using terminals do not.
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
for parsing compatibility; modern requests cannot select their subscription ID.
## Every shim is tagged and dated
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
+21 -5
View File
@@ -8,7 +8,8 @@ This guide walks through adding a new agent provider end-to-end. There are two i
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
This Paseo version accepts these keys:
@@ -19,10 +20,18 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
@@ -49,6 +58,13 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe
process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses
`cursor/list_available_models` because switching models during discovery writes its saved CLI
preferences and selection history. Cursor versions without that extension must be updated. Kimi
still probes model selections in its own shim. The initial session supplies modes and the current
model; it does not override the model list returned by a resolver.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
+32 -1
View File
@@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
## Release branch discipline
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
### Hotfix from a release tag
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
ACP catalog work enters a release through an explicit user request:
@@ -64,7 +94,7 @@ release push as the changelog and version commit.
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
@@ -162,6 +192,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
+1
View File
@@ -26,6 +26,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
+8 -4
View File
@@ -107,14 +107,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
- the `paseo://app/` renderer mounts into `#root`;
- the sandboxed preload exposes the desktop bridge;
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
- the bundled CLI can query that daemon and run a terminal command.
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence.
To exercise the smoke locally on Linux:
@@ -128,7 +132,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
@@ -200,7 +204,7 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths.
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
## Agent authentication in tests
+64 -14
View File
@@ -5,8 +5,10 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
authority and repopulates those rows when an agent resumes.
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
The invariants are:
@@ -67,15 +69,34 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
## Resume behavior
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
page.
Focus alone does not mutate timeline state; the tail response is compared with the local
Opening, reconnecting, and returning from app background establish the current timeline through
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
Focus alone does not mutate timeline state; the response is compared with the local
authoritative range first.
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
offline, then Updating messages until authoritative catch-up completes. Socket connectivity alone cannot
certify that the displayed conversation is current. The timeline owner publishes freshness; the
view renders it without a toast timer or a separate resume workflow.
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
first page leaves a chat this client just created looking hydrated but still catching up, which
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
one; disconnect and backgrounding keep it.
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
@@ -88,6 +109,25 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
A plan approval keeps the original proposal's tool-call identity through resolution and provider
history replay. The pending approval UI can hide that tool from presentation, but the client model
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
retains rendered items and sequence cursors, never a second cache of source events. Pagination
uses the projected display anchor; live updates advance the source cursor without moving tools.
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
an update-host notice for that pane.
Older clients retain child names/status and receive an upgrade message when opening a child
conversation. This gate affects only the child transcript; it does not gate the connection,
main conversation, or current-context forks. A legacy `canonical` root request still receives
projected items.
## Client replica lifetime
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
@@ -95,6 +135,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
history response can arrive before the active-list response that omits it. Clearing history there
would discard accepted rows while the viewed owner still considers them synchronized. Entity
deletion clears the transcript; list membership changes do not.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
@@ -128,14 +173,19 @@ replica cache.
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
an evicted retained timeline displays its cached state immediately while authoritative catch-up
advances it to the current tail.
- Selective daemons receive the chats this session has opened, plus any visible agent pane. A chat
enters that set the first time the user opens it and leaves when its tab closes or the session
ends, independently of mounted or retained React views: switching workspaces, evicting a retained
view, app backgrounding, and reconnect all preserve the demand. There is no recent-agent limit.
Restored workspace layout is a release signal, never a source. It carries a tab for every chat the
user has ever opened on the host, and a catch-up fetch resumes its agent on the daemon, so
subscribing from layout spawns a provider session per historical tab at every launch and stamps
every one of those workspaces as just used (see
[agent lifecycle](agent-lifecycle.md#workspace-activity)). Visible chats get the first catch-up
attempt; the rest follow when those attempts settle, including failures, so a failed visible chat
does not starve background recovery. Split panes catch up together. Hidden chats update the
replica; on web their retained presentation stays suspended until revealed, on native it keeps
rendering. Revealing a chat reads the current store and preserves its local UI state.
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
authoritative catch-up, but the app does not issue selective-subscription RPCs.
@@ -0,0 +1,5 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,5 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,5 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,5 @@
Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards.
Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API.
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,9 @@
Added
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
- Added Cmd/Ctrl+F Find to terminal scrollback
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
- Added automatic Pull request tab opening once per workspace when a PR is detected
- Added expandable plan cards, with rejected plans collapsed by default
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
Fixed
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
- Fixed Import session offering only the newest 100 Codex conversations
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+55 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36196,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36268,6 +36268,7 @@
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-freeze": "1.0.4",
"react-i18next": "^17.0.8",
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
@@ -37261,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/server": "0.8.0-beta.1",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37513,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37527,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37538,6 +37539,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
@@ -37770,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38666,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38695,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38712,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38735,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38762,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38980,16 +38982,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/highlight": "0.8.0-beta.1",
"@getpaseo/plugin": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39002,6 +39004,7 @@
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.15",
"openai": "^6.44.0",
@@ -39012,6 +39015,7 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"semver": "^7.8.5",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
@@ -39024,6 +39028,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/markdown-it": "^14.2.0",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
@@ -39468,6 +39473,17 @@
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"packages/server/node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@@ -39706,6 +39722,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/server/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"packages/server/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
@@ -39867,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.8.0-beta.1",
"version": "0.9.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+2
View File
@@ -5,6 +5,7 @@ const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storag
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -141,6 +142,7 @@ export default {
plugins: [
"expo-router",
withPasteInput,
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
[
@@ -36,14 +36,18 @@ import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import {
recordPanelToasts,
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
workspaceDeckEntryLocator,
} from "../support/helpers/workspace-ui";
import {
openSessions,
clickSessionRow,
expectWorkspaceTabVisible,
} from "../support/helpers/archive-tab";
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
@@ -428,6 +432,18 @@ async function expectInterruptedTurnOrderAfterReconnect(
}
}
async function visitEvictionWorkspaces(
page: Page,
agents: readonly { agentId: string }[],
): Promise<void> {
for (const [index, agent] of agents.entries()) {
await openSessions(page);
await clickSessionRow(page, `Workspace eviction ${index + 1}.`);
await expectWorkspaceTabVisible(page, agent.agentId);
await expectComposerVisible(page);
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
@@ -443,50 +459,76 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`,
title: `Workspace eviction ${index + 1}.`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
const openAgentIds = [target.agentId, ...evictionAgents.map((agent) => agent.agentId)];
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
// Open every chat before holding output. Adding chats replaces the timeline
// subscription and retires the IDs stamped on already-buffered frames.
await visitEvictionWorkspaces(page, evictionAgents);
await subscriptions.waitForSubscribedAgents(openAgentIds);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await expectWorkspaceTabVisible(page, target.agentId);
await expectComposerVisible(page);
const subscriptionRequests = gate.getClientRequestCount(
"agent.timeline.set_subscription.request",
);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamSuppressed(true);
gate.setAgentStreamItemSuppressed("user_message", true);
gate.holdNextAgentStreamEvent("turn_started");
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
for (const evictionAgent of evictionAgents) {
await openAgentRoute(page, evictionAgent);
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([
evictionAgents[WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES - 1]!.agentId,
]);
gate.setAgentStreamSuppressed(false);
await gate.waitForHeldAgentStreamEvent("turn_started");
// Finish production before navigation so passing cannot depend on late
// chunks arriving after the final workspace switch.
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
// Navigate inside the app: a document reload discards the retained deck and
// adds startup history fetches, so it cannot prove eviction/resume behavior.
await visitEvictionWorkspaces(page, evictionAgents);
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents(openAgentIds);
expect(gate.getClientRequestCount("agent.timeline.set_subscription.request")).toBe(
subscriptionRequests,
);
gate.releaseHeldAgentStreamEvent("turn_started");
const requestsBeforeReturn = rememberTimelineRequestCounts(gate, target.agentId);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await openAgentRoute(page, target);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: target.workspaceId,
});
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
await subscriptions.waitForSubscribedAgents(openAgentIds);
const responseStart = page.getByText("Cycle 1", { exact: true });
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(responseStart).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, responseStart);
await expectRenderedBefore(promptRow, response);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
// Open chats stay subscribed when their workspace view is evicted. Returning
// uses that live timeline without another resume check or startup tail fetch.
expect(rememberTimelineRequestCounts(gate, target.agentId)).toEqual(requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.setAgentStreamItemSuppressed("user_message", false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
}
@@ -868,7 +910,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one canonical prompt when the provider echoes before accepting", async ({
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -889,6 +931,20 @@ test.describe("Agent message submission", () => {
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
await expectVisibleAgentSurfacesIdle(page);
for (const [index, nextPrompt] of [
"emit 1 coalesced agent stream updates for the second turn.",
"emit 1 coalesced agent stream updates for the third turn.",
].entries()) {
await submitMessage(page, nextPrompt);
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
await expectVisibleAgentSurfacesIdle(page);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
}
await fillComposerDraft(page, "Keep this unsent draft.");
await composerLocator(page).blur();
await expect(page.getByTestId("user-message")).toHaveCount(3);
await expectComposerEditable(page);
} finally {
await workspace.cleanup();
}
@@ -908,16 +964,12 @@ test.describe("Agent message submission", () => {
await submitMessage(page, prompt);
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
const marker = `late-provider-identity-${Date.now()}`;
await submittedPrompt.evaluate((element, value) => {
element.dataset.lateProviderIdentity = value;
}, marker);
await submittedPrompt.hover();
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
timeout: 5_000,
});
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
await expect(submittedPrompt).toHaveCount(1);
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
await expectVisibleAgentSurfacesIdle(page);
} finally {
@@ -1114,8 +1166,11 @@ test.describe("Agent message submission", () => {
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
@@ -9,7 +9,7 @@ import {
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
},
],
});
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -88,18 +88,12 @@ test.describe("Agent stream UI", () => {
}
await page.evaluate((block) => {
const evidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
};
const evidence = { addedNodes: 0, characterDataMutations: 0, removedNodes: 0 };
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") {
evidence.characterDataMutations += 1;
}
if (record.type === "characterData") evidence.characterDataMutations += 1;
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
@@ -1,6 +1,10 @@
import { expect, test } from "../support/fixtures";
import {
expectSameOlderHistoryLoadingOperation,
withSpokenTimeline,
captureSpokenTimeline,
reloadSpokenTimeline,
expectSpokenTimelinePrompt,
sendSpokenTimelinePrompt,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
@@ -12,7 +16,6 @@ import {
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
@@ -26,42 +29,28 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
test("shows spoken words without voice instructions in history, live updates and after reload", async ({
page,
}, testInfo) => {
const historyText = "Please check the voice history.";
const liveText = "Now check the live voice message.";
await withSpokenTimeline(historyText, async (agent) => {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
await expectSpokenTimelinePrompt(page, historyText);
await sendSpokenTimelinePrompt(agent, liveText);
await expectSpokenTimelinePrompt(page, liveText);
await captureSpokenTimeline(page, testInfo, "desktop");
await reloadSpokenTimeline(page);
await expectSpokenTimelinePrompt(page, liveText);
await captureSpokenTimeline(page, testInfo, "compact");
await expectSpokenTimelinePrompt(page, liveText);
});
});
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
test("keeps the history-start gutter and visible position stable through the final page", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
@@ -73,8 +62,10 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
@@ -96,7 +87,9 @@ test.describe("Agent timeline pagination", () => {
await userScrollsTimelineToHistoryStart(page);
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
await history.expectSettledWithRequestedPages(1);
await userScrollsTimelineToHistoryStart(page);
@@ -137,23 +130,6 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
@@ -181,7 +157,8 @@ test.describe("Agent timeline pagination", () => {
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
// The live turn streams for thirty minutes, so it is still running at every assertion.
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
@@ -193,8 +170,11 @@ test.describe("Agent timeline pagination", () => {
agent.agentId,
(snapshot) => snapshot.status === "running",
);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText("walking through").first()).toBeAttached();
history.releasePage(1);
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
@@ -205,28 +185,7 @@ test.describe("Agent timeline pagination", () => {
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
@@ -235,12 +194,12 @@ test.describe("Agent timeline pagination", () => {
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
} finally {
await agent.cleanup();
}
@@ -84,6 +84,7 @@ test.describe("Assistant fork menu", () => {
await forkInFlightTurnToNewTab(page);
await expectChatHistoryAttachment(page);
expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork);
await expect(page.getByRole("button", { name: "Menu backdrop", exact: true })).toHaveCount(0);
await page.getByRole("button", { name: sourceAgentTitle }).click();
await expectLiveAssistantText(page, visibleAfterFork);
@@ -1,4 +1,4 @@
import type { BrowserContext } from "@playwright/test";
import type { BrowserContext, Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
}
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage.locator(selector).evaluate((element) => {
const selection = window.getSelection();
const range = document.createRange();
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
itemText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.filter({ hasText: itemText })
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
tag: "code" | "strong",
text: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text })
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
startText: string,
endText: string,
): Promise<void> {
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
await assistantMessage.evaluate(
(element, selectedRange) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
await assistantMessageBlocks(page).evaluateAll(
(blocks, selectedRange) => {
// The message's text nodes in reading order, across every block row.
const nodes: Text[] = [];
for (const block of blocks) {
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let found = blockWalker.nextNode();
while (found) {
nodes.push(found as Text);
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let textNode = walker.nextNode();
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
);
await openAgentRoute(page, agent);
const assistantMessage = page.getByTestId("assistant-message").filter({
hasText: "Direct matches:",
});
const assistantMessage = assistantMessageBlocks(page);
for (const [tag, text] of [
["strong", "strong prose"],
["em", "emphasized prose"],
+179 -89
View File
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
const explorer = await openCompactChanges(page, workspace);
await openChangedFilesOverview(page);
await test.step("reopening the overview expands folders again", async () => {
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
});
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
});
});
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
const committed = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, committed, 90_000);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
const explorer = await openCompactChanges(page, committed);
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
await selectEmptyUncommittedComparison(page, explorer);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -954,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
.toBe(true);
});
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
test("changes diff waits for configured fonts, then applies code typography changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await holdBrowserFontLoads(page);
await navigateToWorkspaceChanges(page, workspace);
await test.step("commit no geometry before the configured fonts are ready", async () => {
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
const before = await readDiffTypographyGeometry(page);
await changeCodeTypographyFromSettings(page, {
@@ -977,63 +1013,31 @@ test("changes diff applies code size changes to gutter and code typography", asy
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
});
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await holdBrowserFontLoads(page);
await useUnwrappedDiffLines(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await cancelInlineReview(page);
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
const focusedBackground = await changesTab.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe(focusedBackground);
});
test("inline reviews keep the browser text context menu", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
await test.step("start a focused review with the browser text menu", async () => {
await startReviewOnFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
await cancelInlineReview(page);
});
await test.step("save, edit, and delete the review", async () => {
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
});
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
@@ -1143,36 +1147,36 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
});
test("canvas diff copies a dragged character selection without opening a review", async ({
context,
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
page,
}) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
await test.step("copy the selected characters with the keyboard", async () => {
await copyDiffSelectionWithKeyboard(page);
await expectClipboardText(page, "CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
await test.step("copy the preserved selection after resizing", async () => {
await resizeDiffViewportHeight(page, 960);
await copyFromChangedLineMenu(page, "Copy");
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the complete source line", async () => {
await copyFromChangedLineMenu(page, "Copy line");
await expectClipboardText(page, "ABCDEFGHIJ");
});
await test.step("dismiss a new selection without starting a review", async () => {
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await rightClickFirstChangedLine(page);
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
await page.keyboard.press("Escape");
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
@@ -1186,19 +1190,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
});
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await clickFirstChangedLine(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff replaces a selection with forward and backward drags", async ({
context,
page,
@@ -1393,14 +1384,17 @@ async function setOpenChangesPresentation(
}
}
/** Holds every font load in the document until released; later loads pass through. */
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
const originalLoad = fontSet.load.bind(fontSet);
const pending: Array<() => void> = [];
let released = false;
Object.defineProperty(fontSet, "load", {
configurable: true,
value(font: string, text?: string) {
if (released) return originalLoad(font, text);
return new Promise<FontFace[]>((resolve, reject) => {
pending.push(() => {
originalLoad(font, text).then(resolve, reject);
@@ -1410,6 +1404,7 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
});
Object.assign(window, {
__releasePaseoDiffFontLoads() {
released = true;
for (const release of pending.splice(0)) release();
},
});
@@ -1659,12 +1654,72 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await navigateToWorkspaceChanges(page, workspace);
await expectExpandedMountedTabDiff(page);
}
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-explorer-toggle").first().click();
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
await page.setViewportSize({ width: 390, height: 844 });
// Reload at the compact size: panel selection starts at the center on a cold
// mount, independently of any desktop sidebar used earlier in the test.
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
async function openChangedFilesOverview(page: Page): Promise<void> {
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
await expect(jumpToFile).toBeVisible();
await jumpToFile.click();
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
}
async function expectOverviewReopensExpanded(
page: Page,
folderName: string,
childFileName: string,
): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
const tree = changesTree(page);
const folder = tree
.getByRole("button")
.filter({ has: page.getByText(folderName, { exact: true }) });
const child = tree.getByText(childFileName, { exact: true });
await expect(folder).toBeVisible();
await expect(child).toBeVisible();
await folder.click();
await expect(child).toHaveCount(0);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await openChangedFilesOverview(page);
await expect(child).toBeVisible();
}
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
await expect(sheet).toHaveCount(0);
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
}
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
}
async function openWorkspaceChangesSurface(
@@ -1820,6 +1875,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
}
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await page.keyboard.press("ControlOrMeta+C");
}
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
const item = page.getByRole("menuitem", { name: action, exact: true });
await expect(item).toBeEnabled();
await item.click();
}
async function expectClipboardText(page: Page, text: string): Promise<void> {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
}
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
const previousViewport = page.viewportSize()!;
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
await page.setViewportSize({ width: previousViewport.width, height });
await expect(canvas).toHaveCSS(
"height",
`${previousHeight + height - previousViewport.height}px`,
);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
@@ -1,5 +1,5 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await page.keyboard.press(CHANGES_SHORTCUT);
+530
View File
@@ -0,0 +1,530 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
} from "../support/helpers/workspace-ui";
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
import {
composerLocator,
fillComposerDraft,
expectComposerDraft,
expectComposerFocused,
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
const STREAMED_RESPONSE = [
"First paragraph with alpha-needle and pair-needle inside.",
"Second paragraph with beta-needle and pair-needle inside.",
`Third paragraph ${FILLER}`,
].join("\n\n");
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
}).join("\n\n");
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openChatFind(page: Page) {
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
await page.keyboard.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
}
async function searchChat(page: Page, text: string) {
await openChatFind(page);
await query(page).fill(text);
}
async function expectHighlight(page: Page, text: string) {
await expect
.poll(() =>
page.evaluate(() => {
const entries = Array.from(CSS.highlights.entries()).filter(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const texts: string[] = [];
for (const [, highlight] of entries) {
for (const range of highlight) texts.push(range.toString());
}
return texts;
}),
)
.toEqual([text]);
await expect
.poll(() =>
page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return false;
const rect = range.getBoundingClientRect();
const row = range.startContainer.parentElement?.closest(
'[data-testid="agent-chat-scroll"]',
);
const viewport = row?.getBoundingClientRect();
return (
!!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
);
}),
)
.toBe(true);
}
async function nextMatch(page: Page) {
await query(page).press("Enter");
}
async function previousMatch(page: Page) {
await query(page).press("Shift+Enter");
}
async function expectFindStatus(page: Page, search: string, matches: string) {
await query(page).fill(search);
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
}
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
await nextMatch(page);
await expect(status(page)).toHaveText(matches);
await expectHighlight(page, highlighted);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(query(page)).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")),
),
)
.toEqual([]);
}
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-",
title: "Chat Find",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
for (let index = 0; index < 45; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const jumps = await trackPromptJumpRequests(page, agent.agentId);
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
await searchChat(page, "hello world");
await expectHighlight(page, "hello world");
await expect(status(page)).toHaveText("1 of 90");
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
await nextMatch(page);
await expect(status(page)).toHaveText("2 of 90");
await expectHighlight(page, "hello world");
await previousMatch(page);
await expect(status(page)).toHaveText("1 of 90");
await query(page).fill("hello & world");
await expectHighlight(page, "hello & world");
await query(page).fill("a.b");
await expectHighlight(page, "a.b");
await query(page).fill("Prompt number 0");
await expectHighlight(page, "Prompt number 0");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
// The scope of the search is the whole chat, so the counter is the position across
// every message that holds a hit, and Next walks off the end of one into the next.
const ACROSS_PROMPT = "Find scope-needle in this chat";
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
/** The test id of the message that holds the current match, so it can be watched crossing one. */
async function highlightedMessage(page: Page) {
return page.evaluate(() => {
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
name.startsWith("paseo-chat-find-"),
);
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
}
async function expectHighlightedMessage(page: Page, testId: string) {
await expect.poll(() => highlightedMessage(page)).toBe(testId);
}
test("counts and steps through every match in the chat, not just the selected message", async ({
page,
}, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-across-",
title: "Chat Find across messages",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
// One hit in the prompt and two in the reply: three across the chat.
await searchChat(page, "scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "user-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await expectNextMatch(page, "3 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
await expectNextMatch(page, "1 of 3", "scope-needle");
await expectHighlightedMessage(page, "user-message");
await previousMatch(page);
await expect(status(page)).toHaveText("3 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cancel-",
title: "Chat Find cancellation",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "hidden-destination");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("first cell second cell");
await expect(status(page)).toHaveText("No matches");
gate.holdNextServerMessage("agent.timeline.search.response");
await query(page).fill("hello world");
await expect(status(page)).toHaveText("Searching…");
await gate.waitForHeldServerMessage("agent.timeline.search.response");
await closeFind(page);
gate.releaseHeldServerMessage("agent.timeline.search.response");
await searchChat(page, "a.b");
await expectHighlight(page, "a.b");
await closeFind(page);
} finally {
await agent.cleanup();
}
});
test("shows a disconnected search failure and recovers with Retry", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-retry-",
title: "Chat Find retry",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await gate.drop();
await expectReconnectingToastVisible(page);
await searchChat(page, "hello world");
await expect(status(page)).toHaveText("Failed");
await expect(
page.getByText("Could not search this chat. Check the host connection and retry.", {
exact: true,
}),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
gate.restore();
await expectReconnectingToastGone(page);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expectHighlight(page, "hello world");
} finally {
await agent.cleanup();
}
});
interface StreamedReplyChat extends MockAgentWorkspace {
/** Resolves once the streamed reply has finished arriving. */
replyFinished(): Promise<void>;
}
/**
* Opens the chat first, then streams a multi-block reply into it and waits for a
* later block, so the block holding the early needles is no longer the growing row.
*/
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-live-blocks-",
title: "Chat Find live blocks",
featureValues: {
mockStreamingAssistantResponse: STREAMED_RESPONSE,
mockStreamingAssistantIntervalMs: 60,
},
});
try {
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
timeout: 30_000,
});
return {
...agent,
replyFinished: async () => {
await agent.client.waitForFinish(agent.agentId, 60_000);
},
};
} catch (error) {
await agent.cleanup();
throw error;
}
}
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
test.setTimeout(120_000);
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
/**
* Opens a chat whose newest reply is a single message far taller than the viewport,
* behind enough older turns that the oldest one is not hydrated yet.
*/
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-virtualized-",
title: "Chat Find virtualized message",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
test.setTimeout(120_000);
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
}
});
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
page,
}) => {
const tail = "oversized-tail-needle";
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-cap-",
title: "Chat Find long message",
initialPrompt: "Render a long response",
// One paragraph, so the needle is past the render cap of the row that holds it.
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
const message = page.getByTestId("assistant-message");
await expect(message).toBeVisible();
await expect(message).not.toContainText(tail);
await searchChat(page, tail);
await expectHighlight(page, tail);
await closeFind(page);
await expect(message).not.toContainText(tail);
await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible();
} finally {
await agent.cleanup();
}
});
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-composer-",
title: "Chat Find from composer",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await searchFromComposerWithoutLosingDraft(page, shortcut);
await refocusFindAndReplaceQuery(page, shortcut);
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
await closeFind(page);
await expectCommandCenterOwnsFindShortcut(page, shortcut);
await fillComposerDraft(page, "Draft after closing Find");
await expectComposerDraft(page, "Draft after closing Find");
} finally {
await agent.cleanup();
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Control+f", testInfo);
});
test.describe("macOS", () => {
test.use({ userAgent: MAC_USER_AGENT });
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Meta+f", testInfo);
});
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
test("leaves Control+f to the composer", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-mac-control-f-",
title: "Chat Find macOS Control+f",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await recordComposerKeydown(page);
await page.keyboard.press("Control+f");
await expect
.poll(() => recordedComposerFindKeydown(page))
.toEqual([{ key: "f", defaultPrevented: false }]);
await expect(query(page)).toHaveCount(0);
await expectComposerFocused(page);
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
});
interface RecordedKeydown {
key: string;
defaultPrevented: boolean;
}
/**
* Chat Find listens on `document` in the capture phase, so the composer only ever
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
* on the composer the evidence of who owns the key.
*/
async function recordComposerKeydown(page: Page) {
await composerLocator(page).evaluate((element) => {
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
store.__findKeydown = [];
element.addEventListener("keydown", (event) => {
store.__findKeydown.push({
key: (event as KeyboardEvent).key,
defaultPrevented: event.defaultPrevented,
});
});
});
}
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
const recorded = await page.evaluate(
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
);
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
}
async function openFindFromComposer(page: Page, shortcut: string) {
await page.keyboard.press(shortcut);
await expect(query(page)).toBeFocused();
}
async function searchFromComposerWithoutLosingDraft(page: Page, shortcut: string) {
await fillComposerDraft(page, "Keep this draft");
await expectComposerFocused(page);
await openFindFromComposer(page, shortcut);
await expectComposerDraft(page, "Keep this draft");
await query(page).fill("hello world");
await expectHighlight(page, "hello world");
}
async function refocusFindAndReplaceQuery(page: Page, shortcut: string) {
await composerLocator(page).click();
await expectComposerFocused(page);
await openFindFromComposer(page, shortcut);
await expect(query(page)).toHaveValue("hello world");
await expectComposerDraft(page, "Keep this draft");
await page.keyboard.type("a.b");
await expect(query(page)).toHaveValue("a.b");
await expectHighlight(page, "a.b");
}
async function expectCommandCenterOwnsFindShortcut(page: Page, shortcut: string) {
await openCommandCenter(page);
await page.keyboard.press(shortcut);
await expect(page.getByTestId("command-center-panel")).toBeVisible();
await expect(query(page)).toHaveCount(0);
await closeCommandCenter(page);
}
@@ -20,6 +20,8 @@ import {
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
@@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
page,
}) => {
test.setTimeout(120_000);
const prompt = "Explain in several paragraphs.";
await withStreamingMarkdownOutline(async (agent) => {
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expectChatOutlinePrompts(page, 2);
await expectReadingStreamedMarkdown(page, prompt);
await expectActiveChatOutlinePrompt(page, 2);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectActiveChatOutlinePrompt(page, 2);
await page.reload({ waitUntil: "domcontentloaded" });
await expectActiveChatOutlinePrompt(page, 2);
await clickChatOutlineRowEdge(page, 2);
await expectTimelinePromptLandedBelowTop(page, prompt);
});
});
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
@@ -1,4 +1,4 @@
import { test } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
}
});
test("shows an existing agent's only supported thinking level", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-controls-single-thinking-",
title: "Single thinking level",
model: "max-only-thinking-stream",
thinkingOptionId: "max",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
).toHaveAccessibleName("Select thinking option (Max)");
} finally {
await workspace.cleanup();
}
});
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
@@ -1,33 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { openCommandCenter } from "../support/helpers/command-center";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("selecting an agent result opens its workspace tab", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
await gotoAppShell(page);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
} finally {
await seeded.cleanup();
}
});
});
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectAgentTabActive } from "../support/helpers/launcher";
import { openCommandCenter } from "../support/helpers/command-center";
import { addOfflineHostAndReload } from "../support/helpers/hosts";
import { expectAppRoute } from "../support/helpers/route-assertions";
@@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => {
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
await test.step("find an agent and open its workspace tab", async () => {
const agentPanel = await openCommandCenter(page);
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
await page.keyboard.press("Enter");
await expectAgentTabActive(page, agent.id);
});
} finally {
await seeded.cleanup();
}
@@ -38,6 +38,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { attachFileFromMenu, controlFileUploadCompletion } from "../support/helpers/composer";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -52,17 +53,29 @@ const TEST_JSON = {
};
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
test("selected file shows a loading attachment until upload is acknowledged", async ({
page,
withWorkspace,
}) => {
const upload = await controlFileUploadCompletion(page);
const workspace = await withWorkspace({ prefix: "attach-upload-pending-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
upload.hold();
await attachFileFromMenu(page, TEST_JSON);
await upload.waitForUpload();
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
const pending = page.getByTestId("composer-pending-file-attachment");
await expect(pending).toContainText(TEST_JSON.name);
await expect(pending.getByRole("progressbar")).toBeVisible();
await expect(page.getByTestId("composer-file-attachment-pill")).toHaveCount(0);
upload.complete();
await expect(pending).toHaveCount(0);
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText(TEST_JSON.name);
await expectComposerEditable(page);
});
test("compact Plus menu aligns attachment rows with its sheet title", async ({
@@ -101,59 +114,27 @@ test.describe("Composer attachments", () => {
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
category: "attachments",
issues: [{ title: "fix: attachment issue" }],
prs: [{ title: "feat: attachment pull request", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
// Choose from the repository list. Newly created repositories are not
// necessarily available in GitHub's separate text-search index yet.
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
} finally {
await handle.cleanup();
await ghRepo.cleanup();
@@ -168,7 +149,7 @@ test.describe("Composer attachments", () => {
// needed before this can be exercised end-to-end.
});
test("image lightbox opens on pill click and closes on Escape", async ({
test("attaches, previews, and removes an image before dropping a file", async ({
page,
withWorkspace,
}) => {
@@ -178,38 +159,25 @@ test.describe("Composer attachments", () => {
await clickNewChat(page);
await expectComposerVisible(page);
await test.step("open the available attachment choices", async () => {
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
await page.keyboard.press("Escape");
});
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("dropped JSON file renders as a file attachment in active chat", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await test.step("remove the image and attach a dropped file", async () => {
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await dropFileOnComposer(page, TEST_JSON);
await expectAttachmentPill(page, "composer-file-attachment-pill");
});
});
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
@@ -238,23 +206,6 @@ test.describe("Composer attachments", () => {
}
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
@@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
const TEST_COMMANDS = [
{
@@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => {
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
await expectMobileAgentSidebarVisible(page);
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
@@ -1,4 +1,4 @@
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
@@ -65,6 +65,19 @@ test("blank composer lines remain present and keep their measured height", async
const blankLines = "\n\n\n\n\n";
const collapsedHeight = await composerHeight(page);
await test.step("remeasure the current draft when the window narrows", async () => {
await page.setViewportSize({ width: 1280, height: 1200 });
const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8);
await composer.fill(text);
await expect(composer).toHaveValue(text);
await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight);
const wideHeight = await composerHeight(page);
await page.setViewportSize({ width: 480, height: 1200 });
await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight);
await expect(composer).toHaveValue(text);
await page.setViewportSize({ width: 1280, height: 720 });
});
await test.step("grow the composer with blank lines followed by text", async () => {
await composer.fill(`${blankLines}x`);
await expect(composer).toHaveValue(`${blankLines}x`);
@@ -0,0 +1,130 @@
import type { createCreationScenario } from "../support/helpers/creation";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
creation,
}) => {
await creation.openWorkspaceForm(isolation);
await creation.submitRepeatedly("Create");
await creation.expectOneCreatedWorkspace();
});
}
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
await creation.expectPromptVisible();
await creation.expectAgentCount(1);
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
creation,
agentRetries,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start exactly one agent for this prompt.");
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
await creation.expectAgentCount(1);
});
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitPrompt("Name this new agent from its first prompt.");
await creation.expectPromptVisible();
await creation.expectAgentTitle("Name this new agent from its first prompt.");
});
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("a remounted draft reconciles its original creation after reconnect", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Deliver this initial prompt once.");
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
creation,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
await creation.expectPromptVisible();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
creation,
delayedCreation,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start the first agent.");
await creation.expectPromptVisible("Start the first agent.");
await delayedCreation.waitForDelayedCreatedStatus();
await creation.startAnotherDraft();
await creation.submitPrompt("Start the second agent.");
await creation.expectPromptVisible("Start the second agent.");
await delayedCreation.waitForDelayedCreatedStatus();
delayedCreation.release();
await creation.expectAgentCount(2);
});
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
creation,
startup,
delayedCreation,
}) => {
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
await creation.expectAgentStillStarting();
delayedCreation.expectSingleWorkspaceIntent();
delayedCreation.release();
await startup.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -0,0 +1,230 @@
/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */
import { randomUUID } from "node:crypto";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { metroTest, expect } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { withProjectOwnership } from "../support/helpers/project-ownership";
import { createTempGitRepo } from "../support/helpers/workspace";
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "../support/helpers/new-workspace";
import { fillComposerDraft } from "../support/helpers/composer";
import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
let daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
const test = metroTest.extend<{
client: DaemonClient;
gate: Awaited<ReturnType<typeof holdLegacyAgentCreation>>;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
project: Awaited<ReturnType<typeof seedWorkspace>>;
}>({
gate: async ({ page }, provide) => {
const gate = await holdLegacyAgentCreation(page, daemon.port);
await provide(gate);
gate.release();
},
client: async ({}, provide) => {
const client = withProjectOwnership(
await connectDaemonClient<DaemonClient>({
clientIdPrefix: "legacy-creation",
port: daemon.port,
}),
);
await provide(client);
await client.close();
},
repo: async ({}, provide) => {
const repo = await createTempGitRepo("legacy-creation-");
await provide(repo);
await repo.cleanup();
},
project: async ({}, provide) => {
const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port });
await provide(project);
await project.cleanup();
},
});
test.describe(`published daemon ${version}`, () => {
test.beforeAll(async () => {
test.setTimeout(120_000);
daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, {
publishedVersion: version,
});
});
test.afterAll(async () => {
await daemon?.close();
});
for (const kind of ["workspace", "agent"] as const) {
test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({
repo,
client,
}) => {
const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`;
const features = client.getLastServerInfoMessage()?.features;
expect(features?.creationLifecycle).not.toBe(true);
expect(features?.workspaceRequestReceipts).not.toBe(true);
expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0");
const create = await prepareCreation(client, kind, repo.path, prompt);
const [agent, duplicate] = await Promise.all([create(), create()]);
expect(duplicate.id).toBe(agent.id);
expect(agent.title).toBe(prompt);
await client.waitForFinish(agent.id, 20_000);
const agents = await client.fetchAgents();
expect(agents.entries).toHaveLength(1);
expect(agents.entries[0]!.agent.title).toBe(prompt);
const timeline = await client.fetchAgentTimeline(agent.id);
expect(
timeline.entries.filter(
({ item }) => item.type === "user_message" && item.text === prompt,
),
).toHaveLength(1);
});
}
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
page,
project,
client,
gate,
}) => {
await openOldHost(page, project.workspaceId);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, "worktree");
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
).toBeVisible();
expect((await client.fetchAgents()).entries).toHaveLength(0);
const workspaces = (await client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
gate.release();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
await expectPromptOnce(client, first.id, prompt);
await expect(
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
).toHaveText(prompt);
await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
)!.agent;
expect(second.workspaceId).toBe(first.workspaceId);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect(
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
).toHaveText(secondPrompt);
await expect(
page
.getByTestId("user-message")
.filter({ visible: true })
.filter({ hasText: secondPrompt }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
});
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
const timeline = await client.fetchAgentTimeline(agentId);
expect(
timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text),
).toHaveLength(1);
}
async function openOldHost(page: Page, workspaceId: string) {
const host = buildSeededHost({
serverId: daemon.serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
}
async function prepareCreation(
client: DaemonClient,
kind: "workspace" | "agent",
cwd: string,
prompt: string,
) {
const input = { provider: "mock", cwd, initialPrompt: prompt };
if (kind === "workspace")
return async () => {
const result = await client.createWorkspace({
idempotencyKey: "workspace-intent",
source: { kind: "worktree", cwd, worktreeSlug: "created-once" },
agent: input,
});
expect(result.error).toBeNull();
expect(result.workspace?.workspaceDirectory).not.toBe(cwd);
return result.agent!;
};
const workspace = await client.createWorkspace({
idempotencyKey: "empty-workspace",
source: { kind: "directory", path: cwd },
});
return () =>
client.createAgent({
...input,
workspaceId: workspace.workspace!.id,
idempotencyKey: "agent-intent",
});
}
});
}
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (holding && readers.client(frame)?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
return {
waitForRequest: () => arrived.promise,
release() {
const send = release;
release = () => {};
send();
},
};
}
@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
restartDaemonInSettings,
expectDaemonRestartComplete,
readWorkerPid,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
page,
}, testInfo) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const supervisor = daemon.getPid();
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(daemon.getPid()).toBe(supervisor);
await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true });
} finally {
await daemon.close();
}
});
@@ -1,6 +1,10 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import {
clickNewChat,
gotoWorkspace,
pressDirectNewTabShortcut,
} from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
@@ -1,7 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
@@ -7,7 +7,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
@@ -93,132 +93,221 @@ async function dragChipTo(
await page.mouse.up();
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`));
return errors;
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
withWorkspace,
e2eWorkerClient,
}, testInfo) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
const consoleErrors = collectConsoleErrors(page);
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" });
let agentId = "";
try {
await test.step("empty workspace seeds a draft in the main pane", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
await test.step("choose Agent from the empty workspace launcher", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await openAgentDraftFromLauncher(page);
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
});
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await e2eWorkerClient.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]')).toHaveCount(
1,
);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
await test.step("select Changes in Explorer", async () => {
await selectExplorerChanges(page);
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Explorer Placement Agent",
modeId: "load-test",
model: "ten-second-stream",
// Keep the agent streaming through the drag so pane contents are live,
// matching the reporter's "spinners were still going" environment.
initialPrompt: "stream please",
});
agentId = agent.id;
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
await expect(
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toBeVisible();
await expect(
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
).toHaveCount(0);
// The background open must not change the selected Explorer view.
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("close the draft; the agent is the main pane's only tab", async () => {
await closeSeededDraftInMainPane(page);
await expect(
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
).toHaveCount(1);
await testInfo.attach("before-drag", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await test.step("split the agent out of the main pane, leaving it empty", async () => {
const chip = agentTabChip(page, agentId).first();
const mainBox = await paneBoxContainingChip(page, chip);
// Land inside the left 15% edge band of the main pane content.
await dragChipTo(page, chip, {
x: mainBox.x + mainBox.width * 0.06,
y: mainBox.y + mainBox.height * 0.6,
});
await testInfo.attach("after-split", {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
// JS main thread alive?
const pong = await page.evaluate("1 + 1");
expect(pong).toBe(2);
// Pointer interaction alive? Selecting the agent chip must still work.
const chip = agentTabChip(page, agentId).first();
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the New tab pane (focuses it) must not lock anything. Aim
// above the vertically centred launcher, which would open a tab instead.
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// A second drag must work: center-drop the agent tab back into the New pane.
const target = await emptyPaneBox(page);
await dragChipTo(page, chip, {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await testInfo.attach("after-center-drop", {
body: await page.screenshot(),
contentType: "image/png",
});
// The agent chip must have replaced New in that pane and stay clickable.
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, {
timeout: 10_000,
});
await agentTabChip(page, agentId)
.first()
.click({ position: { x: 12, y: 13 } });
// The explorer toggle must still respond.
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
await testInfo.attach("after-interactions", {
body: await page.screenshot(),
contentType: "image/png",
});
});
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
} finally {
await workspace.cleanup();
}
await testInfo.attach("console-errors", {
body: JSON.stringify(consoleErrors, null, 2),
contentType: "application/json",
});
});
});
async function closeOnlyDraft(page: Page): Promise<void> {
await draftTabChip(page).hover();
await page.getByRole("button", { name: "Close", exact: true }).click();
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
const target = await emptyPaneBox(page);
await dragChipTo(page, draftTabChip(page), {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
await expect(draftTabChip(page)).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
await expectNewLauncher(page);
await page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
// Explorer cannot replace the ordinary workspace canvas, including on restore.
test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await openAgentDraftFromLauncher(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("visible Explorer does not replace the last ordinary workspace pane", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
await openAgentDraftFromLauncher(page);
await moveOnlyDraftIntoRightSplit(page);
await closeOnlyDraft(page);
await expectNewLauncher(page);
await page.reload();
await expectNewLauncher(page);
});
test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({
page,
withWorkspace,
}) => {
const otherWorkspace = await withWorkspace({ prefix: "uncorrupted-explorer-" });
await gotoWorkspace(page, otherWorkspace.workspaceId);
await openAgentDraftFromLauncher(page);
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await seedCorruptedWorkspaceLayout(page, workspace.workspaceId);
await page.reload();
await expectNewLauncher(page);
await gotoWorkspace(page, otherWorkspace.workspaceId);
await expect(draftTabChip(page)).toHaveCount(1);
});
@@ -111,21 +111,6 @@ test.afterEach(async () => {
await workspace?.cleanup();
});
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const row = page
.getByTestId("file-explorer-tree-scroll")
.getByTestId(/^file-explorer-row-\d+$/)
.first();
const name = row.getByTestId(/-name$/);
await expect(row).toHaveCSS("opacity", "1");
await expect(name).toHaveCSS("opacity", "0.76");
await row.hover();
await expect(name).toHaveCSS("opacity", "1");
});
test("creates, renames, copies, and deletes entries through the file explorer", async ({
context,
page,
@@ -82,121 +82,66 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
},
});
test.describe("Host appearance", () => {
test.describe.configure({ timeout: 180_000 });
test.describe.configure({ timeout: 180_000 });
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
};
test("customizes a host badge and preserves its appearance after reload", async ({
page,
twoHostSidebar,
}) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
};
const hostName = "Developer MacBook Pro.local";
await test.step("rename the host and use its available sidebar width", async () => {
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await leaveHostAppearanceSettings(page);
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
});
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
const hostName = "Developer MacBook Pro.local";
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await openHostAppearanceSettings(page, badge.serverId);
await renameHostFromSettings(page, hostName);
await leaveHostAppearanceSettings(page);
await expectHostBadgeName(page, { ...badge, hostName });
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName);
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
const row = page.getByTestId(`sidebar-workspace-row-${badge.serverId}:${badge.workspaceId}`);
const hostBadge = row.getByTestId(`host-badge-${badge.serverId}`);
const [badgeBox, rowBox] = await Promise.all([hostBadge.boundingBox(), row.boundingBox()]);
expect(badgeBox).not.toBeNull();
expect(rowBox).not.toBeNull();
expect(badgeBox!.width).toBeGreaterThan(96);
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
clientWidth: label.clientWidth,
scrollWidth: label.scrollWidth,
const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({
client: label.clientWidth,
content: label.scrollWidth,
}));
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
expect(widths.content).toBeLessThanOrEqual(widths.client);
});
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("preview a color and apply it to the sidebar badge", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostColor(page, "Teal");
await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" });
await leaveHostAppearanceSettings(page);
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" });
});
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
const badge = {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
};
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("keep the icon-only preference through reload", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await expectHostBadgeIconOnly(page, badge);
await expectHostBadgeIconOnly(page, { ...badge, hostName });
await reloadPreservingHostRegistry(page);
await waitForSidebarHydration(page);
await expectHostBadgeIconOnly(page, { ...badge, hostName });
});
test("hiding one host leaves the other host's badge visible", async ({
page,
twoHostSidebar,
}) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await test.step("hide this badge without hiding the other host", async () => {
await openHostAppearanceSettings(page, badge.serverId);
await chooseHostBadgeDisplay(page, "Hidden");
await leaveHostAppearanceSettings(page);
await expectNoHostBadge(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: SECONDARY_HOST_LABEL,
});
await expectNoHostBadge(page, { ...badge, hostName });
await expectHostBadgeName(page, {
serverId: twoHostSidebar.primaryServerId,
workspaceId: twoHostSidebar.primaryWorkspaceId,
hostName: PRIMARY_HOST_LABEL,
});
});
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Amber");
await chooseHostBadgeDisplay(page, "Icon only");
await leaveHostAppearanceSettings(page);
await reloadPreservingHostRegistry(page);
await waitForSidebarHydration(page);
await expectHostBadgeIconOnly(page, {
serverId: twoHostSidebar.secondaryServerId,
workspaceId: twoHostSidebar.secondaryWorkspaceId,
hostName: "Build Box",
});
});
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
await renameHostFromSettings(page, "Build Box");
await chooseHostColor(page, "Emerald");
await expectHostAppearancePreview(page, {
serverId: twoHostSidebar.secondaryServerId,
hostName: "Build Box",
color: "emerald",
});
});
});
@@ -0,0 +1,44 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
await page.addInitScript(
([key, bindingId, value]) => {
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
},
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
);
await gotoAppShell(page);
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
}
test.describe("Multi-step shortcut", () => {
test("completes a chord whose second step is a bare key", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K J");
await page.keyboard.press("Control+k");
await page.keyboard.press("j");
await expectCommandCenterOpen(page);
});
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
test("completes a chord whose second step carries a modifier", async ({ page }) => {
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
await page.keyboard.press("Control+k");
await page.keyboard.press("Control+j");
await expectCommandCenterOpen(page);
});
});
@@ -0,0 +1,65 @@
import { expect, test, type Page } from "../support/fixtures";
// "Toggle command center" recorded as a two-key sequence. Both platform
// bindings are overridden so the spec does not depend on the host OS.
const COMMAND_CENTER_SEQUENCE = {
"command-center-toggle-cmd-k-mac": "Cmd+K J",
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
};
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
const SEQUENCE_FINAL_KEY = "j";
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
async function recordCommandCenterSequence(page: Page): Promise<void> {
await page.addInitScript((overrides) => {
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
}, COMMAND_CENTER_SEQUENCE);
}
/**
* Resize the window and let the app render again, so the resize lands between
* the two keys of the sequence rather than after them.
*/
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 800 });
await page.waitForFunction((expected) => window.innerWidth === expected, width);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function expectCommandCenterOpen(page: Page): Promise<void> {
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
}
test.describe("Multi-key shortcut sequences", () => {
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
test("a sequence completes when the window resizes between its keys", async ({
page,
withWorkspace,
}) => {
await recordCommandCenterSequence(page);
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
await workspace.navigateTo();
await page.keyboard.press(SEQUENCE_PREFIX);
await resizeWindowAndSettle(page, 1100);
await page.keyboard.press(SEQUENCE_FINAL_KEY);
await expectCommandCenterOpen(page);
});
});
+30 -145
View File
@@ -13,17 +13,13 @@ import {
pressNewTabShortcut,
pressDirectNewTabShortcut,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
expectTabTitleFits,
terminalSurfaceLocator,
} from "../support/helpers/launcher";
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
import {
expectTerminalSurfaceVisible,
setupDeterministicPrompt,
} from "../support/helpers/terminal-perf";
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalOutputContains,
@@ -43,7 +39,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
id: "e2e-empty-prompt",
name: "Empty Prompt",
command: "/bin/sh",
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
@@ -126,47 +122,40 @@ test.describe("Tab creation", () => {
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
test("opens the menu, then creates independent New tabs without creating agents", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "new_tab");
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
});
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await expect(newTabs.first()).toBeVisible();
const countBefore = await newTabs.count();
const draftCount = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
await test.step("opening the plus menu leaves the current tabs intact", async () => {
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
).toBeVisible();
await expect(newTabs).toHaveCount(countBefore);
await page.keyboard.press("Escape");
});
await test.step("two shortcuts open two independent launchers", async () => {
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(expect.arrayContaining(firstIds));
expect(await countTabsOfKind(page, "draft")).toBe(draftCount);
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
});
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
@@ -267,110 +256,6 @@ test.describe("Tab creation", () => {
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
test("never shades the New tab button", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
// OSC title escape sequence propagation is inherently flaky — the terminal
// must process the sequence, emit a title change event, and the tab bar
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(
workspace.repoPath,
"title-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
delay: 0,
});
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(
workspace.repoPath,
"debounce-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await terminalSurfaceLocator(page).click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
delay: 0,
});
}
await terminalSurfaceLocator(page).pressSequentially(
`printf '\\033]0;${finalTitle}\\007'\n`,
{ delay: 0 },
);
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await workspace.client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -1,4 +1,7 @@
import { test } from "../support/fixtures";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect, type Page } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import {
closeModelPicker,
expectModelSearchEmptyState,
@@ -15,7 +18,7 @@ import {
expectSearchResultsVirtualized,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
@@ -158,3 +161,111 @@ test.describe("Cross-provider model search", () => {
}
});
});
async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
extends: "acp",
label: "Catalog provider",
enabled: true,
command: [
process.execPath,
path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"),
String(count),
],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.status,
{ timeout: 30_000 },
)
.toBe("ready");
}
async function reloadSavedDraft(page: Page) {
await page.evaluate(() =>
localStorage.setItem(
"@paseo:e2e-disable-default-seed-once",
localStorage.getItem("@paseo:e2e-seed-nonce")!,
),
);
await page.reload();
await expectComposerVisible(page);
}
async function expectOneCatalogChoice(page: Page, label: string) {
await openModelPicker(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await searchAllModels(page, label);
await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1);
await closeModelPicker(page);
}
async function configureModelOverride(client: DaemonClient, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd: cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.models,
{ timeout: 30_000 },
)
.toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]);
}
const catalogTest = test.extend<{ catalogClient: DaemonClient }>({
catalogClient: async ({ e2eWorker }, provide) => {
void e2eWorker;
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "catalog-models" });
try {
await provide(client);
} finally {
try {
await client.patchDaemonConfig({ removeProviders: ["gemini"] });
} finally {
await client.close();
}
}
},
});
catalogTest(
"New Agent and saved drafts stay usable with repeated runtime model rows",
async ({ page, withWorkspace, catalogClient: client }, testInfo) => {
const workspace = await withWorkspace({ prefix: "catalog-models-" });
await test.step("open a draft with a one-row runtime catalog", async () => {
await setRuntimeCatalog(client, 1, workspace.repoPath);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
});
await test.step("open New Agent after the provider publishes repeated model rows", async () => {
await clickNewTerminal(page);
await setRuntimeCatalog(client, 2, workspace.repoPath);
await clickNewChat(page);
await expectComposerVisible(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") });
});
await test.step("reopen the saved draft with the same catalog", async () => {
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
});
await test.step("retain configured model overrides when runtime IDs repeat", async () => {
await configureModelOverride(client, workspace.repoPath);
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Configured model");
});
},
);
@@ -1,9 +1,10 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, type Page } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
@@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "codex-mode-preferences",
@@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
startup,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false });
try {
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
await gotoAppShell(page);
@@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => {
).toBeVisible();
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
const createAgentRequest = await createAgentRecorder.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByText("Keep Codex full access selected globally.", { exact: true }).first(),
).toBeVisible();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
@@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => {
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
await startup.fail();
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
} finally {
await seeded.cleanup();
}
@@ -4,17 +4,21 @@ import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
for (const launch of ["chat", "terminal", "empty"] as const) {
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave");
});
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
});
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "stay");
});
}
// Exercise each submit handler's navigation guard. Checkout isolation shares
// those handlers; ordinary successful launches live in the creation journeys.
test("chat worktree creation keeps the workspace chosen while it was pending", async ({
page,
}) => {
await verifyDelayedWorkspaceCreation(page, "chat", "leave", "worktree");
});
test("terminal creation keeps the workspace chosen while it was pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "terminal", "leave");
});
test("empty creation keeps the workspace chosen while it was pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "empty", "leave");
});
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
@@ -1,5 +1,12 @@
import { randomUUID } from "node:crypto";
import { metroTest as test } from "../support/fixtures";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -17,6 +24,19 @@ test("does not repeat an assistant block when the current app paginates a publis
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (
frame.type === "session" &&
frame.message.type === "fetch_agents_request" &&
frame.message.subscribe
)
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
@@ -79,6 +99,40 @@ test("does not repeat an assistant block when the current app paginates a publis
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
+475
View File
@@ -0,0 +1,475 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
function querySelection(page: Page) {
return query(page).evaluate((input: HTMLInputElement) => [
input.selectionStart,
input.selectionEnd,
]);
}
/** The query box keeps the caret where the user left it, with nothing reselected. */
async function expectQueryCaretKept(page: Page, text: string) {
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
await expect(query(page)).toHaveValue(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectActiveMatchUncovered(page: Page) {
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
await expect(match).toBeVisible();
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
await expect
.poll(async () => {
const m = await match.boundingBox();
const w = await widget.boundingBox();
if (!m || !w) return "missing";
const covered =
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
return covered ? "covered by Find" : "visible";
})
.toBe("visible");
}
async function findBoxes(page: Page) {
const pane = (await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox())!;
const widget = (await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox())!;
return { pane, widget };
}
async function expectFindInsidePane(page: Page) {
const { pane, widget } = await findBoxes(page);
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
}
async function expectFindAtTopRight(page: Page) {
await expectFindInsidePane(page);
const { pane, widget } = await findBoxes(page);
expect(widget.y).toBeLessThan(pane.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("keeps Find controls inside a narrow source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const right = source(page).filter({ hasText: "needle one" });
await expect(right).toBeVisible();
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
await test.step("stays visible when Replace expands the widget", async () => {
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
});
await test.step("navigation and query focus still work", async () => {
await query(page).press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
});
await query(page).press("Escape");
await expect(right).toBeFocused();
});
});
test("Go to line keeps its dialog with Find closed and open", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
await workspace.navigateTo();
await openSource(page, "lines.txt");
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
await test.step("with Find closed", async () => {
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
await gotoInput.fill("12");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
await expect(gotoInput).toBeHidden();
});
await test.step("with Find open", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("line 3");
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
await gotoInput.fill("30");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
await expect(query(page)).toHaveValue("line 3");
await expect(status(page)).toHaveText("11 matches");
});
});
test.describe("macOS", () => {
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
});
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await source(page).focus();
await source(page).press("Control+f");
await expect(query(page)).toHaveCount(0);
await source(page).press("Meta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await test.step("Control+f inside the query box does not reclaim the field", async () => {
await query(page).press("End");
await query(page).press("Control+f");
await expectQueryCaretKept(page, "needle");
});
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
await query(page).press("Meta+f");
await expectQuerySelected(page, "needle");
await closeFind(page);
});
});
@@ -1,4 +1,5 @@
import { mkdtempSync, realpathSync } from "node:fs";
import type { Page, TestInfo } from "@playwright/test";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
@@ -18,7 +19,7 @@ const scenarios = [
},
{
provider: "codex" as const,
providerConfig: { featureValues: { plan_mode: true } },
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
planPrompt:
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
planText: "Inspect permission steering",
@@ -27,57 +28,151 @@ const scenarios = [
},
];
test.describe("composer steer supersedes plan approval", () => {
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
async function readConversationOrder(page: Page, prompt: string, reply: string) {
const plan = page.getByTestId("timeline-plan-card");
const question = page.getByTestId("user-message").filter({ hasText: prompt });
const answer = page.getByTestId("assistant-message").filter({ hasText: reply });
const boxes = await Promise.all([
plan.boundingBox(),
question.boundingBox(),
answer.boundingBox(),
]);
return boxes
.map((box, index) => {
if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered");
return { role: ["plan", "question", "answer"][index], y: box.y };
})
.sort((a, b) => a.y - b.y)
.map((item) => item.role);
}
async function togglePlan(
page: Page,
testId: string,
name: string,
expanded: boolean,
): Promise<void> {
const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", String(expanded));
}
async function expectRejectedPlanCollapsed(page: Page): Promise<void> {
await expect(
page
.getByTestId("timeline-plan-card")
.getByRole("button", { name: "Rejected plan", exact: true }),
).toHaveAttribute("aria-expanded", "false");
}
async function reopenConversation(page: Page, reply: string): Promise<void> {
await page.reload();
await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible();
}
type PlanScenario = (typeof scenarios)[number];
interface PlanSteer {
page: Page;
scenario: PlanScenario;
testInfo: TestInfo;
}
async function withPlanSteer(context: PlanSteer, journey: () => Promise<void>): Promise<void> {
const { page, scenario } = context;
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
}, testInfo) => {
test.setTimeout(420_000);
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
await submitMessage(page, scenario.steerPrompt);
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expect(rejectedPlan).toContainText(scenario.planText);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await journey();
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
await togglePlan(page, "permission-plan-card", "Plan", false);
await expect(page.getByTestId("permission-request-deny")).toBeVisible();
await togglePlan(page, "permission-plan-card", "Plan", true);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
}
async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.steerPrompt);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expectRejectedPlanCollapsed(page);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
await expect(
page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }),
).toBeVisible();
expect
.soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply))
.toEqual(["plan", "question", "answer"]);
}
async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise<void> {
const rejectedPlan = page.getByTestId("timeline-plan-card");
await togglePlan(page, "timeline-plan-card", "Rejected plan", true);
await expect(rejectedPlan).toContainText(scenario.planText);
await rejectedPlan.screenshot({
path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`),
});
await togglePlan(page, "timeline-plan-card", "Rejected plan", false);
}
async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise<void> {
await reopenConversation(page, scenario.reply);
await expectRejectedPlanCollapsed(page);
expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([
"plan",
"question",
"answer",
]);
}
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
const context = { page, scenario, testInfo };
await withPlanSteer(context, async () => {
await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context));
await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context));
await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context));
await test.step("Keep conversation order after reload", () =>
restoreConversationOrder(context));
});
});
}
@@ -0,0 +1,164 @@
import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import {
allowPermission,
denyPermission,
waitForPermissionPrompt,
} from "../support/helpers/permissions";
import {
assertComposerIdle,
cleanupRewindFlow,
launchAgent,
type AgentHandle,
} from "../support/helpers/rewind-flow";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
const PLAN_PROMPT =
"Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED.";
async function requestPlan(page: Page, prompt: string): Promise<void> {
await submitMessage(page, prompt);
await waitForPermissionPrompt(page, 180_000);
await expect(page.getByTestId("permission-plan-card")).toContainText("README.md");
}
function planWithOutcome(page: Page, label: string) {
return page
.getByTestId("timeline-plan-card")
.filter({ has: page.getByRole("button", { name: label, exact: true }) });
}
async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise<void> {
const card = planWithOutcome(page, label);
await expect(card).toHaveCount(1);
await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute(
"aria-expanded",
String(expanded),
);
}
async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise<void> {
await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click();
await expectPlanOutcome(page, label, expanded);
}
async function expectClaudeReply(page: Page, marker: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({
timeout: 180_000,
});
await assertComposerIdle({ page });
}
async function restartIsolatedDaemon(): Promise<void> {
const port = Number(getE2EDaemonPort());
expect([6767, 6768]).not.toContain(port);
const client = await connectDaemonClient<{
connect(): Promise<void>;
close(): Promise<void>;
getDaemonStatus(): Promise<{ pid: number }>;
restartServer(reason: string): Promise<unknown>;
}>({ port, clientIdPrefix: "plan-history-restart" });
try {
const before = (await client.getDaemonStatus()).pid;
await client.restartServer("Verify plan history replay in the isolated Playwright daemon");
await expect
.poll(
async () => {
try {
return (await client.getDaemonStatus()).pid;
} catch {
return before;
}
},
{ timeout: 60_000 },
)
.not.toBe(before);
} finally {
await client.close();
}
}
async function expectRecordedPlans(page: Page): Promise<void> {
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 });
await expectPlanOutcome(page, "Rejected plan", false);
await expectPlanOutcome(page, "Approved plan", true);
}
interface PlanReview {
page: Page;
cwd: string;
testInfo: TestInfo;
}
async function withPlanReview(
page: Page,
testInfo: TestInfo,
review: (context: PlanReview) => Promise<void>,
): Promise<void> {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "haiku", modeId: "plan" },
});
await review({ page, cwd, testInfo });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
}
async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, PLAN_PROMPT);
await denyPermission(page);
await expectClaudeReply(page, "PLAN_REJECTED");
await expectPlanOutcome(page, "Rejected plan", false);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") });
await toggleRecordedPlan(page, "Rejected plan", false);
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello");
}
async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`);
await allowPermission(page);
await expectClaudeReply(page, "PLAN_IMPLEMENTED");
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello");
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Approved plan", false);
await toggleRecordedPlan(page, "Approved plan", true);
await page.screenshot({ path: testInfo.outputPath("approved-plan.png") });
}
async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise<void> {
await restartIsolatedDaemon();
await page.reload();
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await toggleRecordedPlan(page, "Rejected plan", false);
await page.screenshot({ path: testInfo.outputPath("restored-plans.png") });
}
test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
await withPlanReview(page, testInfo, async (review) => {
await test.step("Reject a plan without implementing it", () =>
rejectPlanWithoutImplementing(review));
await test.step("Approve and implement a fresh plan", () =>
approveFreshPlanAndImplement(review));
await test.step("Restore readable plans after daemon restart", () =>
restoreReadablePlanHistory(review));
});
});
@@ -3,9 +3,10 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}, testInfo) => {
}) => {
test.setTimeout(180_000);
await withButtonShowcase(page, testInfo, async (buttons) => {
await withButtonShowcase(page, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
@@ -0,0 +1,31 @@
import { test } from "../support/fixtures";
import { installHostClientsScenario, readHost } from "../support/helpers/plugin-hosts";
test("plugin discovers offline hosts and borrows another host without installing there", async ({
page,
}) => {
test.setTimeout(120_000);
const hosts = await installHostClientsScenario(page);
try {
await hosts.expectOnline();
await readHost(page, "selected", "selected:plugins=true");
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
await hosts.disposeSecondaryAndExpectObservationReleased();
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
await readHost(page, "missing", "Unknown Paseo host: missing");
await hosts.reloadAndExpectObservationReleased();
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
await page.screenshot({ path: test.info().outputPath("two-hosts.png") });
await hosts.restartSecondary();
await hosts.expectOnline();
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
await hosts.useCompactLayout();
await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`);
await page.screenshot({ path: test.info().outputPath("two-hosts-compact.png") });
await hosts.disconnectSecondary();
await readHost(page, "Secondary", `Paseo host is disconnected: ${hosts.serverId}`);
await readHost(page, "selected", "selected:plugins=true");
} finally {
await hosts.close();
}
});
@@ -1,17 +1,109 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { pathToFileURL } from "node:url";
import type { TestInfo } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import {
expectSettingsHeader,
openCompactSettings,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
interface PluginEnvironment {
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
directory: string;
}
const test = base.extend<
{ pluginEnvironment: PluginEnvironment },
{ npmRegistry: Awaited<ReturnType<typeof startNpmRegistry>> }
>({
pluginEnvironment: async ({ e2eWorker: _e2eWorker }, provide) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugins-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await provide({ client, directory });
} finally {
try {
const current = await client.getDaemonConfig();
const existingIds = new Set(Object.keys(previous.config.plugins ?? {}));
for (const id of Object.keys(current.config.plugins ?? {})) {
if (!existingIds.has(id)) await client.removePlugin(id);
}
} finally {
try {
await client.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
} finally {
try {
await client.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
}
},
npmRegistry: [
async ({ browserName: _browserName }, provide) => {
const registry = await startNpmRegistry(npmPluginPackages());
try {
await provide(registry);
} finally {
await registry.close();
}
},
{ scope: "worker" },
],
e2eDaemonEnvironment: [
async ({ npmRegistry }, provide) => {
await provide(npmRegistry.env);
},
{ scope: "worker" },
],
});
async function advertisePluginCapabilities(
page: Page,
features: Record<string, boolean | undefined>,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = JSON.parse(message);
if (
envelope.message?.type === "status" &&
envelope.message.payload?.status === "server_info"
) {
Object.assign(envelope.message.payload.features, features);
}
browser.send(JSON.stringify(envelope));
});
});
}
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
@@ -72,11 +164,28 @@ async function openPluginSettings(page: Page): Promise<void> {
await expectSettingsHeader(page, "Plugins");
}
async function openCompactPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openCompactSettings(page, buildOpenProjectRoute());
await openHostSection(page, serverId, "plugins");
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
}
async function reloadPlugin(page: Page): Promise<void> {
await page.getByRole("button", { name: "Reload", exact: true }).click();
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
}
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
await expect(page.getByRole("menuitem").first()).toBeVisible();
}
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
await openPluginActions(page, pluginId);
await page.getByRole("menuitem", { name: action, exact: true }).click();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1);
@@ -115,99 +224,434 @@ async function expectContributionRemoved(page: Page, title: string): Promise<voi
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
async function capturePluginInstallForm(
page: Page,
testInfo: TestInfo,
name: "wide" | "wide-menu" | "compact-error" | "compact-success" | "compact-menu",
): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(`plugin-source-install-${name}.png`),
fullPage: true,
animations: "disabled",
});
}
async function installPlugin(page: Page, source: string): Promise<void> {
await page.getByLabel("Plugin source").fill(source);
await page.getByRole("button", { name: "Install plugin" }).click();
}
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
const requestedPage = page
.context()
.waitForEvent(
"request",
(request) => request.isNavigationRequest() && request.url().startsWith("https://paseo.sh/"),
);
const docsPagePromise = page.context().waitForEvent("page");
await page.getByRole("link", { name: "Docs", exact: true }).click();
const request = await requestedPage;
const docsPage = await docsPagePromise;
try {
expect(new URL(request.url()).pathname).toBe("/docs/plugins/reference");
// The deployed site can redirect while the matching website change is still in this PR.
await docsPage.waitForURL(
(url) => url.origin === "https://paseo.sh" && url.hash === "#plugin-sources",
{ waitUntil: "commit" },
);
} finally {
await docsPage.close();
}
}
async function createGitPluginRepository(root: string): Promise<string> {
const repository = path.join(root, "repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "git-e2e-plugin",
description: "Adds review tools from a Git repository",
requirements: pluginRequirements,
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Git plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function createDirectoryPlugin(
root: string,
id: string,
description: string | undefined,
title: string,
): Promise<string> {
const directory = await mkdtemp(path.join(root, "plugin-row-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "e2e-plugin", requirements: pluginRequirements }),
JSON.stringify({ id, description, requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() { return <Text>${title}</Text>; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
return () => undefined;
}`,
);
return directory;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
directory: string,
): Promise<void> {
await client.installPluginSource({ source: directory });
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
await client.reloadPlugin("failed-preview-plugin");
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
pluginEnvironment,
}, testInfo) => {
const directory = await installLocalPluginWithStatusExamples(page, pluginEnvironment, testInfo);
await reloadAndRecoverLocalPlugin(page, pluginEnvironment.client, directory);
await toggleRemoveAndReinstallLocalPlugin(page, directory);
});
test("installs a Git source after a failed source remains editable", async ({
page,
pluginEnvironment,
}, testInfo) => {
const source = await prepareCompactGitInstall(page, pluginEnvironment);
await retryGitInstallAndInspectRows(page, source, testInfo);
});
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
page,
pluginEnvironment,
}) => {
const directory = await prepareLegacyPluginHost(page, pluginEnvironment, sourceSupport);
await verifyLegacyPluginManagement(page, directory);
});
}
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
]) {
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
page,
pluginEnvironment,
}, testInfo) => {
await prepareNpmPluginHost(page, pluginEnvironment, viewport);
await retryNpmInstallAndInspectRow(page, viewport.width, testInfo);
await reloadAndRemoveNpmPlugin(page);
});
}
async function openNpmPluginSettings(page: Page, width: number) {
if (width < 600) await openCompactPluginSettings(page);
else await openPluginSettings(page);
}
async function expectSourceHierarchy(page: Page, description: string, source: string) {
const descriptionText = page.getByText(description, { exact: false });
const sourceText = page.getByText(source, { exact: false });
const descriptionSize = await descriptionText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
const sourceSize = await sourceText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
expect(sourceSize).toBeLessThan(descriptionSize);
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
}
async function installLocalPluginWithStatusExamples(
page: Page,
pluginEnvironment: PluginEnvironment,
testInfo: TestInfo,
) {
const directory = await mkdtemp(path.join(pluginEnvironment.directory, "local-"));
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"disabled-preview-plugin-with-a-long-name",
"Keeps optional previews off until this plugin is enabled",
"Preview plugin",
);
const failedDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"failed-preview-plugin",
"Demonstrates a plugin that needs attention",
"Failed preview plugin",
);
const { client } = pluginEnvironment;
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "e2e-plugin",
description: "Exercises the complete local plugin lifecycle",
requirements: pluginRequirements,
}),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByLabel("Plugin installation ID").focus();
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await expectPluginSourceDocsOpen(page);
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await client.installPluginSource({ source: disabledDirectory });
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
await installFailedPlugin(page, client, failedDirectory);
await page.getByLabel("Plugin source").fill(directory);
await page.getByRole("button", { name: "Install plugin" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
await expect(
page.getByText("Keeps optional previews off until this plugin is enabled"),
).toBeVisible();
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
await capturePluginInstallForm(page, testInfo, "wide");
await openPluginActions(page, "e2e-plugin");
await capturePluginInstallForm(page, testInfo, "wide-menu");
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
await page.getByRole("button", { name: "Close", exact: true }).click();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
return directory;
}
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
async function reloadAndRecoverLocalPlugin(
page: Page,
client: PluginEnvironment["client"],
directory: string,
) {
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(
path.join(directory, "index.client.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(
path.join(directory, "index.client.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await openPluginSettings(page);
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
}
await openPluginSettings(page);
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
await openPluginSettings(page);
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
await openPluginSettings(page);
await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await page.getByLabel("Plugin directory").fill(directory);
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
} finally {
await client.removePlugin("e2e-plugin").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "e2e-plugin", "Remove");
await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
await installPlugin(page, directory);
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
}
async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnvironment) {
const root = pluginEnvironment.directory;
const repository = await createGitPluginRepository(root);
const longDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-plugin-with-a-realistically-long-name",
"Shows how a realistically long plugin description wraps on a compact screen",
"Compact long plugin",
);
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-disabled-plugin",
"Remains installed and ready to enable later",
"Compact disabled plugin",
);
const missingSource = path.join(root, "missing-plugin");
const { client } = pluginEnvironment;
await page.setViewportSize({ width: 390, height: 844 });
await client.installPluginSource({ source: longDirectory });
await client.installPluginSource({ source: disabledDirectory });
await client.disablePlugin("compact-disabled-plugin");
await gotoAppShell(page);
await openCompactPluginSettings(page);
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
return { repository, missingSource };
}
async function retryGitInstallAndInspectRows(
page: Page,
source: { repository: string; missingSource: string },
testInfo: TestInfo,
) {
const { repository, missingSource } = source;
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
await installPlugin(page, pathToFileURL(repository).href);
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
await expect(
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
).toBeVisible();
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
await expect(page.getByLabel("Plugin source")).toHaveValue("");
await capturePluginInstallForm(page, testInfo, "compact-success");
await openPluginActions(page, "git-e2e-plugin");
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
await expect(removeAction).toBeInViewport({ ratio: 1 });
await waitForSettledPosition(removeAction);
await capturePluginInstallForm(page, testInfo, "compact-menu");
await page.keyboard.press("Escape");
}
async function prepareLegacyPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
sourceSupport: boolean | undefined,
) {
const directory = await createDirectoryPlugin(
pluginEnvironment.directory,
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const { client } = pluginEnvironment;
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
return directory;
}
async function verifyLegacyPluginManagement(page: Page, directory: string) {
await expect(
page.getByText("Update this host to install plugins", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
await expect(page.getByText(directory, { exact: true })).toBeVisible();
await selectPluginAction(page, "legacy-source-plugin", "Reload");
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
}
async function prepareNpmPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
viewport: { width: number; height: number },
) {
const { client } = pluginEnvironment;
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
}
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
await installPlugin(page, "npm:missing-plugin");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toBeVisible();
await expectSourceHierarchy(
page,
"Installed from the npm fixture registry",
"npm:@paseo-fixture/review · 2.0.0",
);
await expect(
page.getByText("Installed from the npm fixture registry", { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`npm-plugin-${width}.png`),
animations: "disabled",
});
}
async function reloadAndRemoveNpmPlugin(page: Page) {
await selectPluginAction(page, "npm-review", "Reload");
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "npm-review", "Remove");
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
@@ -14,6 +14,8 @@ async function openExample(page: Page, example: string) {
async function closeExample(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).last().click();
// The launcher is visible behind a closing sheet; wait for dismissal before reopening.
await expect(page.getByText(/^Modal example:/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
@@ -0,0 +1,35 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
test(`assistant plugin receives the whole streaming message at width ${width}`, async ({
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
await page.reload({ waitUntil: "domcontentloaded" });
await expectWholeCompletedCard(page);
});
});
}
test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => {
await withTimelinePlugin(page, info, "tools", async (agent) => {
await requestPluginTimeline(agent);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectBothConsecutiveTools(page);
await page.reload({ waitUntil: "domcontentloaded" });
await expectBothConsecutiveTools(page);
});
});
@@ -27,11 +27,18 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
function pluginClientSource(input: {
workspaceId: string;
agentId: string;
remoteServerId: string;
remoteWorkspaceId: string;
remoteAgentId: string;
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
@@ -50,9 +57,14 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
function DirectCollisionSurface({ navigation }) {
return <View>
<Text>Direct collision surface</Text>
<Text>{navigation.openBrowser ? "Browser available" : "Browser unavailable"}</Text>
<ExternalLink href="https://example.com/">Plugin documentation</ExternalLink>
<Pressable accessibilityRole="button" onPress={() => openExternalUrl("https://example.com/")}><Text>Open external plugin URL</Text></Pressable>
{navigation ? <>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ serverId: ${JSON.stringify(input.remoteServerId)}, workspaceId: ${JSON.stringify(input.remoteWorkspaceId)} })}><Text>Open remote workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ serverId: ${JSON.stringify(input.remoteServerId)}, agentId: ${JSON.stringify(input.remoteAgentId)} })}><Text>Open remote agent from plugin</Text></Pressable>
</> : null}
</View>;
}
@@ -67,7 +79,7 @@ function contributeClient(client) {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
const apply = (update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
@@ -94,9 +106,20 @@ function contributeClient(client) {
},
});
pills.set(agent.id, () => pill.remove());
});
};
const lifetime = new AbortController();
void client.paseo.agents.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
snapshot({ entries }) {
for (const removePill of pills.values()) removePill();
pills.clear();
for (const { agent } of entries) apply({ kind: "upsert", agent });
},
update(message) { if (message.type === "agent_update") apply(message.payload); },
});
}).catch((error) => { if (!lifetime.signal.aborted) console.error(error); });
return () => {
unsubscribe();
lifetime.abort();
for (const removePill of pills.values()) removePill();
pills.clear();
};
@@ -137,7 +160,13 @@ export default function contribute(server) {
async function writePluginSources(
directory: string,
input: { workspaceId: string; agentId: string },
input: {
workspaceId: string;
agentId: string;
remoteServerId: string;
remoteWorkspaceId: string;
remoteAgentId: string;
},
): Promise<void> {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
@@ -171,6 +200,52 @@ async function runCommand(page: Page, title: string): Promise<void> {
await expect(panel).not.toBeVisible();
}
async function openPluginExternalTab(page: Page, role: "link" | "button", name: string) {
const opened = page.context().waitForEvent("page");
await page.getByRole(role, { name, exact: true }).click();
const tab = await opened;
await tab.waitForURL("https://example.com/");
await tab.close();
}
interface RemoteNavigationTarget {
serverId: string;
workspaceId: string;
agentId: string;
}
function remoteWorkspaceRoute(target: RemoteNavigationTarget): RegExp {
return new RegExp(
`/h/${encodeURIComponent(target.serverId)}/workspace/${encodeURIComponent(target.workspaceId)}(?:\\?.*)?$`,
);
}
async function openRemoteWorkspaceFromPlugin(
page: Page,
target: RemoteNavigationTarget,
): Promise<void> {
await page
.getByRole("button", { name: "Open remote workspace from plugin", exact: true })
.click();
await page.waitForURL(remoteWorkspaceRoute(target));
await expect(
page
.getByTestId(`workspace-deck-entry-${target.serverId}:${target.workspaceId}`)
.getByTestId("workspace-header-title"),
).toBeVisible();
}
async function openRemoteAgentFromPlugin(
page: Page,
target: RemoteNavigationTarget,
): Promise<void> {
await page.getByRole("button", { name: "Open remote agent from plugin", exact: true }).click();
await page.waitForURL(remoteWorkspaceRoute(target));
await expect(
page.getByTestId(`workspace-tab-agent_${target.agentId}`).filter({ visible: true }).first(),
).toBeVisible();
}
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
@@ -197,6 +272,14 @@ test.describe("plugin workspace panels and Command Center", () => {
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
const secondaryAgent = await secondary.client.createAgent({
provider: "mock",
cwd: secondary.repoPath,
workspaceId: secondary.workspaceId,
title: "Remote plugin navigation agent",
model: "ten-second-stream",
modeId: "load-test",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
@@ -204,6 +287,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
remoteServerId: secondaryDaemon.serverId,
remoteWorkspaceId: secondary.workspaceId,
remoteAgentId: secondaryAgent.id,
});
try {
@@ -252,6 +338,15 @@ test.describe("plugin workspace panels and Command Center", () => {
await capture(page, testInfo, "plugin-workspace-panel-wide");
});
await test.step("plugin links open browser tabs and in-app browsing is unavailable", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Browser unavailable", { exact: true })).toBeVisible();
await openPluginExternalTab(page, "link", "Plugin documentation");
await openPluginExternalTab(page, "button", "Open external plugin URL");
await capture(page, testInfo, "plugin-external-links-web");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
@@ -283,6 +378,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: navigationAgentId,
remoteServerId: secondaryDaemon.serverId,
remoteWorkspaceId: secondary.workspaceId,
remoteAgentId: secondaryAgent.id,
});
await primaryClient.reloadPlugin(PLUGIN_ID);
@@ -297,6 +395,23 @@ test.describe("plugin workspace panels and Command Center", () => {
).toBeVisible();
});
await test.step("surface navigation opens a workspace and agent on another host", async () => {
const remoteTarget = {
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
agentId: secondaryAgent.id,
};
await runCommand(page, "Open direct collision surface");
await openRemoteWorkspaceFromPlugin(page, remoteTarget);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await runCommand(page, "Open direct collision surface");
await openRemoteAgentFromPlugin(page, remoteTarget);
});
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
@@ -344,7 +459,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
// Pressing removed the pill from that page only. The reloaded page evaluates
// the plugin again, and its agents snapshot contributes the pill afresh.
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
@@ -1,50 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import {
beginReplicaCacheObservation,
finishReplicaCacheObservation,
installReplicaCacheMeasurement,
measureReplicaCacheRestore,
type ReplicaCacheMeasurementReport,
} from "../support/helpers/replica-cache-measurement";
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
const HYDRATED_SELECTOR =
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
measurementDescribe("Replica cache live measurement", () => {
test("observes persistence and cached restore without mutating the daemon", async ({
page,
}, testInfo) => {
test.setTimeout(OBSERVATION_MS + 90_000);
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
await installReplicaCacheMeasurement(page);
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle").catch(() => undefined);
const observation = await beginReplicaCacheObservation(page);
await page.waitForTimeout(OBSERVATION_MS);
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
await testInfo.attach("replica-cache-measurement", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
});
});
@@ -26,6 +26,13 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function selectRewindOption(page: Page, name: string): Promise<void> {
const option = page.getByRole("menuitem", { name, exact: true });
// Menus mount offscreen while their anchor and content are measured.
await expect(option).toBeInViewport({ ratio: 1 });
await option.click();
}
async function rewriteCachedMessageAsLegacyRow(
page: Page,
input: { prompt: string; agentId: string; workspaceId: string },
@@ -190,6 +197,7 @@ test.describe("Rewind sheet", () => {
await expectUserMessageVisible(page, secondPrompt);
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await session.client.waitForFinish(session.agentId);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
@@ -202,7 +210,7 @@ test.describe("Rewind sheet", () => {
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await selectRewindOption(page, "Rewind conversation");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
@@ -221,7 +229,7 @@ test.describe("Rewind sheet", () => {
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
@@ -232,7 +240,7 @@ test.describe("Rewind sheet", () => {
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await selectRewindOption(page, "Rewind files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
@@ -242,7 +250,7 @@ test.describe("Rewind sheet", () => {
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await selectRewindOption(page, "Rewind conversation and files");
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);

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