Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 21a25c39aa test(codex): assert the steer rewind is refused 2026-10-04 02:16:02 +02:00
paseo-bot[bot] c644da16d1 test(codex): move the held-response fixture into a helper 2026-10-04 02:15:45 +02:00
paseo-bot[bot]and3ae3ae dbf41b059e fix(codex): rewind whole turns so a steer never drops earlier prompts
The turn-boundary rule now lives in the Codex rewind helper. It refuses a
steer message on both history paths and counts turns rather than user
messages for thread/rollback, so a steer in a later turn no longer rolls
back one turn too many on Codex versions that still roll back.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
2026-10-04 02:11:43 +02:00
paseo-bot[bot] b4bf345a30 Merge main into fix/codex-steer-rewind 2026-10-04 02:09:45 +02:00
paseo-bot[bot] 4869214bcb fix(cli): confirm daemon restart when provider probes are slow (#6009)
paseo daemon restart gave each daemon status read one second. Status probes
every provider binary, which takes longer than that on machines with several
providers installed, so restart kept retrying until its deadline and reported
RESTART_NOT_CONFIRMED after the worker had already been replaced.

The status read now gets the remaining restart deadline. Connecting to the
replacement keeps its one-second bound, so polling a worker that is not up
yet stays responsive.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 01:58:22 +02:00
paseo-bot[bot] ed87679c67 fix(nix): ship the OpenCode v2 bridge plugin in the daemon closure (#5988)
The Nix package copies only what scripts/trace-daemon.mjs lists. The list
named the v1 bridge bundle but not opencode/v2/bridge-plugin.bundle.mjs,
so creating an OpenCode v2 agent from the Nix package failed with
"Bundled OpenCode bridge plugin artifact is missing".

List every bridge bundle under opencode/, and add a dist test that
installs the traced closure the way nix/package.nix does and loads the
bridge plugin for each OpenCode version from it.

Closes #5926

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-03 23:16:11 +01:00
Mohamed Boudra 5eb4c8afad feat(usage): show each agent's own plan usage in its context window details (#5975)
* feat(usage): stream reports for live agent accounts

Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately.

* fix(usage): retain account scope for open agent sessions

Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract.

* test(usage): update browser fixtures for scoped report streams

* feat(usage): let a usage card show window rows without pins

UsageCard takes a required pinnable prop. When false, a window row is a
plain bar: no Pressable, hover highlight, or pin glyph. The settings
Usage list passes true.

* feat(usage): show the agent's usage in the context window popover

Reports stream into the query cache as each update arrives, for the
host list and for a new per-agent query keyed by agent. The context
window popover fetches its agent's reports while open and renders one
unpinnable card per report as it lands, a loading sentence before the
first, the request error as a sentence, and nothing on hosts without
usage reports.

* feat(usage): hide the Refresh button on popover usage cards

UsageCard takes a required refreshable prop. The popover passes false
because its content takes no pointer events, so the button could not
be pressed; the card then shows its freshness inline. The settings
Usage list keeps the button.

* feat(usage): make the sidebar Usage summary opt-in

The sidebar Usage item starts hidden on every layout, not only on
compact ones, so resolveSidebarNavItems drops its compact input. While
the summary is off, the Usage list renders window rows without pins;
saved pins stay in preferences and return when it is turned on. Specs
that read the summary now turn it on first.

* docs(glossary): describe the popover usage card and the opt-in sidebar summary

* fix(usage): show a popover request error without retrying

A daemon error for an agent's usage, such as an unknown agent, does not
heal on retry, and reopening the popover fetches again. Retrying held
the loading sentence for about seven seconds before the error.

* test(usage): cover agent usage in the context window popover

The usage fixture records agentId, streams reports held behind
promises, and can switch host support between reloads. The meter spec
walks loading, streaming, a report problem, a request error and a host
without usage reports, on desktop and compact in both themes.

* fix(usage): keep the plain context window tooltip on hosts without usage

The popover widened to fit usage cards on every host. Hosts that cannot
report usage now keep the tooltip's default padding and width, as
before, and mount no usage section.

* fix(usage): let a report's Refresh replace it in the agent's list too

Refresh wrote the refreshed report only into the host's list, so a card
rendered from an agent's list kept the old report. Both lists now sit
under one host key and Refresh replaces the report in each that holds it.

* feat(usage): open the context window details in a sheet on compact screens

Wide screens keep the tooltip. On compact screens pressing the meter opens
a content-sized bottom sheet titled Context window. Both render one
ContextWindowDetails body; only the sheet's usage cards have Refresh,
since the tooltip cannot be pressed. Pins stay off in both.

* test(usage): cover the compact context window sheet

On the compact viewport the meter opens the sheet; its cards have Refresh,
a Refresh replaces the card, and Close dismisses the sheet. The glossary
names the sheet.

* feat(ui): give tooltips and the context window sheet accessible roles

Tooltip content is role=tooltip, and the context window sheet is a dialog
named Context window, so assistive tech and tests find them by role. The
agent usage message drops its test ID; it is found by its text.

* test(usage): reshape the context window and Usage switch specs as journeys

The context window spec runs one tooltip journey and one sheet journey per
theme, with no layout branches. Its fixture script, meter actions and
report builders move to a context-window helper, and it finds the meter,
tooltip, sheet and Refresh buttons by role. The theme comes from the
system colour scheme. The Usage switch test uses named helpers for the
Usage icon and the Summary in sidebar switch.

* fix(usage): say an agent's usage request failed instead of showing older reports

A failed request keeps whatever reports the query held: those from an
earlier open, or those that streamed in before the failure. The context
window details showed them as if they were the agent's current usage.
The request error now wins over held reports.

* test(usage): let a fixture clean up the context window spec's agent

The tooltip and sheet tests no longer wrap their steps in try/finally;
a spec-local fixture seeds the agent and removes it after the test.

* fix(usage): keep a refreshed report when the list it is in finishes

Pressing Refresh on one card while another source was still streaming
put the pre-refresh report back: the finished list replaced the query's
data with the copies the request had fetched when it started. A report
now keeps whichever copy was fetched later, both when a streamed copy
arrives and when the finished list replaces the streamed one.

* fix(usage): show only the login a resumed agent runs under now

An agent resumed under another login keeps its ID, so its usage query
kept the previous login's reports: reopening the context window showed
them while the new login's reports loaded, then both side by side until
the request finished. The agent's usage is now dropped as soon as its
tooltip or sheet closes, so each open loads the login the agent runs
under at that moment.

* fix(usage): stop a closed agent usage request from writing reports

Closing the context window details cancels the agent's usage request and
drops reports still on their way, so reopening after a resume under
another login sends a new request instead of joining the old one.
2026-10-03 23:12:57 +01:00
paseo-bot[bot]andScott Woolsey Biggart c7759713ec fix(server): publish workspace script status to every client (#6007)
Starting or stopping a script from one client sent script_status_update only
to that client, so other clients viewing the workspace kept the old status
until they reloaded. Script status is daemon state, so the WebSocket server
now publishes it to every session, and each session delivers it only to its
script_status_update subscribers. Scripts started over MCP use the same path.

Closes #5856

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Scott Woolsey Biggart <297695+swbiggart@users.noreply.github.com>
2026-10-03 23:58:17 +02:00
paseo-bot[bot]andhanhnt2 749fe3e5ae fix(server): keep the local speech library path out of agent environments (#5987)
The supervisor prepended the sherpa-onnx platform package directory to the
daemon worker's loader variable (PATH, LD_LIBRARY_PATH, DYLD_LIBRARY_PATH),
so every agent and terminal inherited it. In packaged desktop builds that
directory resolves inside app.asar, and on Windows Git Bash truncates the
PATH it hands to native executables at that entry.

The local speech worker already sets the loader variable on its own
environment when it is forked, so the daemon worker does not need it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: hanhnt2 <56497031+hanh9898@users.noreply.github.com>
2026-10-03 22:04:28 +02:00
paseo-bot[bot] 0d05584d04 fix(omp): open agent history that contains a failed browser tool call (#5985)
* fix(omp): load history that contains a failed Paseo browser tool result

Paseo browser tools return a structured { code, message, retryable } error in
the tool result details. The OMP result schema passed details.error through
unvalidated while the history mapper treated it as a string, so one failed
browser call threw and the whole agent timeline failed to load.

Closes #5949

* fix(omp): keep the browser tool's structured error in the tool result

Model details.error as a string or a structured { message, ... } error
instead of dropping non-string values, so the expanded tool row keeps
the error code and retryable flag, and read the failure text from the
structured message.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-03 19:23:11 +02:00
Mohamed Boudra 42806cdbf9 Let plugins play audio on every client (#5976)
* feat(plugins): play audio files on every client

* fix(audio): preserve voice output and isolate waiting cues

* fix(plugins): normalize audio MIME parameters

* refactor(audio): share PCM conversion and keep plugin playback file-only
2026-10-03 16:56:59 +01:00
Yu Han 15d8ba16a0 fix(cli): handle negated schedule limit flags (#5973) 2026-10-03 17:53:11 +02:00
paseo-ai[bot] 526ad1366f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-03 15:38:39 +00:00
Mohamed Boudra b5e9747e2a Add the reviewed plugin registry and the unlinked plugin directory pages (#5986)
* feat(plugins): install reviewed artifacts from open registries

* fix(website): build protocol before deployment checks

* fix(website): make plugin pages discoverable to search engines

Resolve missing registry entries in route loaders to return HTTP 404. Serve a registry-backed sitemap so newly listed plugins and authors are discoverable without rebuilding the website.

* feat(website): keep plugin directory unlinked for coordinated launch

Preserve direct routes and SEO while retaining the partner directory link. Cover nested Git artifacts through registry installation and pinned website commands.

* fix(website): link plugin cards by registry owner and stub the clipboard in e2e

Plugin and author URLs come from the owner segment of the plugin ID, not the author's GitHub login, so a listed plugin always resolves. The Playwright spec records clipboard writes instead of granting a permission WebKit does not have.
2026-10-03 16:30:13 +01:00
Mohamed Boudra 5f28b3cf8c fix(app): smooth chat history pagination and reserve image space (#5970)
* fix(app): smooth chat history pagination and reserve image space

Select the range at the pending reading offset before a prepend mounts rows. Reuse committed geometry for scroll input so a layout correction cannot hide the user's movement. Reserve image dimensions before loading and measure transient mounts in the browser regression.

* fix(app): scope cached image dimensions to their source

Raw file-path aliases let one workspace supply dimensions for another. Keep file metadata tied to its daemon and workspace, and close the delayed-image test server even when setup fails.

* fix(app): retain local image context in row height estimates

Pass the rendering server and workspace through height estimation so scoped image metadata remains usable before an older row mounts.

* refactor(app): name row height estimate inputs
2026-10-03 14:41:32 +01:00
Mohamed Boudra 42e221746c fix(app): render linked pictures in PR markdown (#5968)
* fix(app): render linked pictures in PR markdown

Consume image-only headings, anchors, and picture wrappers together so splitting at the fallback image does not leave raw opening tags or discard its link.

* fix(app): preserve picture block boundaries in markdown

* fix(app): parse nested image wrappers without recursion

* test(app): account for delayed wheel input in scroll assertions

CI recorded a 480px wheel scroll delivered after its event alongside 41px of image growth below the new reader. Use the same input budget to locate the reader and account for intervening growth, while continuing to reject added viewport displacement.

* test(app): locate the scroll reader from frame movement
2026-10-03 13:55:11 +01:00
Mohamed Boudra 5c1be59b68 test(terminal): isolate Linux keyboard policy from the host platform 2026-10-03 13:27:50 +02:00
Mohamed Boudra e18f9e8f43 refactor(terminal): share platform policy across keyboard shortcuts
Use the existing platform input for editing, Find, and clipboard behavior. Exercise Mac and Linux modifier chords through the app and a real PTY, and keep pending modifier chips authoritative.
2026-10-03 13:15:29 +02:00
marvin-ambrozioandClaude Opus 5 70210f1071 refactor(app): name the terminal key-event input shapes
Promote the inline multi-property parameter types added by this PR to
named interfaces, per docs/coding-standards.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 13:06:27 +02:00
Gustavo AmbrozioandClaude Fable 5 a983ae0444 refactor(terminal): inject mac platform flag at mount
Greptile review: the browser tests patched navigator.userAgent/platform
globals to exercise mac-only key handling. Make the platform an
explicit mount option (defaulting to navigator detection) so tests
inject it directly and the runtime stops sniffing navigator per
keydown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 13:06:27 +02:00
Gustavo AmbrozioandClaude Fable 5 7dc2a3568c fix(terminal): translate mac cmd/option editing shortcuts
xterm ignores meta+arrows, and 6.x dropped the macOS conversion of
option+arrow to ESC b/f, so cmd+arrow did nothing and option+arrow
leaked ESC[1;3C-style sequences the shell rendered as a stray "C"/"D"
plus a bell. Map the chords the way Terminal.app and iTerm2 (Natural
Text Editing) do: cmd+left/right -> ^A/^E, option+left/right ->
ESC b/f, cmd+backspace -> ^U.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 13:06:04 +02:00
paseo-bot[bot]andilteoood 5375f43a05 feat(plugins): expose agent closed lifecycle event (#5971)
* feat(plugins): expose agent closed lifecycle event

Emit the event when a live agent reaches its closed state so plugins can release agent-scoped resources. Keep archive and close notifications distinct.

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>

* fix(plugins): finish close hooks during daemon shutdown

Wait briefly for pending lifecycle events before stopping plugins so agent close hooks can release their resources during shutdown.

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
2026-10-03 11:36:19 +01:00
Mohamed Boudra 8aec0787d2 fix(app): keep chat reading position stable while scrolling (#5945)
* fix(app): keep chat reading position stable while scrolling

Prepend restoration froze the reading offset while wheel input and delayed image measurements changed layout. Give the viewport one reading anchor, preserve concurrent user movement, and apply virtual row measurements before paint. Suspend automatic pagination while explicit message navigation settles.

* fix(app): type the reading anchor scroll intent explicitly

* fix(app): retain the reading anchor through virtualization startup

Keep the reader pinned while the first virtual range mounts. Strengthen the scroll regression detector to compare shared rows when the original anchor unmounts, and consume fixture replies only when they are emitted.

* test(app): distinguish delayed wheel input from idle scroll drift

CI frames can arrive long after their wheel events. Start idle assertions after the input driver finishes, and account for an image entered by wheel input expanding below the new reading line. Replayed all six CI captures without false positives while retaining the original 76 baseline jumps.

* test(app): bound image growth allowance to displaced reading rows

Require the entered image top to follow wheel input before attributing movement of a later row to its growth. Calibrate the detector against simultaneous viewport jumps and jumps of the growing row itself.

* test(app): clamp scroll expectations at the history boundary
2026-10-03 10:12:01 +01:00
paseo-ai[bot] 5293ddac3f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-03 00:29:16 +00:00
Mohamed Boudra d3c76be9c1 Unify Explorer tabs and refine launch controls (#5942)
* feat(app): unify Explorer tabs and refine interface styling

Give Explorer the workspace tab controls while keeping launcher choices separate from drag-and-drop support.

Upgrade Lucide and set a shared icon stroke width for consistent styling. Reduce the desktop composer bottom inset to align the action row with its corners.

* fix(app): show active Explorer tabs in foreground

The shared row coupled foreground styling to keyboard shortcut ownership. Separate those concerns so Explorer can highlight its active tab while Cmd+T remains with the workspace pane.

* fix(app): refine Explorer close controls and launch options

* test(app): respect singleton Files in Explorer plugin workflow

* test(server): trim unused setup from no-track checkout regression

* test(app): count visible main tab rows after pane moves
2026-10-03 01:20:53 +01:00
Mohamed Boudra d831c7bf33 Make Usage settings discoverable above reports (#5921)
* feat(usage): replace options cog with a collapsible Settings row

* test(usage): find Settings by accessible button name
2026-10-02 20:59:16 +01:00
paseo-ai[bot] 8608525496 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-02 17:38:18 +00:00
Mohamed Boudra ac83aa0785 feat(website): use Geist and put FAQ toggles on the content rail (#5919)
Swap Inter for Geist as the website sans font. FAQ +/− toggles now sit on
the content rail instead of hanging outside it, slightly larger, with
answers indented to the question text.

The website dev server crashed with "createStartHandler is not a function"
on every SSR hot reload. Vite 7's module runner races when re-evaluating
the static import chain behind TanStack Start's server handler
(TanStack/router#7285, fixed upstream in vitejs/vite#22369). The worker
entry now imports the handler at request time until the website moves to
a Vite release with that fix.
2026-10-02 18:29:49 +01:00
Mohamed Boudra 087cc71c00 fix(server): use macOS ComputerName for host identity (#5922)
* fix(server): advertise macOS ComputerName

macOS may expose UNKNOWN through its kernel hostname when DHCP or reverse DNS does not provide one. Use the user-configured ComputerName for daemon host identity instead.

* test(server): cover macOS Hub host name
2026-10-02 18:27:51 +01:00
paseo-ai[bot] 3b5bbb139b fix: update lockfile signatures and Nix hash [skip ci] 2026-10-02 15:48:53 +00:00
Mohamed Boudra ee0b0ef7c6 Feature OMP on the homepage, give /agents icons, and use Inter on the website (#5918)
* feat(website): feature OMP, add agent icons to /agents, and use Inter

Featured agents on the homepage are now Claude Code, Codex, OpenCode, Pi,
OMP, Cursor, Muse Code, and Antigravity, driven by one list so the hero
badges, the provider cards, and the "+N more" count stay in sync. The count
previously subtracted a stale constant (5) while 7 agents were shown.

/agents moves onto the shared SiteShell like Hub and Changelog and lists
every agent as an icon and name. Icons for ACP agents are vendored from the
app's provider catalog rather than imported across packages.

The site switches to self-hosted Inter so typography no longer depends on
the visitor's OS; system-ui fell back to DejaVu Sans on many Linux setups.

* fix(website): keep vendored agent marks out of the homepage bundle

The homepage only shows the eight featured agents, which have inline icon
components, but importing AgentPageIcon pulled all 31 vendored SVGs into
its bundle. AgentPageIcon now lives in its own module used only by /agents.

The Codebuddy mark drew past its 16x16 viewBox and was clipped; the
viewBox now matches the artwork's bounds.
2026-10-02 16:40:10 +01:00
Mohamed Boudra 6a794b6572 Show a thanks page with community and sponsor links after downloading (#5914)
* feat(website): add a thanks page after downloading

Desktop and APK download buttons on /download and the homepage now open
/download/thanks, which starts the download and points people to Discord,
GitHub, Reddit, and sponsoring while it runs. The button carries the asset
name rather than the file URL, so the page resolves the URL from the
release data itself and cannot be pointed at an arbitrary link.

* fix(website): start the exact file the visitor clicked on the thanks page

The thanks page looked the file up again from an asset key and channel, so
it could start a different release than the button showed (a release
published in between, or a beta link without a beta). Download links now
carry the release file URL itself; the thanks page accepts only URLs under
this repo's GitHub releases, after URL parsing normalizes `..` segments.

The e2e test waits for the file request instead of a download event,
because WebKit reports no download event for a stubbed response.

* test(website): assert the thanks-page download event outside WebKit

* test(website): split the thanks-page download check out of the shared test
2026-10-02 15:35:45 +01:00
Mohamed Boudra 1d0df5c0b7 docs(changelog): add 0.10.3 release notes 2026-10-02 14:24:29 +02:00
Mohamed Boudra 73d25317a4 fix(usage): derive window names and unify pin selection (#5903)
* fix(usage): derive window names and unify pin selection

Quota slots do not identify a period. Derive numeric windows and IDs from reported duration and quota scope; keep unknown lengths neutral. Resolve source defaults and explicit pins through one model, with a persisted empty selection distinct from defaults.

* fix(plugins): declare the duration helper host requirement

* fix(usage): verify persisted selections at the storage boundary
2026-10-02 11:18:13 +01:00
Mohamed Boudra 6c985769be test(app): tolerate footer icon subpixel rounding (#5898)
Chromium bounding boxes differ by millionths of a pixel for equal-width footer buttons. Allow less than 0.01px in width and grouped-gap comparisons while preserving alignment and Help/Settings separation checks.
2026-10-02 10:10:33 +01:00
paseo-ai[bot] bd3986d964 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-02 00:25:11 +00:00
Mohamed Boudra 6166a7aca5 chore(release): cut 0.11.0-beta.3 2026-10-02 02:15:43 +02:00
Mohamed Boudra 31cd18e748 docs(changelog): add 0.11.0-beta.3 release notes 2026-10-02 01:57:28 +02:00
Mohamed Boudra 05e94ab52e Keep usage logins visible and explain unavailable quota (#5876)
* fix(usage): keep existing logins visible and explain unavailable quota

Credential discovery must preserve accounts whose login exists even when it has expired. Keep vendor failures on the discovered account and isolate discovery failures without inventing provider cards.

* test(usage): align credential route tests with account discovery
2026-10-02 00:30:11 +01:00
paseo-ai[bot] b5b43edd65 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-01 14:35:44 +00:00
Mohamed Boudra 8498c74169 chore(release): cut 0.11.0-beta.2 2026-10-01 16:25:00 +02:00
Mohamed Boudra a52bdb106c docs(changelog): add 0.11.0-beta.2 release notes 2026-10-01 16:22:07 +02:00
Mohamed Boudra 70df09daeb Restore desktop usage and subscription discovery across harnesses (#5844)
* fix(app): hide the sidebar Usage item while it has no summary

Without a summary window with data, the item fell back to a plain "Usage"
row that duplicated the footer's Usage icon. It now renders nothing, and the
footer rows leave it out so an otherwise empty rows block and its separator
disappear with it.

* fix(desktop): restore built-in usage in packaged apps

The external plugin compiler cannot traverse app.asar, and dependency packaging drops declarations needed by import validation. Ship complete built-in resources beside the archive and verify registration in the packaged desktop smoke check.

* fix(usage): restore login discovery across harnesses

Discover subscription accounts independently of agent sessions and retain each account’s logins in preference order so an unusable credential can fall back to another login for the same account.
2026-10-01 14:51:11 +01:00
paseo-bot[bot] e10f6d2e2c Let plugin client code import packages that declare only main (#5838)
The plugin compiler builds client code with esbuild's neutral platform,
which reads no package.json entry fields, so any dependency without an
exports map failed with Could not resolve. Read module and main for the
client target, as the server target already does through the node
platform.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 14:02:49 +02:00
95774ba71f Show each Paseo skill once in Codex (#5827)
* fix(skills): install one Codex-discoverable orchestration copy

* fix(skills): keep installed skills when agent skill roots are symlinked together

Retiring the old Codex copy deleted the skills just installed when
~/.codex/skills resolved to ~/.agents/skills or ~/.claude/skills (or the
reverse), and status reported drift forever. The Codex skill directory is
now treated as a leftover only when it is a separate directory from the
shared and Claude copies.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

* test(e2e): expect agent skills in the shared and Claude roots only

The skills settings spec still expected every skill in ~/.codex/skills.
It now checks the two install roots and that ~/.codex/skills holds no
Paseo skill, which fails against the previous installer.

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 12:04:05 +02:00
paseo-ai[bot] 37b66897a9 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-01 08:59:09 +00:00
Mohamed Boudra 4aa2f2817d chore(release): cut 0.11.0-beta.1 2026-10-01 10:48:28 +02:00
Mohamed Boudra bdb8e13221 docs(changelog): add 0.11.0-beta.1 release notes 2026-10-01 10:41:23 +02:00
paseo-bot[bot] 06213e49bf Complete every Tintinweb subagent in a grouped completion (#5826)
Tintinweb batches completions that land close together into one
subagent-notification, with the first agent at the top level and the
rest in details.others. The adapter only read the top level, so every
other agent in the group stayed running in the Subagents track, live
and after a reload.

Closes #5822

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 10:11:05 +02:00
paseo-bot[bot]andGabriel Giordano d30e99c858 Log why a resumed Claude agent has no history (#5818)
When a resumed Claude agent's transcript was missing, history loading
returned without a log line, and when reading or parsing the transcript
threw, the error was swallowed. Either way the agent opened with an
empty timeline and daemon.log said nothing. A missing transcript is now
logged at info with the session id, cwd and the path that was checked,
and a read or parse failure is logged as a warning with the error.

Closes #5438

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
2026-10-01 06:56:46 +02:00
Mohamed Boudra e939dd4c7a Streamline sidebar usage and stacked sheet interactions (#5805)
* feat(app): streamline sidebar usage and stacked sheets

* fix(app): restore compact sidebar navigation clicks

The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.

* fix(app): cap menu sheets at eighty percent height

Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
2026-10-01 04:55:17 +01:00
paseo-bot[bot] 15f1431416 Let bundlers resolve the published @getpaseo/relay package (#5815)
The relay exports map sent the import and default conditions to src/*.ts,
which the published package does not include, so any bundler resolving
@getpaseo/client failed with 'Could not resolve "@getpaseo/relay/e2ee"'.
Every condition now resolves to dist, like @getpaseo/protocol and
@getpaseo/client. The client build now builds relay first, and the Metro
workaround for compiling relay source is removed.

Fixes #4730

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 05:01:45 +02:00
paseo-bot[bot]and3ae3ae b701cb2dcd Show failed Codex image generations in the chat (#5810)
When a Codex image request fails, Codex completes the imageGeneration
item with status "failed", an empty result, and an optional failure
record. The image mapper only knew how to render a successful image, so
it returned nothing and the item vanished from the live timeline and
from reopened history. Failed items now map to a failed image_generation
tool call carrying the failure details.

Closes #5619

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-10-01 03:57:56 +02:00
paseo-bot[bot] 4893629ff5 Show the thinking level an agent runs on in its composer (#5804)
The composer picked the thinking level from what was chosen at launch and
fell back to the model's catalog default when nothing was chosen. A Pi agent
started without a thinking level runs on Pi's own configured default, so the
composer showed Medium while the agent ran on High. The composer now prefers
the level the running agent reports when it is one of the model's options,
the same way it already prefers the running model.

Fixes #5800

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 02:55:22 +02:00
paseo-bot[bot]andYuval Marcus e0a0cf990d Ask OpenCode before running a Paseo tool (#5801)
OpenCode checks permissions for its own tools and wraps MCP tools in an
ask, but leaves plugin tools to call ctx.ask themselves. The bridge plugin
never did, so an "ask" rule for paseo_* tools never produced a prompt and
the tool ran. The bridge now asks with the tool's catalog name before
dispatching, the same shape OpenCode uses for MCP tools. With no paseo_*
rule OpenCode's default allow applies, so nothing new prompts.

Fixes #5324

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Yuval Marcus <ymarcus93@gmail.com>
2026-10-01 01:15:34 +02:00
Mohamed Boudra 7b0cace991 Restore Antigravity with Full access and session warnings (#5795)
* Revert "Remove the unreleased Antigravity provider plugin (#5781)"

This reverts commit 9d9d9af993.

* fix(providers): commit plugin notices emitted during session opening

* feat(providers): restore Antigravity with full access and session warnings

* fix(providers): warn once when opening Antigravity sessions

Driver respawn retains the live transcript and unchanged permission posture. Emit the warning only when opening a session, including resume after a daemon restart.
2026-09-30 23:18:05 +01:00
paseo-bot[bot] 20a887f4cc Show an agent's own model in the composer when the catalog lacks it (#5796)
The composer fell back to the catalog's default model whenever the agent's
model was missing from the loaded model list, so a Codex subagent started
on a model the catalog did not list yet was labeled as the default model,
with that model's thinking options.

Fixes #5791

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-01 00:00:16 +02:00
paseo-bot[bot]andodoo-mvds a7c9c05bef Start new workspaces off origin/main at the remote tip (#5788)
* fix(server): start new worktrees at the remote tip of an origin/* base

Branching off a remote-tracking base such as origin/main used whatever
the repository had cached. Nothing on the branch-off path fetched, and the
background fetch only runs while a workspace of that repository is
observed, so a new workspace could start hours or weeks behind the remote.

Fetch just the base branch from its remote before git worktree add. The
refspec is passed on the command line so remote.<name>.fetch is untouched,
and a failed fetch falls back to the cached ref so creation still works
offline.

Co-authored-by: odoo-mvds <mv@droggol.be>

* fix(server): fall back to the cached base before the create request times out

A remote that accepts the connection and never answers held the base-ref
fetch for its 120s cap, past the 60s client request timeout, so
paseo worktree create failed and the worktree appeared later. Cap the
fetch at 15s so creation falls back to the cached ref in time.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: odoo-mvds <mv@droggol.be>
2026-09-30 22:59:33 +02:00
Mohamed Boudra 1f5f5d384e Sidebar usage shows default percents with logos, and remembers the picked host (#5786)
* fix(app): show default usage windows when no windows are pinned

* fix(app): show only percentages in sidebar usage summaries

* fix(client): restore usage source logos for released hosts

* fix(app): restore the labeled footer line below sidebar items

* fix(app): use History's host filter to pick the usage and plugin host

The Usage screen, the compact usage sheet and plugin screens used a separate
HostSwitcher trigger. They now use HostFilter without "All hosts", as the
label manager does, and hide it with one host, as History does.

* fix(app): save the usage host pick and show it in the sidebar summary

Picking a host on the Usage screen or usage sheet was view state: it reset when
the view closed, and the sidebar summary never read it. The pick is now a
device-local usage preference. The summary and the screen show the picked host
while it is connected, else the active workspace's host, else the first host.

The usage fixture answers by request, since the summary and the screen each
load reports and only a card's Refresh forces one.

* test(app): expect plugin footer rows above the footer bottom line

Add project moved to the bottom line with the icons, so footer rows sit above
it, spanning Add project to Settings.

* test(app): expect the saved usage host in stored settings

* test(app): let usage settings specs share reports with the sidebar summary

The sidebar summary loads reports on app start, so the settings specs answer
by request instead of by order and read percents from the settings card.
2026-09-30 21:09:07 +01:00
Mohamed Boudra 695ecb2812 fix(muse): make package installs and project rules work by default (#5775)
* fix(muse): make launch defaults usable and configure each agent

Disable sandboxing and trust workspaces by default so package installs and project rules work. Replace the host settings UI with validated per-agent providerOptions, preserving posture across session resume.

* docs(muse): describe shared provider options configuration
2026-09-30 20:09:10 +01:00
paseo-bot[bot] 7a64eef491 Show the command on Codex terminal rows (#5774)
* fix(codex): label terminal writes with the command Codex started

Codex app-server reports the PTY process id on the commandExecution item,
and no longer sends the legacy exec_command_end event that carried it as
text. Record the process-to-command label from the item so terminal
writes show which command they went to.

* ci: rerun after website e2e server start timeout

* fix(codex): unwrap the shell from terminal labels like shell rows

Terminal labels now use the same command normalization as the shell row,
so a Linux wrapper such as /usr/bin/zsh -lc 'python3 -i' labels the
terminal as python3 -i.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 20:50:24 +02:00
Mohamed Boudra 9d9d9af993 Remove the unreleased Antigravity provider plugin (#5781)
* refactor(providers): remove the unreleased Antigravity plugin

Remove the provider at the owner’s request because headless command approvals are unsupported. Preserve Muse, shared plugin infrastructure, and earlier editor and terminal integrations.

* test(plugins): keep builtin startup coverage generic
2026-09-30 19:39:09 +01:00
Mohamed Boudra 29c198f955 fix(providers): apply configured options to every agent provider (#5780)
* fix(providers): apply configured options at session startup

Treat provider options as one opaque record, with provider-owned validation and per-agent overrides over configured defaults. Keep agent overrides separate from defaults so configuration changes apply on the next launch or resume.

* test(providers): verify provider-owned validation through Hub

* fix(providers): preserve the complete wrapped session interface
2026-09-30 19:37:14 +01:00
Mohamed Boudra 318a84a570 Keep native dialogs and sheets usable when stacked (#5772)
* fix(app): stack adaptive sheets across native presentations

Root-owned dialogs could not present over a sibling native dialog on iOS. Use the existing app-wide sheet stack for both native presentation forms, and release the underlying keyboard when a new sheet opens.

* test(app): verify pairing sheet autofocus

* docs(app): clarify stacked sheet journey prerequisites
2026-09-30 18:11:38 +01:00
Mohamed Boudra 364e6456e0 Add confirmation for new hosts (#5753)
* Add confirmation for new hosts

Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.

* Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.

* Move host confirmation into the host runtime

The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.

* Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
2026-09-30 18:11:23 +01:00
paseo-bot[bot] d070db15b3 fix(acp): report the agent's error when session setup is rejected (#5765)
ACP agents reject mode, model, thinking, and feature writes as plain
JSON-RPC error objects. Those calls skipped the request error
normalization, so schedule runs and metadata generation recorded
"[object Object]" instead of the agent's message.

Refs #4666

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 18:19:05 +02:00
paseo-bot[bot]andArietidsZ 4417d7c47a fix(pi): give Pi's built-in MCP the agent's MCP servers (#5762)
Pi 0.99 ships MCP as a built-in extension. Paseo only recognized
pi-mcp-adapter's /mcp command, so a Pi agent without the adapter started
without Paseo's MCP servers. Pi 0.99 also rejects the adapter's
--mcp-config flag, so the servers are now registered with
pi.registerMcpServer from Paseo's per-session extension. Sessions that
load pi-mcp-adapter keep the --mcp-config path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: ArietidsZ <69024461+ArietidsZ@users.noreply.github.com>
2026-09-30 16:08:18 +02:00
Mohamed Boudraandmillerben95 8332f4d1ff feat(muse): add Muse Code as a built-in provider plugin (#5719)
* test(muse): record real host protocol fixtures

* feat(muse): add built-in MSP provider

Co-authored-by: millerben95 <millerben95@users.noreply.github.com>

* fix(muse): expose provider settings in the app

The daemon registered host settings without a client contribution, leaving sandbox, network, and workspace trust unreachable. Share the definition with a settings screen and expose it through Command Center even when installed plugins are disabled.

* docs(muse): publish setup and provider support

* test: correct runtime fixtures and restart readiness

* fix(muse): hide reminder housekeeping from the timeline

* fix(muse): use the official Muse Code mark

Source: https://muse.ai/images/favicon/app-squiggle.svg

Preserve the official silhouette path verbatim. Remove its gradient definition and use currentColor, scaling the original 100-unit coordinates into the 24-unit viewBox. Black silhouette renders match pixel-for-pixel.

* fix(muse): adapt to current plugin APIs

Use the screen API introduced with sidebar plugin items. Usage reports now come from source account discovery; remove the retired agent usage-reference request and headline marker, and exercise discovery through the existing provider fixtures.

---------

Co-authored-by: millerben95 <millerben95@users.noreply.github.com>
2026-09-30 14:56:41 +01:00
Mohamed Boudra 7b7685678d feat(antigravity): add Antigravity provider plugin (#5714)
* test(antigravity): record agy stream-json fixtures

* feat(antigravity): add Antigravity provider plugin

* fix(antigravity): address Phase 1 review findings

* test(antigravity): record parity discovery blockers

* feat(antigravity): support image prompts and daemon e2e

* docs(antigravity): document provider setup and limitations

* test(antigravity): respect Windows interrupt semantics

* docs(plugins): avoid duplicate provider override guidance

* fix(antigravity): report fresh denials and correct mode metadata

Native denial results accumulate within a driver process. Track reported occurrences until respawn so later text-only turns do not repeat notices, while new denials of the same action remain visible.

* fix(antigravity): use the official Antigravity mark
2026-09-30 14:09:07 +01:00
Mohamed Boudra 5715b5ba62 fix(app): reflow iPad chat text after pane resize (#5716)
The selectable UIKit text view was a sibling of the Fabric content container, so existing chat rows kept an old width after Explorer changed the available pane. Make the container own the text view and preserve layout notifications on bounds changes. Includes a native geometry regression.
2026-09-30 13:56:29 +01:00
Mohamed BoudraandJichen 9952c55983 fix(app): restore native tablet touch actions (#5713)
Wide native tooltips followed the desktop hover policy, and native tab chips disabled their long-press menu. Enable the existing touch actions while keeping tab navigation on completed press so horizontal scrolling does not select a tab. Keep URL-opening links from leaving a tooltip over the app on return.

Co-authored-by: Jichen <zhangjichen@oppentech.com>
2026-09-30 13:56:24 +01:00
Mohamed Boudra e9e479c6ba fix(pi): stream running subagent transcripts (#5755)
Follow Pi child session files while subagents run for gotgenes, tintinweb, and nicobailon pi-subagents. gotgenes reports the child file through a runtime bridge that captures the parent's service at session start; nico async runs settle from status.json live and from the persisted subagent-notify notice on replay.

Closes #5752
2026-09-30 13:56:22 +01:00
Mohamed BoudraandLeandro Ardissone 2553ab4d82 fix(app): expose terminal controls on native tablets (#5712)
Wide native panes previously followed the compact-layout gate, hiding terminal keys and skipping software-keyboard clearance. Keep bar eligibility tied to native touch while sizing rows from the available pane width.

Co-authored-by: Leandro Ardissone <lardissone@gmail.com>
2026-09-30 13:56:14 +01:00
Mohamed Boudra ad56659a89 fix(app): resolve plugin provider icons after the snapshot arrives (#5720)
Subscribe icon consumers to snapshot SVG metadata so compiler-cached fallbacks update when provider discovery completes.
2026-09-30 13:46:43 +01:00
Mohamed Boudra b0c9de0c90 feat(plugins): first-class plugin providers — status, launch resolution, config overrides (#5707)
* feat(plugins): support provider status, launch resolution, and config overrides

* fix(plugins): defer override warnings until provider startup settles
2026-09-30 13:46:35 +01:00
paseo-bot[bot] 8cccc1f6fa fix(codex): answer each approval callback that shares one item (#5758)
Codex sends several command approval requests for one item when it
approves subcommands separately, telling them apart by approvalId. The
permission id was derived from the item id alone, so the second request
replaced the first and answering it left the first request unanswered.
Key the permission on approvalId when Codex sends one.

Closes #5615

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 14:09:56 +02:00
Mohamed BoudraandClaude 3a9304607c feat: sidebar header and footer plugin items, Usage in the sidebar footer (#5685)
* refactor(usage): remove agent-to-usage wiring

* feat(plugins): screens, sidebar header and footer items

Plugins register screens with addScreen and contribute component-based
sidebar items with addSidebarHeaderItem and addSidebarFooterItem. Items get
currentScreen, openScreen and openPopover, and render the SidebarRow and
SidebarButton kit. The footer shares the header's ordering model through the
new sidebarFooterItems preference, and Settings > Sidebar gains a Footer card.
addSurface, openSurface and addSidebarItem stay as aliases with their
settings keys and routes.

* refactor(plugins): share resolvePluginPlatform and keep header remembered-host key

Consolidate the five resolvePlatform copies into plugins/platform.ts.
Header sidebar items keep the <pluginId>/sidebar/<id> remembered-host key
so existing users keep their remembered host; footer items use
<pluginId>/sidebar-footer/<id>.

* feat(usage): global usage summary, pins and used/remaining display

- Device-local usage preferences (displayAs, pinned windows) in app settings.
- Sidebar footer usage summary: pinned windows in pin order, one per account;
  opens the expanded view as a popover on desktop and a sheet on compact.
- Pin checkboxes on window rows and a used/remaining toggle shared by the
  summary, popover, Usage screen and Settings usage section.
- Usage screen shows one host with a selector when several are connected.
- Extract HostSwitcher and the sidebar popover host for reuse.

* fix(usage): render each usage report as its own card

* feat(usage): row pinning, one title row for usage controls, even sidebar footer

- Window rows are the pin toggle (checkbox role, checked state); pinned rows
  get a surface2 highlight, unpinned rows an interactionHighlight hover.
- Usage screen, Settings section, popover and sheet put the title left and
  the host selector, used/remaining toggle and Refresh right (UsageControls).
  PageLayout gains an actions slot; the menu sheet header a trailing slot.
- Sidebar footer rows share 28px height, spacing[1] gaps and one leading rail.

* fix(usage): keep row hover lighter than the pinned highlight

Hovering an unpinned window row shows the pinned surface2 at half opacity on
a separate layer, so it never reads stronger than a pinned row. Pinned rows
do not react to hover.

* fix(app): defer sidebar styles and preserve layout exports in timeline test

* fix(usage): open the Usage screen on desktop, drop the Usage footer icon, match git panel refresh

- Desktop presses on the usage summary open the Usage screen; only compact opens the sheet.
- The Usage footer icon is gone; with nothing pinned the summary renders a plain "Usage" row
  in the footer row style. A stored `usage` footer preference is ignored.
- Refresh controls use the Changes panel's icon-only ToolbarButton with RotateCw.

* fix(sidebar): keep the footer icon row fixed; footer items are rows only

The footer's icon row (Hosts, Import session, Help and support, Settings) is back to main's
fixed implementation: not in Settings, not reorderable, and plugins can't add to it.

- Footer items are full-width rows between Add project and the icon row: the usage summary
  and plugin `addSidebarFooterItem` rows. The Footer card in Settings lists only those rows.
- Stored `sidebarFooterItems` keys for hosts, import, help and usage are ignored.
- `SidebarButton` is removed from the plugin UI kit; a footer item renders `SidebarRow`.

* refactor(usage): call the sidebar footer item Usage

One concept, one word: the footer item is the Usage item, with key `usage`.

- Settings > Sidebar > Footer lists it as "Usage"; its preference key is `usage`.
- `usage/summary-item.tsx` becomes `usage/sidebar-item.tsx` (`UsageSidebarItem`); both states
  use testID `sidebar-usage`.
- `usage/summary.ts` becomes `usage/pinned.ts` (`resolvePinnedUsage`, `PinnedUsageWindow`):
  it resolves the pinned windows the item shows.

* feat(plugins): screen params, several rows per sidebar item, SidebarSeparator

openScreen takes { screenId, params }; params live in the screen route's
query so reload, history and links keep them. currentScreen carries the
params. A sidebar item may render a fragment or array of rows; a row press
anchors the popover to that row. SidebarSeparator joins the plugin UI kit.

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

* fix(plugins): any screen param key; trailing presses anchor the row's popover

Screen params go in the query under a param. prefix, so keys like serverId
no longer collide with the route segments Expo Router merges into search
params. A press anywhere inside a SidebarRow, trailing content included,
makes that row the popover anchor.

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

* feat(plugins): plugins title their screens

addScreen takes { id, title, Component }. title is a string or a function
of the screen's params; the screen header shows it and follows param
changes. A title function that throws or returns empty falls back to the
screen id. Legacy addSidebarItem screens keep their item's title and icon,
and the addSurface alias titles its screen with the id.

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

* fix(usage): require discovery and remove unreleased agent resolver

* fix(plugins): restore legacy sidebar rows, share the item runtime, header hint presses its row

- Legacy addSidebarItem rows are their own group kind again: main's row, registered icon in
  Settings, /sidebar/<id> route, host remembered per row.
- New-API items follow the host last used on any of the plugin's screens.
- /sidebar/<id> falls back to a same-id screen so pre-migration links resolve.
- params moves to PluginScreenProps; settings screens no longer receive it.
- Sidebar items, buttons and popovers share one runtime per installation, so no empty first frame.
- A header row's hint slot presses the row; labels wrap as on main.
- Skill and docs corrected.

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

* fix(usage): one host resolver, sheet reuses the screen's controls, name the host

- The Usage row and screen default to the same host.
- The compact sheet carries the screen's controls, host selector included.
- The host selector shows with one host, and unavailable messages name the host.
- The row, sheet title and pinned label use the sidebar.footer.usage translation.
- Window rows read their percent and reset after the pin label.
- Drop the unread per-report cache writes and headline fixture fields.

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

* fix(plugins): popovers, header buttons and composer pills own their runtime again

Only sidebar item rows use the installation's shared runtime. Popover content
disposes its runtime on unmount, so observations close with the popover.

* fix(plugins): a sidebar item releases its subscriptions when it unmounts

Each mounted sidebar item gets its own API scope over the host's client, created
during render so its first frame has content. The scope closes a microtask after
unmount (StrictMode's remount keeps it), when the item moves to another host, or
when the installation stops. Replaces the installation-wide shared runtime.

* refactor(plugins): one Paseo client per plugin; authors own their subscriptions

usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.

* refactor(plugins): one Paseo client per plugin; authors own their subscriptions

usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.

* fix(usage): show usage from released hosts

Normalize released-host usage responses at the client boundary so existing usage and pins remain available across app and daemon versions. Include account labels in legacy responses for older apps.

* fix(usage): keep the usage capability check out of the public SDK

* test(server): retry transient locks during runtime test cleanup

* test(usage): model hosts without either usage capability

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 12:52:13 +01:00
paseo-bot[bot] b1a3b0772d fix(claude): skip user and project hooks for internal agents (#5750)
Branch-name, commit message, and pull request text generation run as
internal Claude agents in the workspace, so a project Stop hook that
pushes HEAD published the placeholder branch before the rename.

Closes #5742

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 12:10:14 +02:00
paseo-bot[bot] 5599f9e567 fix(cli): fail agent open for an agent the daemon does not have (#5727)
paseo agent open launched Desktop with whatever string it was given,
printed "opened" and exited 0, so a bad ID moved the user to a broken
"Agent not found" tab and the calling script saw success. Look the agent
up on the daemon first, the way inspect and mode do, and open Desktop on
the ID the daemon resolved.

Closes #5721

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 02:55:44 +02:00
paseo-bot[bot] 4e9a4582b7 fix(app): stop connecting to a host removed during its first probe (#5722)
Closes #4815

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-30 02:12:03 +02:00
Mohamed Boudra 3fea128382 docs(changelog): add 0.10.2 release notes
(cherry picked from commit c5b4b7bae3)
2026-09-30 01:16:24 +02:00
paseo-bot[bot]andBae Seongmin 53ee9cd993 fix(codex): settle declined commands and file edits as failed (#5717)
Codex completes a command or file change with status "declined" when its
approval is denied. The shared status normalizer did not know that word
and fell back to "running", so the denied command kept a spinner after
the turn ended.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-09-30 00:06:21 +02:00
paseo-bot[bot]andBae Seongmin 767b0e970b Rewind legacy Codex threads on Codex versions without thread/rollback (#5711)
Codex 0.156 removed thread/rollback. Rewinding a legacy-history thread
forked it and then called rollback on the fork, so the rewind failed and
left an unused fork behind. Read the Codex version from the initialize
userAgent and, when rollback is gone, fork before the target turn the
same way paginated threads already do. Older servers keep fork plus
rollback.

Closes #5616

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-09-29 22:59:00 +02:00
paseo-bot[bot]andMatt Cowger d3c902e9fa fix(opencode): fill the context meter during v2 turns (#5710)
OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.

Closes #5701

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
2026-09-29 22:10:27 +02:00
Mohamed Boudra 60aff2346c Support catalog-driven Codex Fast and Ultrafast speeds (#5708)
* feat(codex): discover Fast and Ultrafast speeds from the catalog

Replace the model allowlist with advertised service tiers so new models and speeds need no Paseo catalog update. Normal explicitly overrides the thread tier. Desktop icon-only triggers are provider metadata; mobile selectors remain labeled.

* chore: remove QA screenshots from the change
2026-09-29 20:19:59 +01:00
paseo-bot[bot] 5de45e2086 fix(desktop): capture the annotated element when the app is zoomed (#5703)
The browser pane measures the selected element in page CSS pixels, but
capturePage takes device-independent pixels. Paseo's zoom carries into the
in-app browser, so at any zoom other than 100% the screenshot attached to
an annotation was offset and undersized. Scale the region by the guest's
zoom factor before capturing.

Closes #5690

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 20:08:05 +02:00
paseo-bot[bot] 5764863f63 fix(opencode): show v2 patch edits as edit diffs (#5696)
OpenCode 2 reports GPT models' apply_patch tool calls under the name
patch. The OpenCode tool-call parser only recognized apply_patch, so
these calls fell through to an unknown tool card with the raw patch
text. Map patch payloads the same way as apply_patch.

Closes #5643

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 19:15:39 +02:00
Mohamed Boudra 4b45bc9fc1 fix: restore archived workspaces without recorded branches (#5694)
Create a fresh branch from the recorded base when available, or the repository default, so archived agents remain accessible. Cover the server recovery choices and the browser restore-to-agent flow.
2026-09-29 17:25:21 +01:00
paseo-bot[bot] a8f5d969b8 fix(codex): keep the default effort after a daemon restart (#5686)
A Codex agent created without a thinking option ran on the configured
default effort until the daemon restarted. The resumed session never
resolved the defaults, so it showed thinking auto and sent turns with no
effort. Resolve unset model and effort for a resumed thread the same way
a new thread does.

Closes #5673

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 17:53:43 +02:00
paseo-bot[bot] 23c4404b95 fix(codex): keep output schema fields named like schema keywords (#5684)
The Codex output schema normalizer walked every key as if it held a
schema, so a field named "properties" (or a literal default, enum, or
const value shaped like an object schema) was rewritten with invented
required and additionalProperties entries. Codex then rejected the turn
with invalid_json_schema. Walk only the keywords that hold schemas.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 16:49:03 +02:00
Bae Seongmin fe9b3d693e Merge branch 'main' into fix/codex-steer-rewind 2026-09-29 23:25:59 +09:00
paseo-bot[bot] 2838922ade fix(opencode): show the question text and typed answer in v2 question cards (#5679)
OpenCode v2 sends each question as a form field whose title is the short
header, whose description is the question, and whose custom flag allows a
typed answer. The mapper used the field key as the header and the title as
the question, and dropped custom, so the card showed only the header and
had no answer field.

Closes #5667

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 16:00:31 +02:00
+1 264f6e1cca feat(omp): make OMP a first-class provider (#5550)
Makes OMP a first-class provider: import keeps the session model, replay matches live rendering, /compact waits for OMP, Auto thinking, working multi-select and Other answers, mid-turn steering, fallback model and Fast mode, archived agents open without starting omp, profile-aware import, crash recovery and approval-mode changes, readable tool-call rows, one bubble per /skill, blocked and abandoned todos, OMP notifications, hidden todo reminders on reload, and session MCP servers for schedules and Hub runs.

Fixes #2727, fixes #2857, fixes #4000, fixes #4790, fixes #5354, fixes #5099

Co-authored-by: Abhishek Gupta <183943363+abhi-wan-kenobi@users.noreply.github.com>
Co-authored-by: Jegor Kitskerkin <jegor.kitskerkin@gmail.com>
Co-authored-by: Kh05ifr4nD <meandSSH0219@gmail.com>
Co-authored-by: Nguyen Quang Minh <135627235+n24q02m@users.noreply.github.com>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: allo <alloevil@hotmail.com>
Co-authored-by: gray-graff <gray.graff@gmail.com>
Co-authored-by: kasrakhosravi <kasra@goldsky.com>
Co-authored-by: viet-initx <viet@initx.io>
2026-09-29 14:12:53 +01:00
Mohamed Boudra cc744cc669 feat(app): let users set the chat and Markdown content width (#5680)
* feat(app): let users set the chat and Markdown content width

Wide and ultrawide screens were stuck with an 820px reading column. The
width is now an Appearance setting that flows through the Unistyles theme,
like the font sizes, so every content surface picks it up.

The stored value stays null until the user picks a width, so a future
change to the default reaches everyone who never customized it. Reset
writes null back.

The native composer dock keeps plain React Native styles and takes the width
inline because those styles land on Reanimated views, where theme-driven
Unistyles crash on theme changes.

* fix(app): pass the content width into the web stream viewport

The viewport read app settings itself, which required a QueryClient in every
render of the stream strategy. The agent stream view already reads settings,
so it hands the width over with the rest of the render input.
2026-09-29 13:49:03 +01:00
paseo-bot[bot]and3ae3ae aff476b01b fix(codex): insert custom prompt arguments literally (#5669)
Custom prompt expansion replaced each placeholder kind in its own pass,
so text inserted by an earlier pass was expanded again ($ARGUMENTS
containing "$1"), and named values went through String.replace, which
reads "$&" and friends as replacement patterns. Match every placeholder
in one pass and insert values from a callback.

Closes #5613

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-29 13:51:43 +02:00
Mohamed Boudra d596861891 feat(sidebar): make empty project actions clear and quiet (#5675)
Use the shared button sizes and a bordered list card so the empty sidebar gives project setup actions a consistent visual hierarchy.
2026-09-29 12:02:38 +01:00
Mohamed Boudra be2fb1cab9 fix(opencode): keep long v2 turns alive until execution completes (#5674)
The long-lived session wait hit Node’s headers timeout while OpenCode kept working. Reconcile execution events against active state and the durable log so transport observation failures do not fail healthy turns, including across event gaps and reconnects.
2026-09-29 11:56:57 +01:00
paseo-bot[bot] 87818812ad Stop the desktop app from launching a process every second when no local daemon runs (#5636)
* fix(desktop): stop launching the CLI every second when no local daemon runs

The app polls the local daemon status once a second until it learns a
server id, and a stopped daemon has none, so the poll never ends. Each
poll ran `paseo daemon status` in a new process, which on Windows is
Paseo.exe and shows the busy cursor about once a second while the app
sits idle. The status handler now answers "stopped" in-process when the
home has no running daemon, which is what the CLI reports in that case.

* fix(desktop): report an errored daemon when its state file cannot be read

Reading the daemon instance record ran before the try in
resolveDesktopDaemonStatus, so an unreadable paseo.pid made the status
IPC reject instead of returning the errored status the CLI failure path
produced.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 11:19:19 +02:00
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
3ae3ae 7624140141 Merge main into fix/codex-steer-rewind 2026-09-29 16:01:29 +09:00
3ae3ae 59009a2c2a fix(codex): reject rewinds inside a steered turn 2026-09-29 13:41:45 +09:00
995 changed files with 45445 additions and 9253 deletions
+5 -1
View File
@@ -7,6 +7,7 @@ on:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "packages/protocol/**"
- "package.json"
- "package-lock.json"
- "patches/**"
@@ -29,7 +30,10 @@ jobs:
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root
- name: Build protocol
run: npm run build:protocol
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website
+1
View File
@@ -9,6 +9,7 @@ on:
branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
- "nix/**"
- ".github/workflows/desktop-packages.yml"
+123
View File
@@ -1,5 +1,128 @@
# Changelog
## 0.11.0-beta.3 - 2026-10-02
### Added
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Changed
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
### Fixed
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
## 0.11.0-beta.2 - 2026-10-01
### Added
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
### Changed
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
### Fixed
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
## 0.11.0-beta.1 - 2026-10-01
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
### Changed
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340))
### Fixed
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684))
- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686))
- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758))
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
## 0.10.3 - 2026-10-02
### Added
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
## 0.10.2 - 2026-09-30
### Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
## 0.10.1 - 2026-09-29
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
## Repository map
+4 -2
View File
@@ -29,7 +29,7 @@
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -42,7 +42,7 @@
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -68,6 +68,8 @@ You need at least one agent CLI installed and configured with your credentials:
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity)
- [Muse Code](https://paseo.sh/docs/muse-code)
### Desktop app (recommended)
+2 -2
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
### Cancellation
+38 -9
View File
@@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds:
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
## Provider options
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
`Record<string, unknown>` values: the provider owns validation and application.
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
Providers without option handling ignore the record.
The daemon deep-merges the agent's options over the provider defaults at each
session launch or resume. Plain objects merge recursively; arrays, scalars, and
`null` replace the base value. Only the agent's own options are persisted, so edits
to provider defaults apply on the next launch or resume. A profile inherits its
base provider's options unless it sets its own options record.
```json
{
"agents": {
"providers": {
"codex": {
"options": { "sandbox_mode": "read-only", "approval_policy": "never" }
}
}
}
}
```
See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for
per-agent examples and provider-specific keys.
---
## Table of Contents
@@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"options": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"rpcTimeoutMs": 60000,
"smolModel": "openai/gpt-5-mini",
@@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
@@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"options": {
"sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000
}
@@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
@@ -484,7 +513,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
```json
{
@@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"options": {
"supportsMcpServers": false
}
}
@@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
ACP agents execute filesystem operations in their own environment by default,
while terminal operations run through Paseo on the host. To customize which
operations Paseo handles, configure client capabilities in provider params:
operations Paseo handles, configure client capabilities in provider options:
```json
{
@@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params:
"extends": "acp",
"label": "Container Agent",
"command": ["container-agent", "acp"],
"params": {
"options": {
"clientCapabilities": {
"fs": {
"readTextFile": false,
@@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields:
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
+4 -2
View File
@@ -182,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`.
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed
skill directories and keeps config plus filesystem convergence behind one serialized owner.
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
+2 -2
View File
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
@@ -217,7 +217,7 @@ Changing state must not move the layout. A row that grows when its badge arrives
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
+11 -7
View File
@@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
@@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
focus.
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
does not change workspace 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. 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.
+16
View File
@@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
sibling dialog over an existing iOS presenter.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
React cannot copy contexts reflectively, so the only way across the teleport in
+3 -2
View File
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -33,7 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
+2
View File
@@ -191,6 +191,8 @@ 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.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
- **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
+165 -35
View File
@@ -88,34 +88,64 @@ never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
`server/`, and optional client entry and icon. Add the plugin ID to `builtinPlugins` in
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins:
outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch
one because it resolves through the repository's `node_modules`.
The packaged-app smoke check requires every listed built-in to start without relying on
account credentials.
Built-ins run in process and remain active independently of `pluginsEnabled`. They are
absent from the installed plugin list and source configuration; their client bundles
appear in the plugin catalog. Editing one in development requires a daemon restart.
Directory, Git, and npm installs cannot use a built-in ID.
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
availability and a diagnostic after the daemon resolves the executable. Configure command,
environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep
the bundled integration; an entry with `extends` shadows it with a custom provider. See
[provider contributions](#contribute-a-provider) for the contract and
[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations.
## Install from a registry
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
`pluginRegistries: { "host": { "authorization": "Bearer token" } }`.
Restart your daemon after changing these startup settings. Credentials go only to the registry,
never artifact hosts or redirects. Git/npm use their own host authentication.
The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md)
owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
directory always wins over shorthand resolution.
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
still wins over source resolution.
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin ls
paseo plugin update review
paseo plugin update --all
paseo plugin install github:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install github:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
Append `:relative/path` to the source when the plugin lives below the repository root.
`--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.
Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial
branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD
and ask for approval. `ls` reports the installed commit without contacting the remote.
## Managed source ownership
@@ -259,12 +289,23 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Greeting } from "./client/greeting";
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
return (
<SidebarRow
icon="MessageCircle"
active={currentScreen?.screenId === "main"}
onPress={() => openScreen({ screenId: "main" })}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
return () => {};
}
```
@@ -277,15 +318,33 @@ errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
that id, so links saved before a plugin migrated keep working.
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
keys are stored under a `param.` prefix; plugins never see it and may use any key.
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
disposed per mounted surface. See the
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
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.
@@ -321,7 +380,7 @@ existing agent-context instances, but it cannot create an agent panel without an
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 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)
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
@@ -352,9 +411,38 @@ export default function contribute(server: PluginServerContext) {
}
```
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
environment. Spawn with those values; do not merge the plugin process's environment back in.
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
Without a declared command, existing providers keep their connection behavior and receive no launch.
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
that effective launch. Return `{ available: true }` or
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
settles (including disabled plugins), then on each installed registry generation.
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
remain valid for plugin registration but cannot be used as config provider IDs.
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
connection receives the new launch without restarting the daemon. Running sessions keep their
current launch until refreshed, as native providers do.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target. Return a key covering effective configuration and execution environment, or
workspace target plus the resolved `launch` when a command is declared. Return a key covering
effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
@@ -362,15 +450,22 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `providerOptions` config object.
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
@@ -388,10 +483,45 @@ SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
one section's order from built-ins, plugin groups, and the section's preference
(`sidebarNavItems` or `sidebarFooterItems`).
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
the responder system, so the row's own press anchors too. Until a press, the first mounted row
holds the anchor. An item that renders no kit component
anchors to its section container.
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
the line runs edge to edge in both sections.
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
row; a button inside it wins its own press because only the innermost pressable responds.
- A new-API item renders from the current route's host, else the host remembered under
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
set. One key per plugin: switching host on any of its screens moves all its items.
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
@@ -461,7 +591,7 @@ be rendered intact. The daemon advertises this RPC through
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
+39 -54
View File
@@ -1,52 +1,38 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
server integrations.
## Plugin providers
Keep a bundled provider in `plugins/<id>/` and register it through
`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
| Provider | Transport | Setup and limitations |
| ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
| Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) |
| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) |
## Provider-native session options
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
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 are not accepted as
top-level provider options.
The provider owns validation and application of the opaque record in
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
at session startup. See [provider configuration](custom-providers.md#provider-options)
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
This Paseo version accepts these keys:
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
- **Codex:** `approval_policy`, `sandbox_mode`,
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
`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`, `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).
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
application policy, not an OS sandbox.
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Two Integration Patterns
## Core adapter patterns
### ACP (Agent Client Protocol) -- recommended
@@ -69,7 +55,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -85,7 +71,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence.
The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
@@ -99,6 +87,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
@@ -197,15 +187,10 @@ promise for completion: equal results, including equal discovery timestamps, emi
## Usage sources
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
### Credentials are read only
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
---
@@ -293,7 +278,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+7 -1
View File
@@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
A finite streaming request can emit `.update` messages before its terminal `.response`.
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
follow the response. An empty successful stream is an empty list. Register updates as replies
of the originating request so source-owned delivery routes them to the requesting connection.
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
## Message Shape
+1 -1
View File
@@ -1 +1 @@
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
+58 -41
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -6330,6 +6330,15 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getpaseo/app": {
"resolved": "packages/app",
"link": true
@@ -26261,17 +26270,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
"integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -36415,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36480,7 +36478,7 @@
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
@@ -37335,6 +37333,23 @@
"node": ">=20.19.0"
}
},
"packages/app/node_modules/lucide-react-native": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz",
"integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"packages/app/node_modules/miniflare": {
"version": "4.20260625.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
@@ -37481,12 +37496,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/server": "0.11.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37733,10 +37748,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37747,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37991,7 +38006,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38887,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38916,14 +38931,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38933,8 +38948,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38956,7 +38971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38983,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39201,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@getpaseo/client": "0.11.0-beta.3",
"@getpaseo/highlight": "0.11.0-beta.3",
"@getpaseo/plugin": "0.11.0-beta.3",
"@getpaseo/protocol": "0.11.0-beta.3",
"@getpaseo/relay": "0.11.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40131,10 +40146,11 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
"@fontsource-variable/geist": "^5.3.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-start": "^1.168.26",
"framer-motion": "^12.35.2",
@@ -40149,7 +40165,8 @@
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"wrangler": "^4.75.0"
"wrangler": "^4.75.0",
"@getpaseo/protocol": "*"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.8",
@@ -41736,7 +41753,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.0.0",
"version": "0.11.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1242,6 +1242,7 @@ test.describe("Agent message submission", () => {
await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally {
@@ -0,0 +1,152 @@
import { test } from "../support/fixtures";
import { expect } from "@playwright/test";
import {
findScrollJumps,
expectImageSpaceReserved,
type ScrollFrame,
observeTimelinePages,
openOnlyTimelineTail,
recordUpwardTraversal,
reportScrollJumps,
scrollCadences,
withVariedTimeline,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 1000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -150, height: 150 },
{ id: "reading", top: 0, height: 100 },
],
anchor: "reading",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const after: ScrollFrame = {
...before,
at: 1400,
scrollTop: 900,
wheelTotal: 100,
lastWheelAt: 1050,
rows: [
{ id: "image", top: -50, height: 1150 },
{ id: "reading", top: 1100, height: 100 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
expect(
findScrollJumps([
{ ...before, scrollTop: 200 },
{
...after,
wheelTotal: 1000,
scrollTop: 0,
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
},
]),
).toEqual([]);
expect(
findScrollJumps([
before,
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
]),
).toHaveLength(1);
expect(
findScrollJumps([
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
]),
).toHaveLength(1);
});
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "new-reader", top: -520, height: 278 },
{ id: "image", top: -242, height: 184 },
{ id: "old-reader", top: -58, height: 130 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
const after: ScrollFrame = {
...wheel,
at: 1220,
scrollTop: 1520,
scrollHeight: 5041,
anchor: "new-reader",
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 225 },
{ id: "old-reader", top: 463, height: 130 },
],
};
expect(findScrollJumps([before, wheel, after])).toEqual([]);
// The previous 480 px input has already moved the viewport; the recent
// budget includes it, but only the next 480 px belongs to this frame.
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
const steadyAfter = {
...after,
at: 1100,
wheelTotal: 960,
lastWheelAt: 1099,
scrollHeight: 6000,
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 1184 },
{ id: "old-reader", top: 1422, height: 130 },
],
};
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
expect(
findScrollJumps([
before,
wheel,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await withVariedTimeline(async (agent, newestPrompt) => {
const pages = observeTimelinePages(page, agent.agentId);
await openOnlyTimelineTail(page, agent, newestPrompt, pages);
const frames = await recordUpwardTraversal(page, cadence, testInfo);
await reportScrollJumps(page, testInfo, frames, pages);
});
});
}
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
await expectImageSpaceReserved(page, testInfo);
});
@@ -84,6 +84,18 @@ async function openModelSelector(page: Page) {
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
}
async function dismissStackedModelPickerWithBackdrop(page: Page) {
await page
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
@@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await dismissStackedModelPickerWithBackdrop(page);
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
@@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.keyboard.press(CHANGES_SHORTCUT);
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({
timeout: 30_000,
});
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
@@ -0,0 +1,122 @@
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
// The real daemon discovers both through the same Codex model/list boundary.
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
},
},
},
});
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
if (!(await speed.isVisible())) {
if (await features.isVisible()) await features.click();
else await modelSettings.click();
}
await expect(speed).toBeVisible({ timeout: 30_000 });
await speed.click();
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
}
async function selectSpeed(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).last().click();
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
if (choices.includes(choice)) {
await expect(option).toBeVisible();
await expect(option).toBeInViewport({ ratio: 1 });
} else await expect(option).not.toBeVisible();
}
}
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
const client = await connectDaemonClient<
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
>({
clientIdPrefix: "codex-speed-model",
});
try {
await client.setAgentModel(agentId, modelId);
} finally {
await client.close();
}
}
for (const viewport of [
{ name: "desktop", width: 1280, height: 850 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
try {
const agent = await workspace.client.createAgent({
provider: "codex",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Speed settings",
model: "gpt-6.1-sol",
modeId: "full-access",
initialPrompt: "Get ready to work on this project.",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
if (viewport.name === "desktop") {
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toHaveText("");
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast"]);
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
}
} finally {
await workspace.cleanup();
}
});
}
@@ -0,0 +1,70 @@
import { test } from "../support/fixtures";
import {
expectContentColumnWidth,
expectCustomContentWidthSetting,
expectDefaultContentWidthSetting,
openAppearanceSettings,
resetContentWidth,
setContentWidth,
} from "../support/helpers/content-width";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "content-width-",
title: "Content width",
initialPrompt: "Write a long paragraph.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
const assistantMessage = page.getByTestId("assistant-message").first();
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
await test.step("chat starts at the default width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
});
await test.step("settings show the default without a reset", async () => {
await openAppearanceSettings(page);
await expectDefaultContentWidthSetting(page, 820);
});
await test.step("a custom width is saved and offers a reset", async () => {
await setContentWidth(page, 1600);
await expectCustomContentWidthSetting(page, 1600);
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
});
await test.step("chat and Markdown files use the custom width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
});
await test.step("reset returns to the default without storing it", async () => {
await openAppearanceSettings(page);
await resetContentWidth(page);
await expectDefaultContentWidthSetting(page, 820);
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
});
} finally {
await agent.cleanup();
}
});
@@ -1,7 +1,40 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import type { Locator, Page } from "@playwright/test";
import { expect, test as base } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import {
type AgentUsageScript,
closeContextWindowSheet,
contextWindowSheet,
expectRefreshButtons,
expiredLogin,
gate,
hoverContextWindowMeter,
leaveContextWindowMeter,
type MockAgentSession,
onPersonalLogin,
onWorkLogin,
openAgent,
pressContextWindowMeter,
qaScreenshot,
refreshUsageCard,
reloadAgent,
scriptAgentUsage,
seedAgentWithContextWindow,
usageCard,
} from "../support/helpers/context-window";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
const test = base.extend<{ agent: MockAgentSession }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedAgentWithContextWindow();
try {
await provide(agent);
} finally {
await agent.cleanup();
}
},
});
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
@@ -69,3 +102,184 @@ test.describe("context window meter", () => {
}
});
});
const DESKTOP = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
type OpenDetails = (page: Page) => Promise<Locator>;
type Shot = (state: string) => Promise<void>;
/** Two reports on the agent's login stream in; the details show each card as it arrives. */
async function expectCardsToStreamIn(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<Locator> {
const [claude, codex] = claudeAndCodexReports();
const first = gate();
const second = gate();
usage.answerNext({
stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)],
});
const details = await openDetails(page);
await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible();
await shot("loading");
first.open();
await expect(
usageCard(details, "claude:work").getByText("work@example.com", { exact: true }),
).toBeVisible();
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
await expect(usageCard(details, "codex:work")).toHaveCount(0);
await shot("streaming");
second.open();
await expect(
usageCard(details, "codex:work").getByText("Session", { exact: true }),
).toBeVisible();
// Only the agent's login, never the host's default one, and no pins.
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
await expectUnpinnableRows(details);
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
return details;
}
async function expectFailedRequestSentence(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<void> {
usage.answerNext({ error: "Unknown agent" });
await reloadAgent(page);
const details = await openDetails(page);
await expect(
details.getByText("Unable to load usage: Unknown agent", { exact: true }),
).toBeVisible();
await shot("error");
}
async function expectOnlyContextWindowWithoutUsage(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<void> {
usage.stopReportingUsage();
await reloadAgent(page);
const details = await openDetails(page);
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0);
await shot("unsupported");
}
for (const theme of ["light", "dark"] as const) {
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
test.setTimeout(240_000);
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
await test.step("reports stream in one card at a time, without Refresh", async () => {
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
// The tooltip cannot be pressed, so its cards have no Refresh.
await expectRefreshButtons(tooltip, 0);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("after a resume under another login, only that login shows", async () => {
// Closed while the old login's report is still on its way.
await leaveContextWindowMeter(page);
const slowSource = gate();
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
const closedEarly = await hoverContextWindowMeter(page);
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
await leaveContextWindowMeter(page);
const resumed = gate();
const finished = gate();
usage.answerNext({
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
});
const tooltip = await hoverContextWindowMeter(page);
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
// The old login's report lands during the new request, before the new login's.
slowSource.open();
resumed.open();
await expect(
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
).toBeVisible();
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
finished.open();
});
await test.step("a report with a problem shows it on the card", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
await reloadAgent(page);
const tooltip = await hoverContextWindowMeter(page);
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
await shot("problem");
});
await test.step("a failed request says so in a sentence", async () => {
await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot);
});
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
// One request per open that had usage; this one sends none.
expect(usage.agentRequests()).toHaveLength(5);
});
});
test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({
page,
agent,
}) => {
test.setTimeout(240_000);
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize(COMPACT);
await openAgent(page, agent);
const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`);
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
await expectRefreshButtons(sheet, 2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const sheet = contextWindowSheet(page);
await refreshUsageCard(sheet, "Claude");
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
await shot("problem");
});
await test.step("Close dismisses the sheet", async () => {
await closeContextWindowSheet(page);
});
await test.step("a failed request says so in a sentence", async () => {
await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot);
});
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
expect(usage.agentRequests()).toHaveLength(2);
});
});
}
@@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => {
await waitForWorkspaceTabsVisible(page);
const explorer = await openExplorerWithKeyboard(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
/** Scope the shared tab row to the Explorer dock. */
function explorerTabRow(page: Page): Locator {
return visible(page, "explorer-sidebar-tab-rail");
return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
}
function mainTabRow(page: Page): Locator {
return visible(page, "workspace-tabs-row");
return page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true });
}
async function selectExplorerChanges(page: Page): Promise<void> {
@@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => {
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(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
@@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise<void> {
}
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();
await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: /^Split pane right/ }).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);
await expect(mainTabRow(page)).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
@@ -3,20 +3,20 @@ import {
withExplorerPanelPlugins,
expectWorkspacePanelsInExplorerLauncher,
openWorkspacePanelFromExplorerMenu,
expectIndependentExplorerPanelToggles,
expectIndependentExplorerPanelTabs,
expectExplorerPanelWithFocusedAgent,
} from "../support/helpers/explorer-plugin-menu";
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({
page,
}, testInfo) => {
await withExplorerPanelPlugins(page, async (workspace) => {
await test.step("the New Tab launcher offers compatible workspace panels", () =>
await test.step("the + menu offers compatible workspace panels", () =>
expectWorkspacePanelsInExplorerLauncher(page));
await test.step("the rail menu offers the same workspace panels", () =>
await test.step("the + menu opens a workspace panel in Explorer", () =>
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
await test.step("different panels and plugins keep independent selection and closing", () =>
expectIndependentExplorerPanelToggles(page, workspace));
expectIndependentExplorerPanelTabs(page, workspace));
await test.step("focusing an agent does not add agent panels to Explorer", () =>
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
});
@@ -1,5 +1,9 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
gotoWorkspace,
pressNewTabShortcut,
expectTabTitleFits,
} from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
ensureExplorerSidebar,
@@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
await openFilesPanel(page);
const main = page.getByTestId("workspace-pane-main");
await main.hover();
const activeMainLabel = main
.getByTestId("workspace-tabs-row")
.locator('[aria-selected="true"]')
.getByText("New tab", { exact: true });
const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color);
const activeFiles = explorerSidebar(page).getByRole("button", {
name: "Browse workspace files",
exact: true,
});
await expect(activeFiles).toHaveAttribute("aria-selected", "true");
await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground);
}
test.describe("Explorer sidebar", () => {
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
page,
@@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => {
.count();
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1);
await openFilesPanel(page);
await expectExplorerActiveTabForeground(page);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
await explorer.getByTestId("workspace-tab-changes_tree").click();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await page.getByTestId("workspace-explorer-toggle").first().click();
@@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => {
}
});
});
async function launchExplorerPanel(
page: Parameters<typeof ensureExplorerSidebar>[0],
name: string,
) {
const explorer = explorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click();
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function closeExplorerFilesFromContextMenu(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
const explorer = explorerSidebar(page);
const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true });
await files.hover();
await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0);
await explorer.getByTestId("workspace-tab-changes_tree").hover();
await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0);
await files.click({ button: "right", position: { x: 12, y: 13 } });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect(files).toHaveCount(0);
}
async function expectWorkspaceCloseOnHover(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const main = page.getByTestId("workspace-pane-main");
const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]');
const close = main.getByRole("button", { name: "Close", exact: true });
await explorerSidebar(page).hover();
await expect(close.locator("..")).toHaveCSS("opacity", "0");
await tab.hover();
await expect(close.locator("..")).toHaveCSS("opacity", "1");
}
async function closeOtherExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const files = explorerSidebar(page).getByRole("button", {
name: "Browse workspace files",
exact: true,
});
await files.click({ button: "right", position: { x: 12, y: 13 } });
const confirmation = page.waitForEvent("dialog").then((dialog) => {
expect(dialog.message()).toContain("close 1 tab");
return dialog.accept();
});
await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click();
await confirmation;
}
test("Explorer keeps Files and Changes close actions in the context menu", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const main = page.getByTestId("workspace-pane-main");
const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count();
const explorer = await ensureExplorerSidebar(page);
await expectTabTitleFits(page, "Files", { min: 64, max: 90 });
await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => {
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
const entries = await menu.getByRole("menuitem").allTextContents();
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
entries.findIndex((entry) => entry.startsWith("Diff")),
);
await page.keyboard.press("Escape");
await main.getByTestId("workspace-new-tab-button").click();
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
});
await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => {
await expectWorkspaceCloseOnHover(page);
await closeExplorerFilesFromContextMenu(page);
await launchExplorerPanel(page, "Files");
await expect(
explorer.getByRole("button", { name: "Browse workspace files", exact: true }),
).toBeVisible();
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
});
await test.step("standard bulk-close menus affect only Explorer tabs", async () => {
await closeOtherExplorerTabs(page);
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0);
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount(
mainTabsBefore,
);
await launchExplorerPanel(page, "Changes");
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
});
await test.step("Cmd+T still belongs to the focused workspace pane", async () => {
await pressNewTabShortcut(page);
await expect(
main.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
});
await testInfo.attach("shared-explorer-tabs", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await workspace.cleanup();
}
});
async function dragAgentIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
agentId: string,
) {
const source = page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${agentId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
test("agents can still be dragged into Explorer when absent from its + menu", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const explorer = await ensureExplorerSidebar(page);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Dragged agent",
modeId: "load-test",
model: "ten-second-stream",
});
const main = page.getByTestId("workspace-pane-main");
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await dragAgentIntoExplorer(page, agent.id);
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await testInfo.attach("agent-in-explorer", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await workspace.cleanup();
}
});
@@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible();
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
@@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
@@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
});
} finally {
await workspace.cleanup();
@@ -279,7 +279,12 @@ test.describe("CodeMirror workspace file editing", () => {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "target.ts");
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await expect(
page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(2);
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
@@ -0,0 +1,51 @@
import { test, expect } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { expectAppRoute } from "../support/helpers/route-assertions";
import {
buildPairingLink,
cancelHostConfirmation,
connectToConfirmedHost,
expectHostConfirmationFor,
expectHostInHostPicker,
hostConfirmation,
openPairingLink,
saveLinkedHostBeforeLoad,
type LinkedHost,
} from "../support/helpers/host-confirmation";
// An unreachable relay: the link saves the host without connecting first.
const linkedHost: LinkedHost = {
serverId: "srv_link_confirmation",
relayEndpoint: "127.0.0.1:59998",
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
};
test("a pairing link for a new host asks before saving it", async ({ page }) => {
const link = buildPairingLink(linkedHost);
await test.step("Cancel saves nothing and stays on the page", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await cancelHostConfirmation(page);
await expectAppRoute(page, "/settings/general");
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
});
await test.step("Connect saves the host and opens a project", async () => {
await openPairingLink(page, link);
await expectHostConfirmationFor(page, linkedHost);
await connectToConfirmedHost(page);
await expectAppRoute(page, "/open-project");
await openSettings(page);
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
});
});
test("a pairing link for a saved host connects without asking", async ({ page }) => {
await saveLinkedHostBeforeLoad(page, linkedHost);
await openPairingLink(page, buildPairingLink(linkedHost));
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
await expect(hostConfirmation(page)).toHaveCount(0);
});
@@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.revealMobileEntryPoint();
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
@@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await test.step("the mobile sidebar exposes import in its footer", async () => {
await flow.revealMobileEntryPoint();
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
await test.step("the new workspace screen offers import at the top", async () => {
await flow.revealNewWorkspaceEntryPoint();
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
});
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
await flow.openGlobally();
await flow.openFromNewWorkspace();
await flow.expectScope("Sessions on", false);
await flow.expectRows({
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
@@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
@@ -1,5 +1,5 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
@@ -36,9 +36,18 @@ function ModalBody({ onSaved }) {
</View>;
}
function Surface() {
function Surface({ playAudio }) {
const [audioStatus, setAudioStatus] = useState("Audio ready");
async function play(base64) {
setAudioStatus("Playing audio");
try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); }
catch { setAudioStatus("Audio rejected"); }
}
const [open, setOpen] = useState(false);
return <View>
<Text>{audioStatus}</Text>
<Pressable accessibilityRole="button" onPress={() => play(AUDIO_BASE64)}><Text>Play plugin audio</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => play("UklGRg==")}><Text>Play invalid audio</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row" }}>
<Icon name="Pencil" size={18} />
@@ -59,7 +68,7 @@ function Surface() {
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSurface("main", () => <Surface playAudio={plugin.playAudio} />);
plugin.addSidebarItem({
id: "main",
title: "Host UI",
@@ -123,7 +132,11 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav"));
await writeFile(
path.join(directory, "index.client.tsx"),
`const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -131,6 +144,12 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await useNonCompactLayout(page);
await openHostUiPlugin(page);
await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => {
await playPluginAudio(page, "Play plugin audio", "Audio finished");
await playPluginAudio(page, "Play invalid audio", "Audio rejected");
await playPluginAudio(page, "Play plugin audio", "Audio finished");
});
await test.step("non-compact layouts use a centered dialog", async () => {
await openPluginModal(page);
await expectCenteredPluginDialog(page);
@@ -153,3 +172,8 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await rm(directory, { recursive: true, force: true });
}
});
async function playPluginAudio(page: Page, button: string, result: string) {
await page.getByRole("button", { name: button, exact: true }).click();
await expect(page.getByText(result, { exact: true })).toBeVisible();
}
@@ -14,6 +14,7 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -0,0 +1,483 @@
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BOTS_PLUGIN_ID,
COMPACT,
LEGACY_PLUGIN_ID,
SHOWCASE_PLUGIN_ID,
WIDE,
closeScreen,
expectRowActive,
footerItem,
headerRow,
installSidebarPlugins,
popoverBox,
runCommand,
visibleTestId,
} from "../support/helpers/plugin-sidebar-items";
import {
leaveSettings,
openSidebarNavSettings,
seedSidebarFooterPreferences,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
async function qaScreenshot(page: Page, name: string, area?: Locator) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let popover, sheet and drawer animations settle so the image shows the final state.
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (area) await area.screenshot({ path: file });
else await page.screenshot({ path: file });
}
/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */
async function expectFooterRow(page: Page, row: Locator) {
// Polled: on compact the drawer is still sliding in when the row first shows.
await expect
.poll(async () => {
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
const box = (await row.boundingBox())!;
return {
aboveBottomLine: box.y + box.height <= addProject.y,
alignedLeft: Math.abs(box.x - addProject.x) < 1,
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
};
})
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
}
function botRow(page: Page, rowId: string): Locator {
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
}
async function boxOf(locator: Locator) {
const result = await locator.boundingBox();
expect(result).not.toBeNull();
return result!;
}
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
function screenTitle(page: Page): Locator {
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
}
async function expectBotScreen(page: Page, botId: string) {
await expect(page).toHaveURL(
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
);
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
// The plugin titles the screen from its params: the bot's name.
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
await expectRowActive(botRow(page, "status"), false);
}
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
const popover = await popoverBox(page, text);
const rowBox = await boxOf(row);
const otherBox = await boxOf(elsewhere);
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
}
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
function trackReleasedSubscriptions(page: Page): Set<string> {
const released = new Set<string>();
page.on("websocket", (socket) =>
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
);
return released;
}
function collectRelease(released: Set<string>, payload: string | Buffer) {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.message?.type === "subscription.release.response")
released.add(frame.message.payload.subscriptionId);
}
async function expectReleased(released: Set<string>, subscriptionId: string) {
await expect.poll(() => released.has(subscriptionId)).toBe(true);
}
function readAlertsObservation(page: Page): Promise<string | null> {
return page.evaluate(
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
);
}
/** The id of the observation the Bot alerts item opened after `previous`. */
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
return (await readAlertsObservation(page))!;
}
function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
const plugins = await installSidebarPlugins();
workspaceId = workspace.workspaceId;
cleanup = async () => {
await plugins.cleanup();
await workspace.cleanup();
};
});
test.afterEach(async () => {
await cleanup();
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
test.setTimeout(180_000);
// The Usage item shows only with summary data, once it is turned on.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(sync).toBeVisible();
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
0,
);
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
});
await test.step("the header row highlights while its screen is open", async () => {
await expectRowActive(row, false);
await row.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(screenTitle(page)).toHaveText("Deploys");
await expectRowActive(row, true);
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
await closeScreen(page);
await expectRowActive(row, false);
});
await test.step("the trailing button presses on its own", async () => {
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
await expect(page).not.toHaveURL(/\/plugin\//);
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
});
await test.step("the footer row renders as a row above the bottom line", async () => {
await expect(sync).toHaveAccessibleName("Sync");
await expectFooterRow(page, sync);
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
});
await test.step("the footer row opens a popover anchored to it", async () => {
await sync.click();
const popover = await popoverBox(page);
const button = (await sync.boundingBox())!;
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
expect(button.y - popover.y).toBeLessThan(300);
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
await qaScreenshot(page, "phase2-desktop-popover");
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
await closeScreen(page);
});
await test.step("the command center item opens the screen", async () => {
await runCommand(page, "Open deploys");
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expectRowActive(row, true);
await closeScreen(page);
});
await test.step("on a compact layout the popover is a bottom sheet", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
const sheet = await popoverBox(page);
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
await qaScreenshot(page, "phase2-compact-sheet");
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
});
});
test("a header row's shortcut hint presses the row", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
await newWorkspace.hover();
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
await expect(hint).toBeVisible();
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
await hint.click();
await expect(page).toHaveURL(/\/new(\?|$)/);
});
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
test.setTimeout(180_000);
await page.addInitScript(
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
{
key: APP_SETTINGS_KEY,
sidebarNavItems: [
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
{ key: "new-workspace", visible: true },
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
],
},
);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
await expect(entry).toBeVisible({ timeout: 30_000 });
let entryIcon = "";
await test.step("the saved order and visibility still apply", async () => {
await expect(entry).toHaveAccessibleName("Legacy entry");
await expect(entry.locator("svg")).toHaveCount(1);
entryIcon = await entry.locator("svg").innerHTML();
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
const entryBox = (await entry.boundingBox())!;
const newWorkspaceBox = (await visibleTestId(
page,
"sidebar-global-new-workspace",
).boundingBox())!;
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
});
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
await entry.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
await expect(screenTitle(page)).toHaveText("Legacy entry");
await expectRowActive(entry, true);
await closeScreen(page);
});
await test.step("remembered sidebar routes keep resolving", async () => {
await page.goto(
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
);
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
});
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
const host = encodeURIComponent(getServerId());
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(screenTitle(page)).toHaveText("Bot 2");
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
await expect(screenTitle(page)).toHaveText("Bot");
});
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
await openSidebarNavSettings(page);
const legacyIcon = page
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
.locator("svg")
.first();
const showcaseIcon = page
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
.locator("svg")
.first();
const footerIcon = page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
.locator("svg")
.first();
const markup = await showcaseIcon.innerHTML();
expect(await footerIcon.innerHTML()).toBe(markup);
expect(await legacyIcon.innerHTML()).not.toBe(markup);
// The same registered icon the sidebar row draws.
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
await qaScreenshot(
page,
"phase5-settings-sidebar-footer",
page.getByTestId("sidebar-nav-section-footer"),
);
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
await qaScreenshot(page, "phase2-compact-settings-sidebar");
});
});
test("one item renders a group of rows, and each row opens its own screen params", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const bot1 = botRow(page, "bot-1");
const bot2 = botRow(page, "bot-2");
const status = botRow(page, "status");
await expect(bot1).toBeVisible({ timeout: 30_000 });
await test.step("the rows and the separator render in order in the header", async () => {
await expect(bot1).toHaveAccessibleName("Bot 1");
await expect(bot2).toHaveAccessibleName("Bot 2");
await expect(status).toHaveAccessibleName("Bot status");
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
expect(second.y).toBeGreaterThan(first.y);
// The separator adds its line and margins between Bot 2 and the status row.
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
expect(second.y - (first.y + first.height)).toBeLessThan(4);
});
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
await bot2.click();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
});
await test.step("a reload keeps the screen and its params", async () => {
await page.reload();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-bot-screen");
});
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
await bot1.click();
await expectBotScreen(page, "bot-1");
await page.goBack();
await expectBotScreen(page, "bot-2");
});
await test.step("a popover opened from a later row anchors to that row", async () => {
await status.click();
await expectPopoverBeside(page, "Bot status details", status, bot1);
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
});
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
await qaScreenshot(page, "phase6-desktop-popover-row");
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
});
await test.step("a param named like a route segment reaches the screen", async () => {
await runCommand(page, "Open bot 3 on server x");
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
// No bot named bot-3, so the title function returns its own fallback.
await expect(screenTitle(page)).toHaveText("Bot");
await page.reload();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
});
await test.step("Settings lists the group as one item", async () => {
await openSidebarNavSettings(page);
const header = page.getByTestId("sidebar-nav-section-header");
await expect(
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
).toHaveCount(1);
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
await leaveSettings(page);
});
await test.step("the compact sidebar shows the same group", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expect(botRow(page, "bot-1")).toBeInViewport();
await expect(botRow(page, "status")).toBeInViewport();
await qaScreenshot(page, "phase6-compact-sidebar-bots");
});
});
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await expect(alerts).toHaveCount(0);
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
await expect(alerts).toHaveAccessibleName("Bot alerts");
await expectFooterRow(page, alerts);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
});
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
test.setTimeout(120_000);
const released = trackReleasedSubscriptions(page);
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
const first = await nextAlertsObservation(page, null);
expect(released.has(first)).toBe(false);
await test.step("hiding the item in Settings closes its observation", async () => {
await openSidebarNavSettings(page);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
await expectReleased(released, first);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
await leaveSettings(page);
});
await test.step("removing the item through its remover closes its observation", async () => {
await expect(alerts).toBeVisible();
const second = await nextAlertsObservation(page, first);
expect(released.has(second)).toBe(false);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
await expectReleased(released, second);
});
});
});
@@ -1,18 +1,83 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
import {
expectFooterIconRow,
expectFooterSeparator,
hoverFooterAddProject,
footerScreenshot,
expectFooterItemHidden,
expectFooterOrder,
expectFooterSettingsKeys,
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveFooterItemUp,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarFooterPreferences,
seedSidebarNavPreferences,
setFooterItemVisible,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [
{ key: "add-project", visible: false },
{ key: "usage", visible: true },
]);
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await footerScreenshot(page, "footer-desktop-with-rows");
await hoverFooterAddProject(page);
await footerScreenshot(page, "footer-desktop-add-project-tooltip");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, ["usage"]);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-desktop-without-rows");
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-without-rows");
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0);
await footerScreenshot(page, "footer-compact-add-project-hover");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
});
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
@@ -24,8 +89,8 @@ test.describe("Sidebar items in Appearance settings", () => {
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
"About Header",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
@@ -106,3 +171,69 @@ test.describe("Sidebar items in Appearance settings", () => {
await expectSidebarItemHidden(page, "schedules");
});
});
test.describe("Sidebar footer rows in Appearance settings", () => {
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
({ cleanup } = await installSidebarPlugins());
});
test.afterEach(async () => {
await cleanup();
});
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
test.setTimeout(120_000);
// The Usage item shows only with summary data.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
// Keys for the fixed footer icon buttons are ignored.
await seedSidebarFooterPreferences(page, [
{ key: "usage", visible: true },
{ key: "help", visible: false },
{ key: "hosts", visible: false },
{ key: "import", visible: false },
]);
await gotoAppShell(page);
await test.step("the Footer card lists only the footer rows", async () => {
await openSidebarNavSettings(page);
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
"About Footer",
);
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
await leaveSettings(page);
await expectFooterOrder(page, ["usage", syncKey]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
await openSidebarNavSettings(page);
await moveFooterItemUp(page, syncKey);
await setFooterItemVisible(page, "usage", false);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
await leaveSettings(page);
await expectFooterItemHidden(page, "usage");
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible();
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("the rows keep that shape across a reload", async () => {
await page.reload();
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible({ timeout: 30_000 });
await expectFooterItemHidden(page, "usage");
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
});
});
});
@@ -572,18 +572,21 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(1);
await explorerToggle.click();
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toHaveCount(0);
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {
@@ -199,6 +199,49 @@ async function quitVim(page: Page) {
});
}
interface TerminalChord {
key: string;
bytes: string;
}
async function openKeyboardCapture(page: Page) {
await test.step("Open a real terminal process that reports received bytes", async () => {
const terminal = await harness.createTerminal({
name: "Keyboard bytes",
command: process.execPath,
args: [
"-e",
`
process.stdin.setRawMode(true);
let received = Buffer.alloc(0);
process.stdin.on('data', chunk => {
received = Buffer.concat([received, chunk]);
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
});
console.log('KEYBOARD_READY');
`,
],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
});
}
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
let received = "";
for (const chord of chords) {
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
await pressTerminalShortcut(page, chord.key);
// A printable sentinel proves delivery even for chords expected to send nothing.
await input(page).pressSequentially("x");
received += Buffer.from(chord.bytes + "x").toString("hex");
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
});
}
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
}
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
@@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => {
"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",
});
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "\x01" },
{ key: "Meta+ArrowRight", bytes: "\x05" },
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
{ key: "Meta+Backspace", bytes: "\x15" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+v", bytes: "\x16" },
{ key: "Control+f", bytes: "\x06" },
{ key: "ArrowLeft", bytes: "\x1b[D" },
{ key: "ArrowRight", bytes: "\x1b[C" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
]);
await pressTerminalShortcut(page, "Meta+f");
await expect(query(page)).toBeFocused();
});
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
@@ -232,7 +297,40 @@ test.describe("macOS terminal shortcuts", () => {
});
});
async function useLinuxKeyboardPlatform(page: Page) {
// A user agent alone leaves navigator.platform as MacIntel on macOS hosts.
// Emulate both browser identity fields before the app reads its keyboard policy.
const session = await page.context().newCDPSession(page);
await session.send("Emulation.setUserAgentOverride", {
userAgent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
platform: "Linux x86_64",
});
}
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
await useLinuxKeyboardPlatform(page);
await openKeyboardCapture(page);
await expectTerminalChords(page, [
{ key: "Meta+ArrowLeft", bytes: "" },
{ key: "Meta+ArrowRight", bytes: "" },
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
{ key: "Meta+Backspace", bytes: "\x7f" },
{ key: "Control+c", bytes: "\x03" },
{ key: "Control+a", bytes: "\x01" },
{ key: "Control+e", bytes: "\x05" },
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
{ key: "Control+Backspace", bytes: "\x08" },
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
]);
await pressTerminalShortcut(page, "Control+f");
await expect(query(page)).toBeFocused();
});
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
await useLinuxKeyboardPlatform(page);
await openControlCharacterTerminal(page);
await pressTerminalShortcut(page, "Control+f");
await expect(query(page)).toBeFocused();
@@ -1,219 +0,0 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
return {
id: REPORT_ID,
account: { label: "Fixture account" },
fetchedAt,
sourceId: "fixture",
sourceLabel: "Fixture plan",
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
report: {
status: "available",
planLabel: "Test plan",
windows: [
{ id: "session", label: "Session", usedPct: 3 },
{ id: "weekly", label: "Weekly", usedPct, headline: true },
],
},
};
}
async function openMockAgent(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "usage-composer-pill-",
title: "Usage composer pill e2e",
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
return session;
}
test.describe("usage composer pill", () => {
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
await expect(popover.getByText("Test plan")).toBeVisible();
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("refreshes only its report from the popover", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: [REPORT_ID] },
{ forceRefresh: true, reportIds: [REPORT_ID] },
]);
await expect(popover.getByText("64%")).toBeVisible();
await expect(pill).toContainText("64%");
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
// Pressing closes the tooltip; it reopens on the next hover.
await popover.getByText("Fixture plan", { exact: true }).hover();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
expect(usage.listRequests()).toHaveLength(2);
} finally {
await session.cleanup();
}
});
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [
[agentEntry(42, twoHoursAgo())],
{ error: "Usage source timed out" },
() => [agentEntry(64, new Date().toISOString())],
],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
const refresh = popover.getByTestId("usage-refresh");
await refresh.click();
await usage.waitForListRequests(2);
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
"Unable to refresh usage",
);
await expect(popover.getByText("42%")).toBeVisible();
await expect(pill).toContainText("42%");
await refresh.click();
await usage.waitForListRequests(3);
await expect(popover.getByText("64%")).toBeVisible();
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
} finally {
await session.cleanup();
}
});
test("prints the freshness on the card where there is no hover", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 390, height: 844 });
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
const requestsBeforeTurn = usage.agentRequests().length;
const listRequestsBeforeTurn = usage.listRequests().length;
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
expect(usage.agentRequests().at(-1)).toEqual({
agentId: session.agentId,
});
await expect(pill).toContainText("42%");
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
} finally {
await session.cleanup();
}
});
test("is hidden when the agent has no usage report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
const session = await openMockAgent(page);
try {
await usage.waitForAgentRequests(1);
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
test("is hidden on a host without usage sources", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
usageSources: false,
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.agentRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
});
+358 -22
View File
@@ -1,16 +1,105 @@
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
installUsageReportsFixture,
type UsageListRequest,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import {
installLoginUsage,
installCodexWindowUsage,
expectCodexReportedWindows,
openUsage,
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
expectPinnedUsage,
pinRow,
openUsageOptions,
showUsageAs,
refreshAllUsage,
togglePin,
usageItem,
} from "../support/helpers/usage-sidebar-item";
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
test.use({
e2eDaemonEnvironment: {
HOME: emptyHome,
USERPROFILE: emptyHome,
CODEX_HOME: emptyHome,
CLAUDE_CONFIG_DIR: emptyHome,
XDG_DATA_HOME: emptyHome,
PI_CODING_AGENT_DIR: emptyHome,
COPILOT_TOKEN: "",
GITHUB_TOKEN: "",
GITHUB_PAT: "",
CURSOR_ACCESS_TOKEN: "",
CURSOR_TOKEN: "",
GROK_API_KEY: "",
GROK_TOKEN: "",
KIMI_TOKEN: "",
KIMI_API_KEY: "",
KIMI_CODE_HOME: emptyHome,
MINIMAX_API_KEY: "",
ZAI_API_KEY: "",
GLM_API_KEY: "",
},
});
// These tests read the sidebar summary and pin windows, which both need the Usage item on.
test.beforeEach(async ({ page }) => {
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
});
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
return usage.listRequests().filter((request) => request.forceRefresh);
}
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
function hostFilter(page: Page) {
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
}
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
return {
id: `${sourceId}:a`,
account: {},
fetchedAt: twoHoursAgo(),
sourceId,
sourceLabel: `${sourceId} plan`,
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] },
};
}
test.describe("usage screen", () => {
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
@@ -24,7 +113,7 @@ test.describe("usage screen", () => {
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
},
},
{
@@ -33,7 +122,10 @@ test.describe("usage screen", () => {
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: "beta",
sourceLabel: "Beta plan",
report: { status: "unavailable", windows: [] },
report: {
status: "unavailable",
problem: { kind: "no_quota", detail: "No active coding plan" },
},
},
],
],
@@ -46,6 +138,8 @@ test.describe("usage screen", () => {
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "phase7-usage-screen-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -66,7 +160,7 @@ test.describe("usage screen", () => {
sourceLabel: "Beta plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
},
};
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
@@ -77,16 +171,16 @@ test.describe("usage screen", () => {
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
[alpha(31, twoHoursAgo()), beta],
() => [
alpha(58, new Date().toISOString()),
{ ...beta, fetchedAt: new Date().toISOString() },
],
(request) =>
request.forceRefresh
? [alpha(58, new Date().toISOString())]
: [alpha(31, twoHoursAgo()), beta],
],
});
@@ -97,11 +191,9 @@ test.describe("usage screen", () => {
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: undefined },
{ forceRefresh: true, reportIds: ["alpha:a"] },
]);
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
@@ -138,19 +230,263 @@ test.describe("usage screen", () => {
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
});
test("tells the user to update a host without usage sources", async ({ page }) => {
test("tells the user to update a host without usage support", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
// Without reports the footer has no Usage item; its Usage icon opens the screen.
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
exact: true,
}),
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
});
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
page,
}) => {
test.setTimeout(420_000);
const primaryServerId = getServerId();
const secondary = await startIsolatedHostDaemon(
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
);
try {
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
await installUsageReportsFixture(page, {
port: secondary.port,
lists: [[weeklyReport("beta", 12)]],
});
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: "Secondary box",
port: secondary.port,
});
// Nothing picked and no workspace open: the first host.
await expectPinnedUsage(page, ["31% Weekly"]);
await usageItem(page).click();
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-screen-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
await addConnectedHostAndReload(page, {
serverId: secondary.serverId,
label: "Secondary box",
port: secondary.port,
});
await expectPinnedUsage(page, ["12% Weekly"]);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
}
});
});
const loginWindows: UsageReportEntry["report"] = {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 },
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 },
],
};
test("expired login refreshes to windows with visible pin toggles", async ({ page }) => {
const fixture = await installLoginUsage({
status: "unavailable",
problem: {
kind: "expired",
expiresAt: new Date(Date.now() - 3_600_000).toISOString(),
refreshedBy: "claude",
},
});
try {
await gotoAppShell(page);
await openUsage(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
await qaScreenshot(page, "usage-expired-login");
await fixture.setReport(loginWindows);
await refreshLoginUsage(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toHaveCount(0);
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
await expect(row).toBeChecked();
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
await hoverUsageWindow(page, "Weekly");
await expect(page.getByText("Unpin", { exact: true })).toBeVisible();
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
"fill",
"none",
);
await qaScreenshot(page, "usage-pin-hover");
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
"fill",
"none",
);
await hoverUsageWindow(page, "Weekly");
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% 5h"]);
await qaScreenshot(page, "usage-pin-unselected");
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
} finally {
await fixture.cleanup();
}
});
test("compact usage rows always show the pin glyph", async ({ page }) => {
const fixture = await installLoginUsage(loginWindows);
try {
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await openUsage(page);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1");
await qaScreenshot(page, "usage-pin-compact");
} finally {
await fixture.cleanup();
}
});
for (const shape of ["seven-day-only", "Spark"] as const) {
test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({
page,
}) => {
const reset = Math.floor(Date.now() / 1000) + 5 * 86400;
const source = await installCodexWindowUsage({
rate_limit: {
primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset },
secondary_window: null,
},
additional_rate_limits:
shape === "Spark"
? [
{
limit_name: "GPT-5.3-Codex-Spark",
metered_feature: "codex_bengalfox",
rate_limit: {
primary_window: {
used_percent: 0,
limit_window_seconds: 18000,
reset_at: Math.floor(Date.now() / 1000) + 3600,
},
secondary_window: {
used_percent: 0,
limit_window_seconds: 604800,
reset_at: reset,
},
},
},
]
: [],
});
try {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expect(usageItem(page)).toBeVisible();
await usageItem(page).click();
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
await expectCodexReportedWindows(page, shape);
await qaScreenshot(page, `codex-${shape}-pins`);
} finally {
await source.cleanup();
}
});
}
for (const theme of ["light", "dark"] as const) {
for (const [size, viewport] of Object.entries({
desktop: { width: 1440, height: 900 },
compact: { width: 390, height: 844 },
})) {
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript((value) => {
const key = "@paseo:app-settings";
const current = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...current, theme: value }));
}, theme);
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize(viewport);
await page.goto("/usage");
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = screen.getByRole("button", { name: "Settings", exact: true });
const chevron = settings.locator("svg").locator("../..");
await expect(settings).toHaveAccessibleName("Settings");
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(chevron).toHaveCSS("transform", "none");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
).toHaveCSS("opacity", "1");
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
await togglePin(screen, "Claude", "Weekly");
await page.mouse.move(0, viewport.height - 1);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
).toHaveCSS("opacity", "1");
await openUsageOptions(page);
await expect(settings).toHaveAttribute("aria-expanded", "true");
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
"aria-selected",
"true",
);
await settings.blur();
await page.mouse.move(0, viewport.height - 1);
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
await showUsageAs(page, "remaining");
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
);
await settings.click();
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
});
}
}
async function captureSettingsState(
page: Page,
testInfo: import("@playwright/test").TestInfo,
name: string,
) {
await qaScreenshot(page, name);
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
}
+42 -27
View File
@@ -3,14 +3,24 @@ import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
function forcedRefreshCount(usage: UsageReportsFixture): number {
return usage.listRequests().filter((request) => request.forceRefresh).length;
}
function report(input: {
sourceId: string;
sourceLabel: string;
report: Partial<UsageReportEntry["report"]>;
report:
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
| Exclude<UsageReportEntry["report"], { status: "available" }>;
}): UsageReportEntry {
return {
id: `${input.sourceId}:account`,
@@ -19,11 +29,14 @@ function report(input: {
sourceId: input.sourceId,
sourceLabel: input.sourceLabel,
icon: ICON,
report: {
status: "available",
windows: [],
...input.report,
},
report:
input.report.status === "error" || input.report.status === "unavailable"
? input.report
: {
status: "available",
windows: [],
...input.report,
},
};
}
@@ -39,7 +52,7 @@ test.describe("usage settings", () => {
sourceLabel: "Alpha plan",
report: {
planLabel: "Max",
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
}),
report({
@@ -69,7 +82,6 @@ test.describe("usage settings", () => {
await gotoAppShell(page);
await openSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openSettingsHostSection(page, serverId, "usage");
await usage.waitForListRequests(1);
@@ -82,26 +94,29 @@ test.describe("usage settings", () => {
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
// Percentages now live on the Usage screen, and still apply to the host section.
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
const hostUsageUrl = page.url();
await page.goto("/usage");
await showUsageAs(page, "remaining");
await page.goto(hostUsageUrl);
await expect(card.getByText("30% left")).toBeVisible();
await expect(card.getByText("93% left")).toBeVisible();
});
test("refresh forces a fresh report", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
// The sidebar summary and the section each load reports; only Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
[
(request) => [
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(23) },
}),
],
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(64) },
report: { windows: windows(request.forceRefresh ? 64 : 23) },
}),
],
],
@@ -110,26 +125,26 @@ test.describe("usage settings", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usage.waitForListRequests(2);
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
await expect(page.getByText("64%")).toBeVisible();
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
test("asks to update a host without usage support and never calls it", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
// Names the host: "Update Laptop to see usage".
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
@@ -0,0 +1,377 @@
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
leaveSettings,
openSidebarNavSettings,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectNoUsageItem,
expectOnUsageScreen,
expectPinnedUsage,
expectSummaryInSidebar,
expectUnpinnableRows,
leaveUsageScreen,
openCompactSidebar,
openUsageScreenFromIcon,
pinRow,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
togglePin,
usageItem,
usageSheet,
installTallUsageSource,
} from "../support/helpers/usage-sidebar-item";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let sheet and drawer animations settle so the image shows the final state.
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (area.kind === "element") {
await area.locator.screenshot({ path: file });
return;
}
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
await page.screenshot({ path: file, clip });
}
/** Includes summary rows above the fixed icon row. */
async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
test.describe("Usage item", () => {
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
page,
}) => {
test.setTimeout(120_000);
const source = await installTallUsageSource();
try {
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await usageItem(page).click();
const content = page.getByTestId("sidebar-usage-sheet-content");
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
await waitForSettledPosition(content);
const bounds = (await content.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
} finally {
await source.cleanup();
}
});
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
test.setTimeout(180_000);
const serverId = getServerId();
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
await test.step("a fresh device shows default windows once the Usage item is on", async () => {
// The Usage item starts off on every layout; turning it on stores the choice.
await expectNoUsageItem(page);
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await setFooterItemVisible(page, "usage", true);
await qaScreenshot(page, "desktop-settings-sidebar-footer");
await page.setViewportSize(COMPACT);
await expectFooterItemSetting(page, "usage", true);
await qaScreenshot(page, "compact-settings-sidebar-footer");
await gotoAppShell(page);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "compact-footer-defaults");
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
await qaScreenshot(page, "default-pins-card");
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
});
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
// One host: no host filter, as on History.
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
0,
);
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
await qaScreenshot(page, "default-pins-after-one-toggle");
await togglePin(screen, "Codex", "Session");
await expectPinnedUsage(page, ["31% 5h", "12% wk"]);
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
await qaScreenshot(page, "usage-screen-header", {
kind: "element",
locator: page.getByTestId("page-title").locator(".."),
});
await qaScreenshot(page, "usage-card-refresh", {
kind: "element",
locator: screen.getByTestId("usage-report-claude:default"),
});
});
await test.step("remaining flips the Usage item and the Usage screen", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
await expect(
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
).toBeVisible();
await expect(
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
).toBeVisible();
});
await test.step("on desktop the Usage item opens the Usage screen", async () => {
await gotoAppShell(page);
await expect(page).not.toHaveURL(/\/usage$/);
await usageItem(page).click();
await expectOnUsageScreen(page);
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
});
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
await page.setViewportSize(COMPACT);
await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await qaScreenshot(page, "compact-footer");
await usageItem(page).click();
const sheet = usageSheet(page);
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
await expect(sheet.getByText("88% left")).toBeVisible();
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
// The row reads its window, percent and reset; the checkbox state says it is pinned.
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the same inline Settings row and title-row Refresh all.
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await openUsageOptions(page);
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
"true",
);
await qaScreenshot(page, "compact-settings-expanded");
await sheet.getByTestId("usage-options-toggle").click();
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
await qaScreenshot(page, "phase7-compact-sheet");
// Tap the backdrop above the sheet.
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
await expect(sheet).toHaveCount(0);
await page.setViewportSize(WIDE);
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
"true",
);
await qaScreenshot(page, "usage-settings-expanded");
await page.getByTestId("usage-options-toggle").click();
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
const usageUrl = page.url();
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const section = page.getByTestId("usage-card");
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
await page.goto(usageUrl);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step("unpinning everything stays empty after a reload", async () => {
await togglePin(screen, "Claude", "Session");
await togglePin(screen, "Codex", "Weekly");
await expectNoUsageItem(page);
await page.reload();
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoUsageItem(page);
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
await qaScreenshot(page, "explicit-empty-pins-after-reload");
});
await test.step("Settings > Sidebar lists the Usage item", async () => {
await openSidebarNavSettings(page);
const footer = page.getByTestId("sidebar-nav-section-footer");
await expect(
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
).toBeVisible();
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
await leaveSettings(page);
});
});
});
test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({
page,
}) => {
await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: new Date().toISOString(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
status: "unavailable",
problem: { kind: "no_quota", detail: "No active coding plan" },
},
},
],
],
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({
timeout: 30_000,
});
await expectNoUsageItem(page);
await expectFooterSeparator(page, false);
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
await expectOnUsageScreen(page);
});
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
page,
}) => {
const serverId = getServerId();
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
const claude = screen.getByTestId("usage-report-claude:default");
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
await openUsageScreenFromIcon(page);
await expectNoUsageItem(page);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
await expectSummaryInSidebar(page, false);
await qaScreenshot(page, "usage-screen-summary-off");
});
await test.step("on: the Usage item and the pins appear", async () => {
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await qaScreenshot(page, "usage-screen-summary-on");
});
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
await setSummaryInSidebar(page, false);
await expectNoUsageItem(page);
await expectUnpinnableRows(claude);
await page.reload();
await expectOnUsageScreen(page);
await expectSummaryInSidebar(page, false);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
});
});
test("released hosts supply source logos through the client conversion", async ({ page }) => {
await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
providerUsageListOnly: true,
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
// Source logos are decorative SVGs with no accessible role. Their path data distinguishes
// the source artwork from the fallback gauge.
const claudePath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
const codexPath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!;
const summary = usageItem(page).getByTestId("sidebar-usage-source");
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(
screen.getByTestId("usage-report-claude").locator("svg path").first(),
).toHaveAttribute("d", claudePath);
await expect(
screen.getByTestId("usage-report-codex").locator("svg path").first(),
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-screen");
});
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
.toBeNull();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `branchless-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(backingWorktree.projectKey);
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
execFileSync("git", ["switch", "--detach", "main"], {
cwd: backingWorktree.workspaceDirectory,
stdio: "pipe",
});
const created = await worktreeClient.createWorkspace({
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
title: "Sample workspace",
});
if (!created.workspace) {
throw new Error(created.error ?? "Could not create the branchless workspace");
}
const workspaceId = created.workspace.id;
const agent = await createIdleAgent(client, {
cwd: backingWorktree.workspaceDirectory,
workspaceId,
title: "Archived agent",
});
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
scope: "worktree",
});
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
await expect(
page.getByText(
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
{ exact: true },
),
).toBeVisible();
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim();
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
cwd: tempRepo.path,
encoding: "utf8",
}).trim();
expect(restoredHead).toBe(defaultHead);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim(),
).toMatch(/^restored\//);
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
});
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
function commitRestorableChange(cwd: string): void {
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
@@ -0,0 +1,88 @@
import readline from "node:readline";
import { randomUUID } from "node:crypto";
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
const ultra = {
id: "ultrafast",
name: "Ultrafast",
description: "Lowest latency, increased usage",
};
const models = [
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
].map((model) =>
Object.assign(model, {
model: model.id,
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
}),
);
function send(message) {
process.stdout.write(JSON.stringify(message) + "\n");
}
function respond(method, params) {
switch (method) {
case "initialize":
return {};
case "model/list":
return { data: models };
case "config/read":
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
case "collaborationMode/list":
return { data: [] };
case "skills/list":
return { data: [] };
case "thread/list":
case "thread/loaded/list":
return { data: [] };
case "thread/start":
return { thread: { id: randomUUID() }, model: params.model };
case "thread/resume":
return { thread: { id: params.threadId } };
case "thread/read":
return { thread: { id: params.threadId, turns: [] } };
case "turn/start": {
const turnId = randomUUID();
setTimeout(() => {
send({
method: "turn/started",
params: { threadId: params.threadId, turn: { id: turnId } },
});
send({
method: "item/completed",
params: {
threadId: params.threadId,
turnId,
item: {
id: randomUUID(),
type: "agentMessage",
text: "Ready to work on this project.",
},
},
});
send({
method: "turn/completed",
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
});
}, 50);
return { turn: { id: turnId } };
}
default:
throw new Error(`Unsupported Codex fixture request: ${method}`);
}
}
if (process.argv.includes("--version")) {
console.log("codex-cli 0.159.0");
} else {
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
try {
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
} catch (error) {
send({ id: request.id, error: { code: -32601, message: error.message } });
}
});
}
@@ -0,0 +1,24 @@
context platform=android timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 45000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-key-esc\"" 10000
is visible "id=\"terminal-virtual-keyboard-native-wide\""
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-ctrl\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000
press "id=\"terminal-key-ctrl\""
type "c" --delay-ms 0
type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_MODIFIER_RESET_OK__" 10000
close
@@ -0,0 +1,17 @@
context platform=ios timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 30000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_IOS_KEY_OK__" 10000
close
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: workspace-new-tab-button
timeout: 30000
- tapOn:
id: workspace-new-tab-button
- tapOn:
id: workspace-new-tab-menu-terminal
- extendedWaitUntil:
visible:
id: terminal-virtual-keyboard-native-wide
timeout: 10000
- assertVisible:
id: terminal-key-esc
- assertVisible:
id: terminal-key-enter
- assertVisible:
id: terminal-paste
- tapOn:
id: terminal-paste
- runFlow:
when:
visible: Allow Paste
commands:
- tapOn: Allow Paste
- tapOn:
id: terminal-key-enter
- extendedWaitUntil:
visible: __AD_IOS_PASTE_OK__
timeout: 10000
@@ -0,0 +1,18 @@
# Native wide terminal device flows
Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device.
Android Pixel Tablet API 36.1:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36
```
iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`.
```sh
printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
```
The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states.
@@ -0,0 +1,12 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
wait "id=\"workspace-tabs-scroll\"" 10000
close
@@ -0,0 +1,14 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,17 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"context-window-meter\"" 20000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"context-window-meter\"" 10000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,17 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"context-window-meter\"" 20000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"context-window-meter\"" 10000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,33 @@
# Native wide menu flows
Run these on a connected task-owned tablet with the current development app and Metro bundle. `workspace-tabs-scroll` asserts the wide tab row; also pass an explicit tablet `--device` so a landscape phone above the breakpoint cannot stand in for tablet coverage. The context meter flow requires an agent tab with reported token usage selected. Its tooltip assertion uses a stable ID emitted only by the open content.
The agent tab menu flow requires an existing agent tab. Set `AGENT_ID` to the ID of that agent and `TAB_ID` to its workspace tab ID (shown in the `workspace-tab-context-*` test ID) before replay. The flow long-presses the tab and selects **Copy agent id**. It does not claim that an arrow tap works.
The sidebar Rename flow requires a selected workspace and its `WORKSPACE_KEY` (shown in the `sidebar-workspace-kebab-*` test ID). It opens the row's actions, opens Rename, then cancels without changing the workspace name. This covers the currently working row reported in #5226.
For the native-wide tooltip dismissal flows, select an agent with reported token usage so `context-window-meter` exists. A fresh load-test stream can supply usage. The Maestro flows assert that the tooltip is absent after dismissal, then reopen it to prove the control is usable. The overlay dismissal flows assert that the tab menu and Rename dialog are absent after selection/cancel. On iPad the focused Rename input can consume the first Cancel tap; the iOS Maestro flow conditionally repeats that tap only while the dialog remains visible.
For tab scrolling, first create enough tabs for actual horizontal overflow. The Android replay was validated with nine tabs and the Explorer sidebar open; the iPad replay was validated with six tabs. Set `DRAFT_ID` to a visible draft tab, `AGENT_ID` to the first visible agent tab, `OTHER_AGENT_ID` to another agent tab, and `TAB_ID` to the first agent's workspace tab ID. The short pan starts on an agent chip while a draft is selected; the draft composer must remain active. The flow then checks a stationary long press and an ordinary tap. Keep the tab row scrolled to its beginning before running the iPad flow.
Android Pixel Tablet API 36.1:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad --platform android --device paseo-tablet-p1-api36 --env OTHER_AGENT_ID=<other-agent-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad --platform android --device paseo-tablet-p1-api36 --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id> --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad --platform android --device paseo-tablet-p1-api36 --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
```
iPad Pro 11-inch (M5), iOS 26.5, from the Namespace Mac checkout:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env OTHER_AGENT_ID=<other-agent-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
```
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- extendedWaitUntil:
visible:
id: workspace-tabs-scroll
timeout: 30000
- tapOn:
id: workspace-tab-agent_${AGENT_ID}
- longPressOn:
id: workspace-tab-agent_${AGENT_ID}
- assertVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- assertNotVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- longPressOn:
id: workspace-tab-agent_${AGENT_ID}
- assertVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- assertNotVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- assertNotVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- stopApp
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: workspace-tabs-scroll
timeout: 30000
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- runFlow:
when:
visible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
commands:
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- extendedWaitUntil:
notVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
timeout: 10000
- assertNotVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- stopApp
@@ -0,0 +1,13 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
wait "id=\"workspace-tabs-scroll\"" 10000
close
@@ -0,0 +1,16 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
close
@@ -0,0 +1,17 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
gesture pan 900 155 550 0 280
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
wait "id=\"composer-import-agent-pill\"" 10000
gesture pan 770 155 -310 0 280
is visible "id=\"composer-import-agent-pill\""
gesture pan 900 155 550 0 280
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
press "label=\"Menu backdrop\""
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,16 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
wait "id=\"composer-import-agent-pill\"" 10000
gesture pan 450 86 -210 0 280
is visible "id=\"composer-import-agent-pill\""
gesture pan 450 86 240 0 280
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
press "label=\"Menu backdrop\""
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,23 @@
appId: sh.paseo.debug
---
- launchApp
- extendedWaitUntil:
visible:
id: context-window-meter
timeout: 30000
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
@@ -0,0 +1,30 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: context-window-meter
timeout: 30000
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
@@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
Keep generated screenshots, recordings, and run logs outside the repository.
## Stacked adaptive sheets
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
confirmation. It first types without tapping the input to verify autofocus, then cancels the
confirmation, closes and reopens the underlying sheet, confirms again,
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
bottom sheet and centered dialog are exercised.
These integration journeys require the host-confirmation flow in the test app. Include both the
sheet fix and that flow in the QA checkout when verifying them; the production sheet component
has no dependency on the host-confirmation feature.
Use a fresh English app with notification permission granted and first-use keyboard prompts
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
daemon accepts relay connections with no credential.
```sh
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
--udid "$SHEET_QA_IOS_UDID" \
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
--env OUT="$SHEET_QA_OUTPUT"
```
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
first. Use a second loopback spelling
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
Android uses the same journeys: copy each script outside the repository, change its context's
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
default mobile suite, alongside the existing modal-sheet scripts.
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
URI one character at a time passed with both implementations; faster typing remains unverified.
On iPad, replacing the field a second time appends text. For this Settings journey, use the
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
runs end to end without these adjustments.
@@ -0,0 +1,50 @@
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"welcome-paste-pairing-link\"" 20000
screenshot "${OUT}/01-welcome.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
# Type without tapping the input: opening the sheet must retain its autofocus.
wait "id=\"pair-link-input\"" 10000
type "relay://"
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/02-pairing-input.png"
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/03-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"pair-link-submit\" enabled=true" 10000
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
# Close and reopen the underlying sheet, then edit it and submit again.
press "id=\"pair-link-cancel\""
wait stable
wait "id=\"welcome-paste-pairing-link\"" 10000
screenshot "${OUT}/05-underlying-sheet-closes.png"
press "id=\"welcome-paste-pairing-link\""
wait stable
wait "id=\"pair-link-input\"" 10000
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/06-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"pair-link-password-input\"" 20000
screenshot "${OUT}/07-password-after-connect.png"
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
wait stable
press "id=\"pair-link-submit\""
wait stable
wait text "Open a folder on your machine" 30000
screenshot "${OUT}/08-connected.png"
@@ -0,0 +1,46 @@
# Settings with Add host visible: no saved hosts, or the host picker already open.
context platform=ios timeout=180000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"settings-add-host\"" 20000
screenshot "${OUT}/09-settings.png"
press "id=\"settings-add-host\""
wait stable
wait "id=\"add-host-method-direct\"" 10000
screenshot "${OUT}/10-add-host-methods.png"
press "id=\"add-host-method-direct\""
wait stable
press "id=\"direct-host-advanced-toggle\""
wait stable
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
screenshot "${OUT}/11-add-host-relay-input.png"
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
press "id=\"host-confirmation-cancel\""
wait stable
wait "id=\"direct-host-submit\" enabled=true" 10000
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
wait stable
press "id=\"direct-host-submit\""
wait stable
wait "id=\"host-confirmation-connect\"" 10000
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
press "id=\"host-confirmation-connect\""
wait stable
wait "id=\"direct-password-input\"" 20000
screenshot "${OUT}/15-add-host-password.png"
fill "id=\"direct-password-input\"" "${PASSWORD}"
wait stable
press "id=\"direct-host-submit\""
wait stable
open "${APP_ID}" "paseo://settings"
wait "label=\"Connections\"" 10000
press "label=\"Connections\""
wait stable
wait "id=\"host-page-connections-card\"" 30000
screenshot "${OUT}/16-settings-connected.png"
@@ -0,0 +1,26 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
Attach only to your task's app, while no device replay is running:
```sh
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
```
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
This covers native container resizing and selection retention. It does not cover
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
menu. Those require the real chat journey: read an older multiline response,
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
retain expanded/collapsed details, and check a stationary reader's position.
Accessibility exposes full text even when glyphs are clipped, so a text wait
alone does not verify that journey.
@@ -0,0 +1,55 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
auto hostFrame = host.frame;
hostFrame.size.width = 500;
hostFrame.size.height = 200;
host.frame = hostFrame;
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
container.frame = host.bounds;
UITextView *text = nil;
for (UIView *child in host.subviews) {
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
for (UIView *nested in child.subviews) {
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
}
}
text.frame = container.bounds;
text.font = [UIFont systemFontOfSize:16];
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
auto range = text.selectedRange;
range.location = 12;
range.length = 9;
text.selectedRange = range;
auto frame = container.frame;
frame.size.width = 240;
container.frame = frame;
[container layoutIfNeeded];
CGFloat narrow = text.frame.size.width;
auto selection = text.selectedRange;
frame.size.width = 500;
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
echo "Expected a running PaseoDebug simulator app" >&2
exit 1
fi
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "command script import $probe_dir/ios.py" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -47,7 +47,7 @@ async function openAddProjectFlowSurface(
page: Page,
expectedPage: "host" | "method",
): Promise<void> {
await page.getByTestId("sidebar-add-project").click();
await page.locator('[data-testid="sidebar-add-project"]:visible').click();
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
await expectAddProjectPage(page, expectedPage);
}
@@ -22,7 +22,7 @@ type AgentSkillsClient = Pick<
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
>;
export type SkillTargetName = "agents" | "claude" | "codex";
export type SkillTargetName = "agents" | "claude";
export interface AgentSkillsSandbox {
available: string[];
@@ -30,6 +30,7 @@ export interface AgentSkillsSandbox {
daemon: IsolatedHostDaemon;
home: string;
targets: Record<SkillTargetName, string>;
codexSkillsDir: string;
blockAgentsDirectory(): Promise<void>;
unblockAgentsDirectory(): Promise<void>;
close(): Promise<void>;
@@ -63,8 +64,8 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
const targets = {
agents: path.join(home, ".agents", "skills"),
claude: path.join(home, ".claude", "skills"),
codex: path.join(home, ".codex", "skills"),
};
const codexSkillsDir = path.join(home, ".codex", "skills");
return {
available,
@@ -72,6 +73,7 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
daemon,
home,
targets,
codexSkillsDir,
blockAgentsDirectory: async () => {
await mkdir(path.dirname(targets.agents), { recursive: true });
await writeFile(targets.agents, "not a directory", "utf8");
@@ -196,6 +198,7 @@ export async function expectInstalledSkills(
for (const directory of Object.values(sandbox.targets)) {
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
}
await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]);
}
export async function expectSavedSelection(
@@ -0,0 +1,63 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function contentWidthInput(page: Page): Locator {
return page.getByLabel("Content width in pixels", { exact: true });
}
function resetContentWidthButton(page: Page): Locator {
return page.getByRole("button", { name: "Reset content width to default", exact: true });
}
export async function openAppearanceSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
}
export async function setContentWidth(page: Page, width: number): Promise<void> {
const input = contentWidthInput(page);
await input.fill(String(width));
await input.press("Tab");
}
export async function resetContentWidth(page: Page): Promise<void> {
await resetContentWidthButton(page).click();
}
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toHaveCount(0);
await expect.poll(() => readStoredContentWidth(page)).toBeNull();
}
export async function expectCustomContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toBeVisible();
await expect.poll(() => readStoredContentWidth(page)).toBe(width);
}
/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
export async function expectContentColumnWidth(
element: Locator,
bounds: { min?: number; max: number },
): Promise<void> {
await expect(element).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeLessThanOrEqual(bounds.max);
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeGreaterThan(bounds.min ?? 0);
}
async function readStoredContentWidth(page: Page): Promise<unknown> {
return page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw === null
? undefined
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
}, APP_SETTINGS_KEY);
}
@@ -0,0 +1,166 @@
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expectComposerVisible } from "./composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports";
import { claudeAndCodexReports } from "./usage-sidebar-item";
// 32,000 of the mock's 128,000-token window.
const METER_NAME = "Context window 25% used";
export type MockAgentSession = Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
/** An agent a quarter into its context window. */
export function seedAgentWithContextWindow(): Promise<MockAgentSession> {
return seedMockAgentWorkspace({
repoPrefix: "context-window-usage-",
title: "Context window usage e2e",
initialPrompt: "emit 32000 byte file agent stream payload",
});
}
export async function openAgent(page: Page, session: MockAgentSession): Promise<void> {
await openAgentRoute(page, session);
await expectComposerVisible(page);
}
export async function reloadAgent(page: Page): Promise<void> {
await page.reload({ waitUntil: "commit" });
await expectComposerVisible(page);
}
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
await expect(tooltip).toBeVisible();
return tooltip;
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
}
/** Compact screens open the meter's details in a sheet. */
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
const sheet = contextWindowSheet(page);
await expect(sheet).toBeVisible();
return sheet;
}
export async function closeContextWindowSheet(page: Page): Promise<void> {
const sheet = contextWindowSheet(page);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export function contextWindowSheet(page: Page): Locator {
return page.getByRole("dialog", { name: "Context window" });
}
/** One report card; cards have no role of their own. */
export function usageCard(surface: Locator, reportId: string): Locator {
return surface.getByTestId(`usage-report-${reportId}`);
}
export async function refreshUsageCard(surface: Locator, source: string): Promise<void> {
await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click();
}
export async function expectRefreshButtons(surface: Locator, count: number): Promise<void> {
await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count);
}
/** A report on the agent's own login, which is not the host's default one. */
export function onWorkLogin(report: UsageReportEntry): UsageReportEntry {
return onLogin(report, "work");
}
/** A report on the login an agent resumed under. */
export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry {
return onLogin(report, "personal");
}
function onLogin(report: UsageReportEntry, login: string): UsageReportEntry {
return {
...report,
id: `${report.sourceId}:${login}`,
account: { label: `${login}@example.com` },
};
}
export function expiredLogin(report: UsageReportEntry): UsageReportEntry {
return {
...report,
report: {
status: "unavailable",
problem: {
kind: "expired",
expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
refreshedBy: "claude",
},
},
};
}
/** Holds a stream at a point until the test opens it, like a slow source. */
export function gate(): { promise: Promise<void>; open: () => void } {
let open!: () => void;
const promise = new Promise<void>((resolve) => {
open = resolve;
});
return { promise, open };
}
export interface AgentUsageScript {
/** The answer to the next agent usage request or report Refresh. */
answerNext(response: UsageListResponse): void;
/** From the next reload the host no longer reports usage. */
stopReportingUsage(): void;
/** The agent of each agent usage request, in order. */
agentRequests(): string[];
/** Each report Refresh, by report ID. */
refreshedReports(): string[][];
}
/**
* Scripts the host's usage: the host's own list is Claude and Codex on its default login; each
* agent request and each Refresh takes the next scripted answer.
*/
export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
let supported = true;
const answers: UsageListResponse[] = [];
const usage = await installUsageReportsFixture(page, {
usageSupported: () => supported,
lists: [
(request) => {
if (!request.agentId && !request.reportIds) return claudeAndCodexReports();
const next = answers.shift();
if (!next) throw new Error("The test scripts every agent usage request.");
return next;
},
],
});
return {
answerNext: (response) => answers.push(response),
stopReportingUsage: () => {
supported = false;
},
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
refreshedReports: () =>
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
};
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
export async function qaScreenshot(page: Page, name: string): Promise<void> {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let the sheet and tooltip animations settle.
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
@@ -35,18 +35,14 @@ export default function contribute(client) {
async function openExplorerMenu(page: Page) {
const explorer = await ensureExplorerSidebar(page);
// The rail's empty background has no accessible role; tab menus are separate.
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
button: "right",
position: { x: 20, y: 2 },
});
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu).toHaveCSS("opacity", "1");
return menu;
}
async function toggleExplorerView(page: Page, name: string) {
async function launchExplorerView(page: Page, name: string | RegExp) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name, exact: true }).click();
await expect(menu).not.toBeVisible();
@@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins(
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
const launcher = page.getByTestId("workspace-explorer-sidebar");
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount(
0,
);
await page.keyboard.press("Escape");
}
export async function openWorkspacePanelFromExplorerMenu(
@@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu(
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
contentType: "image/png",
});
await expect(menu.getByRole("menuitem")).toHaveText([
"New tab",
"Changes",
"Files",
"Other review",
"Other review summary",
"Review",
"Review summary",
]);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible();
await expect(
menu.getByRole("menuitem", { name: "Other review summary", exact: true }),
).toBeVisible();
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
await expect(
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
}
export async function expectIndependentExplorerPanelToggles(
export async function expectIndependentExplorerPanelTabs(
page: Page,
workspace: ExplorerPanelWorkspace,
) {
await toggleExplorerView(page, "Other review");
await launchExplorerView(page, "Other review");
await expect(
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await toggleExplorerView(page, "Review summary");
await launchExplorerView(page, "Review summary");
const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
await tabs
.getByRole("button", { name: "Review", exact: true })
.click({ button: "right", position: { x: 12, y: 13 } });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0);
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true });
await expect(files).toHaveCount(1);
const menu = await openExplorerMenu(page);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
const updated = await openExplorerMenu(page);
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await expect(
updated.getByRole("menuitem", { name: "Other review", exact: true }),
).toHaveAttribute("aria-checked", "true");
await expect(
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
).toHaveAttribute("aria-checked", "true");
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await files.click({ button: "right", position: { x: 12, y: 13 } });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect(files).toHaveCount(0);
await launchExplorerView(page, /^Files/);
await expect(files).toHaveCount(1);
const restoredMenu = await openExplorerMenu(page);
await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await page.keyboard.press("Escape");
await toggleExplorerView(page, "Files");
await toggleExplorerView(page, "Files");
await expect(
page
.getByTestId("explorer-sidebar-tab-rail")
.getByRole("button", { name: "Browse workspace files", exact: true }),
).toHaveCount(1);
}
export async function expectExplorerPanelWithFocusedAgent(
@@ -0,0 +1,95 @@
import { expect, type Locator, type Page } from "@playwright/test";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
export interface LinkedHost {
serverId: string;
relayEndpoint: string;
daemonPublicKeyB64: string;
}
/**
* A pairing link that opens on the settings page. `/` redirects to
* `/open-project` on its own, so starting in settings leaves the pairing link
* as the only thing that can move the app to `/open-project`.
*/
export function buildPairingLink(host: LinkedHost): string {
const offer = {
v: 2,
serverId: host.serverId,
daemonPublicKeyB64: host.daemonPublicKeyB64,
relay: { endpoint: host.relayEndpoint, useTls: false },
};
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
return `/settings/general#offer=${encoded}`;
}
/** Loads the app from a pairing link, the way opening the link in a browser does. */
export async function openPairingLink(page: Page, link: string): Promise<void> {
// Leave the link's page first so the link always loads the app from scratch.
await page.goto("/");
await page.goto(link);
}
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
await page.goto("/");
const nowIso = new Date().toISOString();
const connectionId = `relay:${host.relayEndpoint}`;
const savedHost = {
serverId: host.serverId,
label: host.serverId,
connections: [
{
id: connectionId,
type: "relay",
relayEndpoint: host.relayEndpoint,
useTls: false,
daemonPublicKeyB64: host.daemonPublicKeyB64,
},
],
preferredConnectionId: connectionId,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
key: EXTRA_HOSTS_KEY,
hosts: [savedHost],
});
}
export function hostConfirmation(page: Page): Locator {
return page.getByTestId("host-confirmation");
}
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
const sheet = hostConfirmation(page);
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
}
export async function cancelHostConfirmation(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
export async function connectToConfirmedHost(page: Page): Promise<void> {
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
await expect(hostConfirmation(page)).toHaveCount(0);
}
/** Checks the host picker on the settings page the app is already showing. */
export async function expectHostInHostPicker(
page: Page,
serverId: string,
expected: "listed" | "not listed",
): Promise<void> {
await page.getByTestId("settings-host-picker").click();
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
expected === "listed" ? 1 : 0,
);
await page.keyboard.press("Escape");
}
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { openCommandCenter } from "./command-center";
import { openGlobalNewWorkspaceComposer } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
@@ -18,16 +19,24 @@ export class ImportSessionFlow {
timeout: 30_000,
});
}
async revealMobileEntryPoint() {
await openMobileAgentSidebar(this.page);
await expectMobileAgentSidebarVisible(this.page);
const button = this.page.getByTestId("sidebar-import-session");
/** The new workspace screen's Import session button, reached from the sidebar. */
async revealNewWorkspaceEntryPoint() {
const compact = (this.page.viewportSize()?.width ?? 0) < 768;
if (compact) {
await openMobileAgentSidebar(this.page);
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session");
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
async openGlobally() {
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await this.page.getByTestId("sidebar-import-session").click();
await this.revealNewWorkspaceEntryPoint();
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -108,8 +117,12 @@ export class ImportSessionFlow {
});
}
async expectTranscript(userText: string, assistantText: string) {
await expect(this.page.getByTestId("user-message")).toContainText(userText);
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText(
userText,
);
await expect(
this.page.getByTestId("assistant-message").filter({ visible: true }),
).toContainText(assistantText);
}
async showAll() {
await this.page.getByTestId("import-session-show-all").click();
@@ -120,7 +133,9 @@ export class ImportSessionFlow {
timeout: 30_000,
});
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
await expect(workspace.getByTestId("user-message")).toContainText(userText);
await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText(
userText,
);
}
async close() {
await this.page.keyboard.press("Escape");
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
}
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").click();
// The shell keeps a hidden copy of the other layout's sidebar mounted.
await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click();
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
timeout: 30_000,
@@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) {
React.useEffect(() => {
const owner = paseo.observeEvents(["project.update"]);
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
// Deliberately leave this observation to the mounted host scope.
return () => { void owner.release(); };
}, [paseo]);
const rpc = useRpc(summary);
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -0,0 +1,268 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
export const BOTS_PLUGIN_ID = "sidebar-bots";
export const WIDE = { width: 1440, height: 900 };
export const COMPACT = { width: 390, height: 844 };
/** A plugin written against the current API: a screen, header and footer items, a command. */
const SHOWCASE_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
function DeploysScreen({ theme }) {
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Deploys screen body</Text></View>;
}
function RefreshButton({ theme, onPress }) {
return (
<Pressable accessibilityRole="button" accessibilityLabel="Refresh deploys" onPress={onPress} style={{ paddingHorizontal: 6 }}>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Refresh</Text>
</Pressable>
);
}
function DeploysItem({ theme, currentScreen, openScreen }) {
const [refreshes, setRefreshes] = React.useState(0);
return (
<SidebarRow
icon="Rocket"
label={refreshes === 0 ? undefined : "Deploys (refreshed " + refreshes + ")"}
active={currentScreen?.screenId === "deploys"}
onPress={() => openScreen({ screenId: "deploys" })}
trailing={<RefreshButton theme={theme} onPress={() => setRefreshes((count) => count + 1)} />}
/>
);
}
function SyncDetails({ theme, layout, close, openScreen }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>Sync details</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Open deploys from popover" onPress={() => openScreen({ screenId: "deploys" })}><Text style={{ color: theme.colors.foreground }}>Open deploys</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Close sync details" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
}
function SyncItem({ openPopover }) {
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
}
function Broken() {
throw new Error("Sidebar item exploded");
}
export default function contribute(client) {
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
return () => {};
}
`;
/**
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
* id on `globalThis.__botAlertsObservation`.
*/
const BOTS_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { usePaseo } from "@getpaseo/plugin/client";
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
const BOTS = [
{ id: "bot-1", name: "Bot 1" },
{ id: "bot-2", name: "Bot 2" },
];
function BotScreen({ theme, params }) {
return (
<View style={{ flex: 1, padding: 24, gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>{"Bot screen: " + params.botId}</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>{"serverId param: " + (params.serverId ?? "none")}</Text>
</View>
);
}
function botTitle(params) {
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
}
const botMenus = {};
function botMenu(bot) {
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>{bot.name + " options"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel={"Close " + bot.name + " options"} onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
};
return botMenus[bot.id];
}
function MoreButton({ theme, bot, onPress }) {
return (
<Pressable accessibilityRole="button" accessibilityLabel={"More for " + bot.name} onPress={onPress} style={{ paddingHorizontal: 6 }}>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>More</Text>
</Pressable>
);
}
function BotStatus({ theme, close }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>Bot status details</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Close bot status" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
}
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
return (
<>
{BOTS.map((bot) => (
<SidebarRow
key={bot.id}
id={bot.id}
icon="Bot"
label={bot.name}
active={currentScreen?.screenId === "bot" && currentScreen.params.botId === bot.id}
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
trailing={<MoreButton theme={theme} bot={bot} onPress={() => openPopover(botMenu(bot))} />}
/>
))}
<SidebarSeparator />
<SidebarRow id="status" icon="Activity" label="Bot status" onPress={() => openPopover(BotStatus)} />
</>
);
}
function AlertsItem() {
const paseo = usePaseo();
React.useEffect(() => {
const observation = paseo.observeEvents(["project.update"]);
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
return () => { void observation.release(); };
}, [paseo]);
return <SidebarRow icon="Bell" onPress={() => {}} />;
}
export default function contribute(client) {
let removeAlerts = null;
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
} });
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
removeAlerts?.();
removeAlerts = null;
} });
return () => {};
}
`;
/** A plugin that still uses only the old descriptor calls. */
const LEGACY_SOURCE = `import React from "react";
import { Text, View } from "react-native";
function Main({ theme }) {
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Legacy screen body</Text></View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Main);
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
return () => {};
}
`;
export async function installSidebarPlugins() {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directories: string[] = [];
for (const [id, source] of [
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
[BOTS_PLUGIN_ID, BOTS_SOURCE],
] as const) {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
}
await client.patchDaemonConfig({ pluginsEnabled: true });
for (const directory of directories) await client.installDirectoryPlugin(directory);
return {
async cleanup() {
await client.removePlugin(SHOWCASE_PLUGIN_ID);
await client.removePlugin(LEGACY_PLUGIN_ID);
await client.removePlugin(BOTS_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await Promise.all(
directories.map((directory) => rm(directory, { recursive: true, force: true })),
);
},
};
}
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
export function visibleTestId(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
}
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
}
export async function expectRowActive(row: Locator, active: boolean): Promise<void> {
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
else await expect(row).not.toHaveAttribute("aria-selected", "true");
}
export async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
export async function closeScreen(page: Page): Promise<void> {
await page.getByTestId("plugin-surface-close").click();
await expect(page).not.toHaveURL(/\/plugin\//);
}
/** Box of the popover body, located from the text it renders. */
export async function popoverBox(page: Page, text = "Sync details") {
const details = page.getByText(text, { exact: true });
await expect(details).toBeInViewport();
const box = await details.boundingBox();
expect(box).not.toBeNull();
return box!;
}
@@ -1,3 +1,4 @@
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
@@ -55,10 +56,25 @@ export async function seedSidebarNavPreferences(
);
}
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
export async function seedSidebarFooterPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "sidebar");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
}
export async function leaveSettings(page: Page): Promise<void> {
@@ -125,9 +141,9 @@ export async function expectStoredSidebarNav(
.toEqual(expected);
}
async function expectVerticalOrder(
keys: SidebarNavKey[],
locate: (key: SidebarNavKey) => Locator,
async function expectVerticalOrder<Key extends string>(
keys: Key[],
locate: (key: Key) => Locator,
subject: string,
): Promise<void> {
await expect
@@ -136,11 +152,7 @@ async function expectVerticalOrder(
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
if (
!measured.every(
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
)
)
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
@@ -148,3 +160,138 @@ async function expectVerticalOrder(
)
.toEqual(keys);
}
/** Persisted footer row key -> the testID the app shell renders that row with. */
function shellFooterTestID(key: string): string {
if (key === "usage") return "sidebar-usage";
const [, pluginId, itemId] = key.split(":");
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
}
function shellFooterRow(page: Page, key: string): Locator {
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
}
function footerSettingsRows(page: Page): Locator {
return page
.getByTestId("sidebar-nav-section-footer")
.locator('[data-testid^="sidebar-nav-item-"]');
}
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
await expect
.poll(async () =>
(
await footerSettingsRows(page).evaluateAll((rows) =>
rows.map((row) => row.getAttribute("data-testid")),
)
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
)
.toEqual(keys);
}
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
await page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-move-up-${key}`)
.click();
}
function footerItemSwitch(page: Page, key: string): Locator {
return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`);
}
export async function setFooterItemVisible(
page: Page,
key: string,
visible: boolean,
): Promise<void> {
const toggle = footerItemSwitch(page, key);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
/** Whether Settings > Sidebar shows a footer item as on. */
export async function expectFooterItemSetting(
page: Page,
key: string,
visible: boolean,
): Promise<void> {
await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible));
}
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
}
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
}
const FOOTER_ICON_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const FOOTER_GEOMETRY_TOLERANCE = 0.01;
/**
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
expect(Math.abs(box.width - first!.width)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
}
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
for (const index of [0, 1, 3]) {
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
}
expect(gaps[2]).toBeGreaterThan(first!.width);
}
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS(
"border-top-width",
"1px",
);
await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS(
"border-top-width",
"0px",
);
const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible');
await expect(separator).toHaveCount(shown ? 1 : 0);
expect(
await separator.evaluateAll((elements) =>
elements.map((element) => getComputedStyle(element).borderBottomWidth),
),
).toEqual(shown ? ["1px"] : []);
}
export async function hoverFooterAddProject(page: Page): Promise<void> {
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
const tooltip = page.getByTestId("sidebar-add-project-tooltip");
await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible();
await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible();
}
export async function footerScreenshot(page: Page, name: string): Promise<void> {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
@@ -163,7 +163,6 @@ class StartupAssertions {
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
@@ -0,0 +1,537 @@
import { writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
export const scrollCadences = [
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
{ name: "medium", delta: 480, intervalMs: 32, steps: 400 },
{ name: "fast", delta: 1200, intervalMs: 16, steps: 180 },
] as const;
type Cadence = (typeof scrollCadences)[number];
interface RowFrame {
id: string;
top: number;
height: number;
}
export interface ScrollFrame {
at: number;
scrollTop: number;
scrollHeight: number;
viewportHeight: number;
virtualized: boolean;
loading: boolean;
rows: RowFrame[];
anchor: string | null;
wheelTotal: number;
lastWheelAt: number;
inputFinishedAt: number | null;
imageLoads: number;
mounted: number;
unmounted: number;
}
interface Recording {
frames: ScrollFrame[];
finishInput(): void;
stop(): void;
}
interface TimelinePage {
direction: string;
count: number;
hasOlder: boolean;
at: number;
}
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
export async function withVariedTimeline(
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
): Promise<void> {
const timers = new Set<ReturnType<typeof setTimeout>>();
const server = createServer((request, response) => {
const portrait = request.url?.includes("portrait");
const timer = setTimeout(
() => {
timers.delete(timer);
response.writeHead(200, {
"Content-Type": "image/svg+xml",
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
});
response.end(
`<svg xmlns="http://www.w3.org/2000/svg" width="${portrait ? 320 : 960}" height="${portrait ? 640 : 240}"><rect width="100%" height="100%" fill="${portrait ? "#936" : "#369"}"/><text x="20" y="50" fill="white" font-size="24">Delayed ${portrait ? "portrait" : "landscape"}</text></svg>`,
);
},
portrait ? 900 : 350,
);
timers.add(timer);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Image server did not listen");
const imageUrl = `http://127.0.0.1:${address.port}`;
let agent: MockAgentWorkspace | undefined;
try {
agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-scroll-smoothness-",
title: "Varied timeline scroll reproduction",
featureValues: {
mockAssistantResponses: Array.from({ length: 70 }, (_, turn) =>
[
`## Review of turn ${turn}`,
"A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.",
"| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |",
`![Delayed landscape](${imageUrl}/${turn}/landscape.svg)`,
"```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```",
"> Keep reading while content above the viewport changes.",
"- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item",
`![Delayed portrait](${imageUrl}/${turn}/portrait.svg)`,
"Review complete. The next prompt starts a separate turn.",
].join("\n\n"),
),
},
});
for (let turn = 0; turn < 70; turn += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await run(
agent,
`Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`,
);
} finally {
await agent?.cleanup();
for (const timer of timers) clearTimeout(timer);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] {
const pages: TimelinePage[] = [];
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
const envelope = JSON.parse(String(payload));
const message = envelope.message;
if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId)
return;
pages.push({
direction: message.payload.direction,
count: message.payload.entries.length,
hasOlder: message.payload.hasOlder,
at: Date.now(),
});
});
});
return pages;
}
export async function openOnlyTimelineTail(
page: Page,
agent: MockAgentWorkspace,
newestPrompt: string,
pages: TimelinePage[],
): Promise<void> {
await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true));
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, newestPrompt.trim());
await expect
.poll(() => pages.filter((entry) => entry.direction === "tail").length)
.toBeGreaterThanOrEqual(1);
expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true });
expect(pages[0].count).toBe(40);
expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1);
expect(
pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0),
).toBe(true);
await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0);
// Start each cadence after the tail's initial image loads and bottom anchoring.
await page.waitForTimeout(1500);
expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0);
}
export async function recordUpwardTraversal(
page: Page,
cadence: Cadence,
testInfo: TestInfo,
): Promise<ScrollFrame[]> {
const stopTrace = await traceScrollIfRequested(page, testInfo, cadence);
const timeline = page.getByTestId("agent-chat-scroll");
await timeline.hover();
await timeline.evaluate((viewport) => {
const scroll = viewport as HTMLElement;
const frames: ScrollFrame[] = [];
let frame = 0;
let wheelTotal = 0;
let lastWheelAt = -Infinity;
let inputFinishedAt: number | null = null;
let imageLoads = 0;
let mounted = scroll.querySelectorAll("[data-history-row-id]").length;
let unmounted = 0;
const collectRows = (node: Node, rows: Set<Element>) => {
if (!(node instanceof Element)) return;
if (node.matches("[data-history-row-id]")) rows.add(node);
for (const row of node.querySelectorAll("[data-history-row-id]")) rows.add(row);
};
const recordMutations = (records: MutationRecord[]) => {
// A newly inserted container and its children can both appear in one
// delivery. Count each DOM row once, including rows removed before paint.
const added = new Set<Element>();
const removed = new Set<Element>();
for (const record of records) {
for (const node of record.addedNodes) collectRows(node, added);
for (const node of record.removedNodes) collectRows(node, removed);
}
mounted += added.size;
unmounted += removed.size;
};
const mutations = new MutationObserver(recordMutations);
mutations.observe(scroll, { childList: true, subtree: true });
const wheel = (event: WheelEvent) => {
wheelTotal += -event.deltaY;
lastWheelAt = performance.now();
};
const imageLoad = (event: Event) => {
if (event.target instanceof HTMLImageElement) imageLoads += 1;
};
scroll.addEventListener("wheel", wheel, { passive: true });
scroll.addEventListener("load", imageLoad, true);
const observed = new Set<Element>();
const capture = (replaceLast: boolean) => {
const rect = scroll.getBoundingClientRect();
const elements = Array.from(scroll.querySelectorAll<HTMLElement>("[data-history-row-id]"));
const current = new Set<Element>([scroll, ...elements]);
if (scroll.firstElementChild) current.add(scroll.firstElementChild);
for (const element of observed) {
if (!current.has(element)) {
resizeObserver.unobserve(element);
observed.delete(element);
}
}
for (const element of current) {
if (!observed.has(element)) {
resizeObserver.observe(element);
observed.add(element);
}
}
const rows = elements.map((row) => {
const box = row.getBoundingClientRect();
return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height };
});
// Include rows mounted and removed in the same commit, before sampling.
recordMutations(mutations.takeRecords());
// Preserve the row crossing the reading line. Rows below an expanding
// visible image legitimately move even when the reading position is stable.
const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8);
const snapshot: ScrollFrame = {
at: performance.now(),
scrollTop: scroll.scrollTop,
scrollHeight: scroll.scrollHeight,
viewportHeight: rect.height,
rows,
anchor: anchor?.id ?? null,
virtualized: scroll.id.includes("virtualized"),
loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'),
wheelTotal,
lastWheelAt,
inputFinishedAt,
imageLoads,
mounted,
unmounted,
};
if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot;
else frames.push(snapshot);
};
// rAF precedes ResizeObserver. Refresh that frame after resize delivery so
// it represents the final layout, not geometry repaired before the paint.
const resizeObserver = new ResizeObserver(() => capture(true));
const sample = () => {
capture(false);
frame = requestAnimationFrame(sample);
};
const recording: Recording = {
frames,
finishInput() {
inputFinishedAt = performance.now();
},
stop() {
cancelAnimationFrame(frame);
resizeObserver.disconnect();
mutations.disconnect();
scroll.removeEventListener("wheel", wheel);
scroll.removeEventListener("load", imageLoad, true);
},
};
Reflect.set(window, "__timelineScrollRecording", recording);
const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__");
if (typeof reset === "function") reset();
sample();
});
// No settling between inputs: exercise wheel input overlapping prepend and measurement.
for (let step = 0; step < cadence.steps; step += 1) {
await page.mouse.wheel(0, -cadence.delta);
await page.waitForTimeout(cadence.intervalMs);
}
await page.evaluate(() => {
(Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput();
});
await page.waitForTimeout(1500);
await stopTrace();
return page.evaluate(() => {
const recording = Reflect.get(window, "__timelineScrollRecording") as Recording;
recording.stop();
return recording.frames;
});
}
export function findScrollJumps(frames: ScrollFrame[]) {
return frames.flatMap((current, index) => {
const previous = frames[index - 1];
if (!previous?.anchor) return [];
// Wheel input can move the reading line onto an image before it expands.
// Follow that intended row, not text now below the image.
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
const readingLine = 8 - availableScroll;
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
const anchor = previous.anchor;
const before = currentRows.has(anchor)
? previous.rows.find((row) => row.id === anchor)!
: previous.rows
.filter((row) => currentRows.has(row.id))
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
if (!before) {
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
}
const after = currentRows.get(before.id)!;
const movement = after.top - before.top;
// A busy main thread can deliver wheel movement hundreds of milliseconds
// after its event. Only assert idle stability after the driver stops input.
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
// Wheel events can precede their scroll update, or include input already
// applied in earlier frames. Locate the reader with this frame's scroll,
// bounded by recent input. Growth below it can move the old anchor without
// moving the reading line.
const readerFollowsInput =
intendedRow &&
currentRows.has(intendedRow.id) &&
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
const enteredRowGrowth = readerFollowsInput
? previous.rows.reduce((growth, row) => {
if (row.top < intendedRow.top || row.top >= before.top) return growth;
const height = currentRows.get(row.id)?.height ?? row.height;
return growth + Math.max(0, height - row.height);
}, 0)
: 0;
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
// Upward wheel input moves the same text DOWN the viewport. A negative move
// is a reversal, independent of legitimate scrollTop compensation on prepend.
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
return [
{
frame: index,
at: current.at,
row: before.id,
movement,
idle,
excessForward,
resizedRows: current.rows.flatMap((row) => {
const old = previous.rows.find((candidate) => candidate.id === row.id);
return old && old.height !== row.height
? [{ id: row.id, before: old.height, after: row.height }]
: [];
}),
loading: current.loading || previous.loading,
virtualized: current.virtualized,
heightChange: current.scrollHeight - previous.scrollHeight,
imageLoads: current.imageLoads - previous.imageLoads,
frameGapMs: current.at - previous.at,
},
];
});
}
export async function reportScrollJumps(
page: Page,
testInfo: TestInfo,
frames: ScrollFrame[],
pages: TimelinePage[],
): Promise<void> {
const jumps = findScrollJumps(frames);
const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []);
const gaps = frames
.slice(1)
.map((frame, index) => frame.at - frames[index].at)
.sort((a, b) => a - b);
const summary = {
browserTimeOrigin: await page.evaluate(() => performance.timeOrigin),
frames: frames.length,
pages,
jumpCount: jumps.length,
worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)),
frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)],
frameGapMaxMs: gaps.at(-1),
imageLoads: frames[frames.length - 1].imageLoads,
mounted: frames[frames.length - 1].mounted,
unmounted: frames[frames.length - 1].unmounted,
jumps,
};
await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2));
await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames));
await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react));
await testInfo.attach("scroll-summary", {
body: JSON.stringify(summary, null, 2),
contentType: "application/json",
});
await testInfo.attach("scroll-frames", {
body: JSON.stringify(frames),
contentType: "application/json",
});
await testInfo.attach("scroll-react-profile", {
body: JSON.stringify(react),
contentType: "application/json",
});
await attachTimelineScreenshot(page, testInfo, "scroll-end");
console.log(
`[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`,
);
expect(
pages.filter((entry) => entry.direction === "before").length,
"must exercise multiple older pages",
).toBeGreaterThanOrEqual(2);
expect(
frames.some((frame) => frame.virtualized),
"must exercise virtualization",
).toBe(true);
expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0);
expect(
summary.mounted - summary.unmounted,
"row counters must balance against the final DOM",
).toBe(frames[frames.length - 1].rows.length);
const uniqueRows = new Set(frames.flatMap((frame) => frame.rows.map((row) => row.id))).size;
expect(
summary.mounted,
"prepending must not mount a discarded range before correcting the viewport",
).toBeLessThanOrEqual(uniqueRows * 1.25);
expect(
jumps,
"visible text must not reverse direction while scrolling upward or move after input stops",
).toEqual([]);
}
async function traceScrollIfRequested(
page: Page,
testInfo: TestInfo,
cadence: Cadence,
): Promise<() => Promise<void>> {
if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {};
const cdp = await page.context().newCDPSession(page);
await cdp.send("Tracing.start", {
categories:
"devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler",
transferMode: "ReturnAsStream",
});
return async () => {
const completed = new Promise<string>((resolve) => {
cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!));
});
await cdp.send("Tracing.end");
const stream = await completed;
const chunks: string[] = [];
for (;;) {
const chunk = await cdp.send("IO.read", { handle: stream });
chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data);
if (chunk.eof) break;
}
await cdp.send("IO.close", { handle: stream });
await cdp.detach();
const path = testInfo.outputPath("scroll-chromium-trace.json");
await writeFile(path, chunks.join(""));
await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" });
};
}
/** Hold real image bytes until the reader has reached its placeholder. */
export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo): Promise<void> {
let released = false;
const pending = new Set<() => void>();
const server = createServer((_request, response) => {
const send = () => {
response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" });
response.end(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#369"/></svg>',
);
};
if (released) send();
else pending.add(send);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Image server did not listen");
const agent = await seedMockAgentWorkspace({
repoPrefix: "reserved-timeline-image-",
title: "Reserved image geometry",
featureValues: {
mockAssistantResponses: [
`Before the image.\n\n![Reserved image](http://127.0.0.1:${address.port}/image.svg)\n\nText below the image.`,
],
},
});
try {
await agent.client.sendAgentMessage(agent.agentId, "Show the delayed image");
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, "Show the delayed image");
const image = page.getByRole("img", { name: "Reserved image" }).first();
await expect(image).toBeVisible();
const before = await image.boundingBox();
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
released = true;
for (const send of pending) send();
await expect
.poll(() =>
image.evaluate((element) => {
const img =
element instanceof HTMLImageElement ? element : element.querySelector("img");
return img?.naturalWidth;
}),
)
.toBe(240);
await expect
.poll(async () => {
const box = await image.boundingBox();
return box ? Math.round((box.width / box.height) * 100) : 0;
})
.toBe(150);
const after = await image.boundingBox();
await attachTimelineScreenshot(page, testInfo, "image-loaded");
expect(before?.height, "reserve the final image height before the HTTP response").toBe(
after?.height,
);
} finally {
await agent.cleanup();
}
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
async function attachTimelineScreenshot(
page: Page,
testInfo: TestInfo,
name: string,
): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.getByTestId("agent-chat-scroll").screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
@@ -0,0 +1,134 @@
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { UsageReport } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { expectPinnedUsage, pinRow, usageItem } from "./usage-sidebar-item";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
/** A real store-backed plugin on the worker's isolated daemon. */
export async function installLoginUsage(initialReport: UsageReport) {
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const store = path.join(directory, "report.json");
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
const cleanup = async () => {
try {
await client.removePlugin("login-journey");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
} finally {
await client.close();
await rm(directory, { recursive: true, force: true });
}
};
try {
await setReport(initialReport);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "login-journey", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
import {readFile} from "node:fs/promises";
import {z} from "zod";
export default function contribute(server) {
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
discover: async () => [{key: "account", input: {}}],
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
});
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
return { setReport, cleanup };
} catch (error) {
await cleanup();
throw error;
}
}
export async function openUsage(page: Page) {
await page.goto("/usage");
}
export async function refreshLoginUsage(page: Page) {
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
}
export async function hoverUsageWindow(page: Page, window: string) {
await page
.getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) })
.getByTestId(/usage-pin-glyph-/)
.hover();
}
/** Exercises the built-in Codex adapter through the real plugin loader and daemon transport. */
export async function installCodexWindowUsage(payload: unknown) {
const directory = await mkdtemp(path.join(tmpdir(), "usage-codex-windows-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const cleanup = async () => {
try {
await client.removePlugin("codex-window-qa");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
} finally {
await client.close();
await rm(directory, { recursive: true, force: true });
}
};
try {
const source = path.resolve(__dirname, "../../../../../plugins/codex-usage-source");
await cp(path.join(source, "server"), path.join(directory, "server"), { recursive: true });
await cp(path.join(source, "shared"), path.join(directory, "shared"), { recursive: true });
await writeFile(
path.join(directory, "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-codex" } }),
);
await writeFile(path.join(directory, "payload.json"), JSON.stringify(payload));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "codex-window-qa", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
import {readFile} from "node:fs/promises";
import {fetchUsage, discover} from "./server/usage.js";
import {inputSchema} from "./shared/input.js";
export default function contribute(server) {
server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema,
discover: (scope) => discover(scope, {home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))),
});
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
return { cleanup };
} catch (error) {
await cleanup();
throw error;
}
}
export async function expectCodexReportedWindows(page: Page, shape: "seven-day-only" | "Spark") {
await expectPinnedUsage(page, ["11% wk"]);
await expect(pinRow(page.locator("body"), "Codex", "Weekly")).toBeChecked();
await expect(page.getByText("Session", { exact: true })).toHaveCount(0);
if (shape === "Spark") {
const fiveHour = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · 5-hour");
const weekly = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · Weekly");
await expect(fiveHour).not.toBeChecked();
await expect(weekly).not.toBeChecked();
await fiveHour.click();
await expect(fiveHour).toBeChecked();
await expect(usageItem(page)).toHaveAccessibleName(
/Codex Weekly 11% used, Codex GPT-5.3-Codex-Spark · 5-hour 0% used/,
);
}
}
+121 -57
View File
@@ -1,27 +1,44 @@
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
export interface UsageListRequest {
forceRefresh: boolean;
reportIds?: string[];
agentId?: string;
}
export interface UsageReportsFixture {
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
agentRequests(): Array<{ agentId: string }>;
listRequests(): UsageListRequest[];
waitForListRequests(count: number): Promise<void>;
waitForAgentRequests(count: number): Promise<void>;
}
interface UsageReportsFixtureOptions {
/**
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
* request; a function builds the response from the request when it arrives (e.g. a fresh
* `fetchedAt`, or a different answer to a forced refresh).
*/
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
/** Successive agent report IDs; the last one repeats. */
agentReportIds?: Array<string | null>;
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
usageSources?: boolean;
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
/**
* False simulates a host with no usage reporting capability. A function is read each time the
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
port?: number;
}
type UsageListResponse = UsageReportEntry[] | { error: string };
/**
* A list of reports, a request error, or a stream: reports sent in order, where a promise holds
* the rest of the stream and the response until it settles, like a slow source.
*/
export type UsageListResponse =
| UsageReportEntry[]
| { error: string }
| { stream: Array<UsageReportEntry | Promise<unknown>> };
type WebSocketMessage = string | Buffer;
@@ -42,7 +59,11 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return envelope.message as Record<string, unknown>;
}
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
@@ -61,7 +82,14 @@ function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): s
...envelope,
message: {
...envelope.message,
payload: { ...payload, features: { ...features, usageSources: enabled } },
payload: {
...payload,
features: {
...features,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
},
},
});
}
@@ -94,85 +122,121 @@ export async function installUsageReportsFixture(
page: Page,
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
const agentRequests: Array<{ agentId: string }> = [];
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const agentCounter = createCounter();
const usageSources = options.usageSources ?? true;
const usageSupported = options.usageSupported ?? true;
const isUsageSupported = () =>
typeof usageSupported === "function" ? usageSupported() : usageSupported;
const providerUsageListOnly = options.providerUsageListOnly ?? false;
const requestType = providerUsageListOnly
? "provider.usage.list.request"
: "usage.list_reports.request";
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const route =
options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`);
await page.routeWebSocket(route, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const request = getSessionMessage(message);
const requestId = request?.requestId;
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
listRequests.push({
if (request?.type === requestType && typeof requestId === "string") {
const listRequest: UsageListRequest = {
forceRefresh: request.forceRefresh === true,
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
});
agentId: typeof request.agentId === "string" ? request.agentId : undefined,
};
listRequests.push(listRequest);
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
const response = typeof scripted === "function" ? scripted() : scripted;
const response = typeof scripted === "function" ? scripted(listRequest) : scripted;
if ("error" in response) {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "usage.list_reports.request",
error: response.error,
code: "transport",
},
},
message: providerUsageListOnly
? {
type: "rpc_error",
payload: { requestId, requestType, error: response.error, code: "transport" },
}
: {
type: "usage.list_reports.response",
payload: { requestId, error: response.error },
},
}),
);
listCounter.increment();
return;
}
const sendUpdate = (report: UsageReportEntry) =>
ws.send(
JSON.stringify({
type: "session",
message: { type: "usage.list_reports.update", payload: { requestId, report } },
}),
);
if ("stream" in response) {
void (async () => {
for (const item of response.stream) {
if (item instanceof Promise) await item;
else sendUpdate(item);
}
ws.send(
JSON.stringify({
type: "session",
message: {
type: "usage.list_reports.response",
payload: { requestId, error: null },
},
}),
);
listCounter.increment();
})();
return;
}
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
ws.send(
JSON.stringify({
type: "session",
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
}),
);
if (!providerUsageListOnly) {
for (const report of reports) sendUpdate(report);
}
const reply = providerUsageListOnly
? {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: new Date().toISOString(),
providers: reports.map((entry) => ({
providerId: entry.sourceId,
displayName: entry.sourceLabel,
fetchedAt: entry.fetchedAt,
status: entry.report.status,
windows: entry.report.status === "available" ? entry.report.windows : [],
balances: entry.report.status === "available" ? entry.report.balances : undefined,
details: entry.report.status === "available" ? entry.report.details : undefined,
planLabel:
entry.report.status === "available" ? (entry.report.planLabel ?? null) : null,
error: entry.report.status === "error" ? entry.report.error : null,
})),
},
}
: { type: "usage.list_reports.response", payload: { requestId, error: null } };
ws.send(JSON.stringify({ type: "session", message: reply }));
listCounter.increment();
return;
}
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
agentRequests.push({
agentId: String(request.agentId),
});
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
ws.send(
JSON.stringify({
type: "session",
message: {
type: "agent.resolve_usage_report.response",
payload: { requestId, reportId },
},
}),
);
agentCounter.increment();
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo =
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
typeof message === "string"
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
});
return {
listRequests: () => [...listRequests],
agentRequests: () => [...agentRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
waitForAgentRequests: (count) => agentCounter.waitFor(count),
};
}
@@ -0,0 +1,242 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, type Locator, type Page } from "@playwright/test";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
export async function installTallUsageSource() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const cleanup = async () => {
try {
await client.removePlugin("tall-usage");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
} finally {
await client.close();
await rm(directory, { recursive: true, force: true });
}
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "tall-usage",
requirements: pluginRequirements,
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
import { z } from "zod";
export default function contribute(server) {
server.registerUsageSource({
id: "tall-usage", label: "Scrolling account", input: z.object({}),
discover: async () => [{key: "scrolling-account", input: {}}],
fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({
id: String(i), label: "Window " + (i + 1), usedPct: 25,
})) }),
});
return () => {};
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
return { cleanup };
} catch (error) {
await cleanup();
throw error;
}
}
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
function sourceIcon(plugin: string): string {
return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8");
}
function inOneDay(): string {
return new Date(Date.now() + 24 * 60 * 60_000).toISOString();
}
/** Claude and Codex reports shaped like the built-in sources report them. */
export function claudeAndCodexReports(): UsageReportEntry[] {
const fetchedAt = new Date().toISOString();
return [
{
id: "claude:default",
account: { label: "dev@example.com" },
fetchedAt,
sourceId: "claude",
sourceLabel: "Claude",
icon: sourceIcon("claude-usage-source"),
report: {
status: "available",
planLabel: "Max",
windows: [
{
id: "five_hour",
label: "Session",
shortLabel: "5h",
summary: true,
usedPct: 31,
resetsAt: inOneDay(),
},
{
id: "weekly",
label: "Weekly",
shortLabel: "wk",
summary: true,
usedPct: 54,
resetsAt: inOneDay(),
},
],
},
},
{
id: "codex:default",
account: { label: "dev@example.com" },
fetchedAt,
sourceId: "codex",
sourceLabel: "Codex",
icon: sourceIcon("codex-usage-source"),
report: {
status: "available",
planLabel: "Pro",
windows: [
{
id: "session",
label: "Session",
shortLabel: "5h",
summary: true,
usedPct: 7,
resetsAt: inOneDay(),
},
{
id: "weekly",
label: "Weekly",
shortLabel: "wk",
summary: true,
usedPct: 12,
resetsAt: inOneDay(),
},
],
},
},
];
}
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
function visible(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
/** The sidebar footer's Usage item: each summary window with data. */
export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
/** The compact usage sheet the Usage item opens. */
export function usageSheet(page: Page): Locator {
return visible(page, "usage-expanded");
}
export async function expectOnUsageScreen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/usage$/);
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
await expect(pinned).toHaveText(windows);
}
/** Without a summary window with data the footer has no Usage item, only the Usage icon. */
export async function expectNoUsageItem(page: Page): Promise<void> {
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible();
}
/** A window row, which is itself the pin toggle: "Claude", "Session". */
export function pinRow(scope: Locator, source: string, window: string): Locator {
// The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h".
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
}
/**
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
* but buttons, so no row presses or highlights on hover.
*/
export async function expectUnpinnableRows(card: Locator): Promise<void> {
await expect(card.getByRole("checkbox")).toHaveCount(0);
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
const focusable = await card
.locator("[tabindex]")
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
expect(focusable).toBe(0);
}
export async function togglePin(scope: Locator, source: string, window: string) {
const row = pinRow(scope, source, window);
const pinned = await row.isChecked();
await row.click();
await expect(row).toBeChecked({ checked: !pinned });
}
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
await expectOnUsageScreen(page);
}
function summaryInSidebarSwitch(page: Page): Locator {
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
}
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
await openUsageOptions(page);
await summaryInSidebarSwitch(page).click();
await expectSummaryInSidebar(page, on);
}
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
await openUsageOptions(page);
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
}
/** Expand the inline Settings row when its controls are folded. */
export async function openUsageOptions(page: Page): Promise<void> {
const toggle = visible(page, "usage-options-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
await expect(visible(page, "usage-display-used")).toBeVisible();
}
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
await openUsageOptions(page);
await visible(page, `usage-display-${displayAs}`).click();
await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
export async function refreshAllUsage(page: Page): Promise<void> {
await visible(page, "usage-refresh-all").click();
}
/** Opens the compact sidebar drawer. */
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
export async function leaveUsageScreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).first().click();
await expect(page).not.toHaveURL(/\/usage$/);
}
@@ -89,7 +89,7 @@ async function openExplorerView(
): Promise<void> {
const explorer = await ensureExplorerSidebar(page);
const tab = explorer.getByTestId(view.tabTestId);
await tab.click();
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
timeout: view.timeout ?? 30_000,
});
@@ -97,7 +97,7 @@ async function openExplorerView(
export async function openChangesTreePanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-changes_tree",
tabTestId: "workspace-tab-changes_tree",
contentTestId: "changes-tree-panel",
});
}
@@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise<vo
export async function openFilesPanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-files",
tabTestId: "workspace-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
-11
View File
@@ -1,12 +1,10 @@
const { getDefaultConfig } = require("expo/metro-config");
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
const origin = context.originModulePath;
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
if (fs.existsSync(candidatePath)) {
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
}
}
return resolveWithCustomWebOverlay(context, moduleName, platform);
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.10.0",
"version": "0.11.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -102,7 +102,7 @@
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
@@ -62,6 +62,8 @@ function installProbe(
clientBundle,
lifetime: new AbortController(),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
};
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { createReadingAnchor } from "./reading-anchor";
describe("reading anchor", () => {
const rows = [
{ id: "above", top: 0, height: 500 },
{ id: "reading", top: 500, height: 500 },
{ id: "below", top: 1000, height: 500 },
];
it("preserves wheel movement during an asynchronous prepend", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
expect(
anchor.reconcile(
440,
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
),
).toBe(2440);
expect(
anchor.reconcile(
2280,
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
),
).toBe(2280);
});
it("ignores growth below the reader and compensates growth above", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600);
expect(
anchor.reconcile(600, [
{ ...rows[0]!, height: 1000 },
{ ...rows[1]!, top: 1000 },
{ ...rows[2]!, top: 1500 },
]),
).toBe(1100);
});
it("leaves expansion of the row being read in place", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
expect(
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]),
).toBe(600);
});
it("keeps the existing reader through prepend measurements until the user scrolls", () => {
const anchor = createReadingAnchor();
anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]);
const prepended = [
{ id: "new", top: 2000, height: 57 },
{ id: "reading", top: 2057, height: 100 },
];
expect(anchor.reconcile(0, prepended)).toBe(2009);
expect(anchor.getRowId()).toBe("reading");
const measured = [
{ id: "new", top: 2000, height: 100 },
{ id: "reading", top: 2100, height: 100 },
];
expect(anchor.reconcile(2009, measured)).toBe(2052);
expect(anchor.getRowId()).toBe("reading");
expect(anchor.reconcile(2000, measured, true)).toBe(2000);
expect(anchor.getRowId()).toBe("new");
});
it("retains the reader while the virtualizer initializes its mounted range", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.reconcile(600, [rows[2]!]);
expect(anchor.getRowId()).toBe("reading");
expect(
anchor.reconcile(
600,
rows.map((row) => ({ ...row, top: row.top + 2000 })),
),
).toBe(2600);
});
it("projects a prepend range without consuming the correction before layout commits", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
expect(
anchor.reconcile(
440,
rows.map((row) => ({ ...row, top: row.top + 2000 })),
true,
),
).toBe(2440);
expect(anchor.getRowId()).toBe("above");
});
it("repicks from user movement before applying a simultaneous layout correction", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.scroll(440);
expect(anchor.getRowId()).toBe("above");
expect(
anchor.reconcile(
440,
rows.map((row) => ({ ...row, top: row.top + 2000 })),
),
).toBe(2440);
expect(anchor.getRowId()).toBe("above");
});
it("releases the old reading position for explicit navigation", () => {
const anchor = createReadingAnchor();
anchor.reconcile(600, rows);
anchor.reset();
expect(anchor.getRowId()).toBeNull();
expect(
anchor.reconcile(
0,
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
),
).toBe(0);
});
});
@@ -0,0 +1,45 @@
interface RowGeometry {
id: string;
top: number;
height: number;
}
// A row must clear the reading line before the next row takes ownership.
const READING_POSITION_OFFSET_PX = 8;
// Content coordinates survive user scrolling. Layout commits replace the geometry;
// scroll events reuse it, without another DOM measurement pass.
export function createReadingAnchor() {
let anchor: { id: string; top: number } | null = null;
let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
return {
getRowId: () => anchor?.id ?? null,
getReadingRowId: (scrollTop: number) =>
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
project,
reset() {
anchor = null;
},
scroll(scrollTop: number) {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
},
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
geometry = rows;
const previous = anchor && rows.find((row) => row.id === anchor?.id);
// The first virtualized commit can precede its mounted range. Keep the
// pinned reader until it mounts, rather than adopting an unrelated row.
if (anchor && !previous && !userScrolled) return scrollTop;
const correctedTop = project(scrollTop, previous ?? undefined);
// A prepend can expose the bottom of an estimated row above the reader.
// Do not transfer ownership to it until the user moves the reading position.
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
anchor = next ? { id: next.id, top: next.top } : null;
return correctedTop;
},
};
}
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
})}
</>,
);
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const unrevised = {
contentById: new Set<string>(),
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -483,10 +491,7 @@ describe("createWebStreamStrategy", () => {
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4959.1708984375,
});
scrollContainer.scrollTop = 4959.1708984375;
scrollTo.mockClear();
act(() => {
@@ -496,10 +501,7 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4960.5,
});
scrollContainer.scrollTop = 4960.5;
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
@@ -508,10 +510,7 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
value: 4967,
});
scrollContainer.scrollTop = 4967;
act(() => {
viewportRef.current?.scrollToBottom("message-sent");
@@ -527,7 +526,7 @@ describe("createWebStreamStrategy", () => {
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
this.scrollTop = top;
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -553,6 +552,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -576,7 +576,7 @@ describe("createWebStreamStrategy", () => {
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
scrollContainer.scrollTop = 0;
act(() => {
root?.render(
@@ -612,10 +612,7 @@ describe("createWebStreamStrategy", () => {
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
@@ -646,6 +643,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -672,7 +670,7 @@ describe("createWebStreamStrategy", () => {
const scrollElement = scrollContainer;
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
scrollElement.scrollTop = 0;
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
@@ -714,10 +712,7 @@ describe("createWebStreamStrategy", () => {
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
@@ -755,6 +750,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -775,7 +771,7 @@ describe("createWebStreamStrategy", () => {
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.scrollTop = 1000;
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
@@ -787,7 +783,7 @@ describe("createWebStreamStrategy", () => {
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
scrollContainer.scrollTop = 0;
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -820,10 +816,7 @@ describe("createWebStreamStrategy", () => {
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
@@ -860,6 +853,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -880,7 +874,7 @@ describe("createWebStreamStrategy", () => {
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
scrollContainer.scrollTop = 0;
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
@@ -889,17 +883,17 @@ describe("createWebStreamStrategy", () => {
});
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
scrollContainer.scrollTop = 991;
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
scrollContainer.scrollTop = 91;
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
scrollContainer.scrollTop = 0;
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -925,10 +919,7 @@ describe("createWebStreamStrategy", () => {
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
@@ -959,6 +950,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -979,7 +971,7 @@ describe("createWebStreamStrategy", () => {
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
scrollContainer.scrollTop = 50;
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
@@ -991,13 +983,13 @@ describe("createWebStreamStrategy", () => {
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
scrollContainer.scrollTop = 20;
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
scrollContainer.scrollTop = 50;
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
@@ -1025,7 +1017,7 @@ describe("createWebStreamStrategy", () => {
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
@@ -1056,6 +1048,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1070,7 +1063,7 @@ describe("createWebStreamStrategy", () => {
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.scrollTop = 1000;
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
@@ -1110,7 +1103,7 @@ describe("createWebStreamStrategy", () => {
window.dispatchEvent(jitterPointerMove);
});
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
scrollContainer.scrollTop = 800;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
@@ -1118,11 +1111,11 @@ describe("createWebStreamStrategy", () => {
nestedScroller.style.overflowY = "auto";
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
nestedScroller.scrollTop = 100;
scrollContainer.append(nestedScroller);
act(() => {
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
scrollContainer.scrollTop = 700;
scrollContainer.dispatchEvent(new Event("scroll"));
scrollContainer.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
@@ -1145,7 +1138,7 @@ describe("createWebStreamStrategy", () => {
it("stops following output after scrollbar and middle-button upward scrolling", () => {
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
@@ -1175,6 +1168,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1189,7 +1183,7 @@ describe("createWebStreamStrategy", () => {
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.scrollTop = 1000;
scrollContainer.getBoundingClientRect = () =>
({
bottom: 500,
@@ -1217,7 +1211,7 @@ describe("createWebStreamStrategy", () => {
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
scrollContainer.scrollTop = 700;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
@@ -1233,7 +1227,7 @@ describe("createWebStreamStrategy", () => {
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
scrollContainer.scrollTop = 1300;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
scrollContainer.getBoundingClientRect = () =>
@@ -1261,7 +1255,7 @@ describe("createWebStreamStrategy", () => {
pointerType: { value: "mouse" },
});
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
scrollContainer.scrollTop = 900;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
@@ -1280,7 +1274,7 @@ describe("createWebStreamStrategy", () => {
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
scrollContainer.scrollTop = 1400;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const autoscrollPointerDown = new MouseEvent("pointerdown", {
bubbles: true,
@@ -1314,7 +1308,7 @@ describe("createWebStreamStrategy", () => {
window.dispatchEvent(autoscrollPointerUp);
window.dispatchEvent(autoscrollPointerMove);
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.scrollTop = 1000;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
scrollTo.mockClear();
@@ -1375,7 +1369,7 @@ describe("createWebStreamStrategy", () => {
};
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
const top = typeof options === "object" ? (options.top ?? 0) : 0;
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
this.scrollTop = top;
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
@@ -1408,6 +1402,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
@@ -1431,12 +1426,18 @@ describe("createWebStreamStrategy", () => {
throw new Error("Expected retained history rows");
}
let rowsShifted = false;
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect;
firstRow.getBoundingClientRect = () =>
({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect;
secondRow.getBoundingClientRect = () =>
({
top: rowsShifted ? 4 : 120,
height: rowsShifted ? 116 : 40,
bottom: rowsShifted ? 120 : 160,
}) as DOMRect;
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
scrollContainer.scrollTop = 1000;
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
const viewportObservation = observed.get(scrollContainer);
expect(viewportObservation).toBeDefined();
@@ -1467,7 +1468,7 @@ describe("createWebStreamStrategy", () => {
act(() => root?.render(renderWithActivity(false)));
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
scrollContainer.scrollTop = 0;
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
scrollContainer.dispatchEvent(new Event("scroll"));
+150 -178
View File
@@ -9,10 +9,13 @@ import React, {
} from "react";
import {
defaultRangeExtractor,
observeElementOffset,
measureElement as measureVirtualElement,
useVirtualizer,
type Range as VirtualRange,
type Virtualizer,
} from "@tanstack/react-virtual";
import { flushSync } from "react-dom";
import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -20,10 +23,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import {
estimateStreamItemHeight,
shouldAdjustScrollForVirtualRowResize,
} from "./web-virtualization";
import { estimateStreamItemHeight } from "./web-virtualization";
import { createReadingAnchor } from "./reading-anchor";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
@@ -49,12 +50,6 @@ interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
}
interface HistoryStartPrependAnchor {
progressKey: string;
rowId: string;
viewportOffset: number;
}
type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
@@ -67,24 +62,12 @@ const HISTORY_START_SLOT_HEIGHT_PX = 32;
const CONTENT_PADDING_TOP_PX = 16;
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
// A row has to clear this much of the viewport top before the next one takes over as the
// reading position, so a row resting exactly on the edge does not flip back and forth.
const READING_POSITION_OFFSET_PX = 8;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
if (element.dataset.historyRowId === rowId) {
return element;
}
}
return null;
}
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
@@ -303,6 +286,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
imageContext,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -347,14 +332,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
@@ -388,14 +372,56 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
return indexes.length > 0 ? indexes : null;
}, [chatFindMessageId, segments.historyVirtualized]);
const rowVirtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
const rangeExtractor = useCallback(
(range: VirtualRange) => {
const visible = defaultRangeExtractor(range);
if (!chatFindRowIndexes) return visible;
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
(range: VirtualRange): number[] => {
const anchorIndex = segments.historyVirtualized.findIndex(
(row) => row.id === readingAnchor.getRowId(),
);
const container = scrollContainerRef.current;
// TanStack has computed the new measurements before calling the extractor.
// Select against the pending anchor correction, before mounting any rows at
// the stale pre-prepend offset. The DOM correction still belongs to commit.
const virtualizer = rowVirtualizerRef.current!;
const measurement = virtualizer.measurementsCache[anchorIndex];
const correctedOffset =
container && measurement
? readingAnchor.project(container.scrollTop, {
id: String(measurement.key),
top: measurement.start,
})
: container?.scrollTop;
let visibleRange = range;
if (
container &&
correctedOffset !== undefined &&
Math.abs(correctedOffset - container.scrollTop) > 0.5
) {
const first = virtualizer.getVirtualItemForOffset(correctedOffset);
const last = virtualizer.getVirtualItemForOffset(correctedOffset + container.clientHeight);
if (first && last)
visibleRange = { ...range, startIndex: first.index, endIndex: last.index };
}
return [
...new Set([
...defaultRangeExtractor(visibleRange),
...(chatFindRowIndexes ?? []),
...(anchorIndex < 0 ? [] : [anchorIndex]),
]),
].sort((left, right) => left - right);
},
[chatFindRowIndexes],
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
);
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
virtualOffsetListener.current = onOffset;
const unsubscribe = observeElementOffset(instance, onOffset);
return () => {
virtualOffsetListener.current = null;
unsubscribe?.();
};
}, []);
const contentWidth = contentRef.current?.getBoundingClientRect().width;
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
@@ -403,31 +429,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
return row ? estimateStreamItemHeight(row) : 120;
const measured = row && measuredRowHeights.current.get(row.id);
if (measured && measured.width === contentWidth) return measured.height;
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
},
measureElement: measureVirtualElement,
observeElementOffset: observeVirtualOffset,
rangeExtractor,
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
rowStart: item.start,
scrollOffset,
remainingDistanceFromBottom: remainingDistance,
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
});
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
};
}, [rowVirtualizer]);
rowVirtualizerRef.current = rowVirtualizer;
// Scroll correction belongs to the viewport's committed layout. The virtualizer
// otherwise writes scrollTop before React applies its new row positions.
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
@@ -441,7 +455,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
distanceFromHistoryStart: scrollContainer.scrollTop,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && bottomAnchorSettled,
// An explicit jump owns the landing position. Revealing another local
// page here changes the virtual index before the target has mounted.
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
@@ -452,29 +468,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!isHistoryStartLoadingOperation(transition.state)) {
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
const anchorElement =
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
if (scrollContainer && anchorRow && anchorElement) {
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
rowId: anchorRow.id,
viewportOffset:
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
} else {
historyStartPrependAnchorRef.current = null;
}
historyStartPrependAnchorActiveRef.current = false;
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
@@ -511,27 +507,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const applyHistoryStartPrependAnchor = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
const anchor = historyStartPrependAnchorRef.current;
if (
!scrollContainer ||
!contentNode ||
!anchor ||
!historyStartPrependAnchorActiveRef.current
) {
return;
}
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
if (!anchorElement) {
return;
}
const viewportOffset =
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -542,24 +517,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return (
!input ||
input.isLoadingOlderHistory ||
pendingVirtualRowMeasureFramesRef.current.size > 0
);
return !input || input.isLoadingOlderHistory;
},
onFrame: applyHistoryStartPrependAnchor,
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
historyStartPrependAnchorActiveRef.current = false;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
historyStartPrependAnchorRef.current = null;
applyHistoryStartPaginationTransition(transition);
},
});
@@ -568,59 +536,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduler.schedule();
});
useLayoutEffect(() => {
if (!isActiveRef.current) {
return;
}
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
}
historyStartPrependAnchorActiveRef.current = true;
evaluateHistoryStart();
applyHistoryStartPrependAnchor();
scheduleHistoryStartPrependSettle();
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted,
segments.historyVirtualized,
virtualTotalSize,
]);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
rowVirtualizer.measureElement(null);
return;
}
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
const existingFrame = pendingFrames.get(node);
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame);
}
const frame = window.requestAnimationFrame(() => {
pendingFrames.delete(node);
if (isActiveRef.current && node.isConnected) {
rowVirtualizer.measureElement(node);
}
});
pendingFrames.set(node, frame);
},
// ResizeObserver runs before paint. Batch measurements and commit React's new
// positions here so a resized image cannot overlap rows for an extra frame.
// Use resizeItem exclusively; measureElement would install a second observer.
const virtualRowObserver = useMemo(
() =>
new ResizeObserver((entries) => {
if (!isActiveRef.current) return;
flushSync(() => {
for (const entry of entries) {
const element = entry.target as HTMLElement;
if (!element.isConnected) continue;
rowVirtualizer.resizeItem(
Number(element.dataset.index),
measureVirtualElement(element, entry, rowVirtualizer),
);
}
});
}),
[rowVirtualizer],
);
useEffect(() => {
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
return () => {
for (const frame of pendingFrames.values()) {
window.cancelAnimationFrame(frame);
}
pendingFrames.clear();
};
}, []);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
if (!node) return;
virtualRowObserver.observe(node, { box: "border-box" });
return () => virtualRowObserver.unobserve(node);
},
[virtualRowObserver],
);
useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]);
const cancelPendingStickToBottom = useCallback(() => {
const pendingFrame = pendingAutoScrollFrameRef.current;
@@ -659,10 +603,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom();
historyStartSettleSchedulerRef.current?.cancel();
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
window.cancelAnimationFrame(frame);
}
pendingVirtualRowMeasureFramesRef.current.clear();
clearMouseScrollGesture();
clearUpwardInputEvidence();
lastTouchClientYRef.current = null;
@@ -730,19 +670,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onReadingPositionChange(null);
return;
}
const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX;
let readingRowId: string | null = null;
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
const rowId = element.dataset.historyRowId;
if (!rowId) {
continue;
}
readingRowId = rowId;
if (element.getBoundingClientRect().bottom > readingLine) {
break;
}
}
onReadingPositionChange(readingRowId);
onReadingPositionChange(readingAnchor.getReadingRowId(scrollContainer.scrollTop));
});
const updateScrollMetrics = useCallback(() => {
@@ -768,6 +696,44 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearBottomChange,
});
const reconcileReadingPosition = useStableEvent(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!isActiveRef.current || !container || !content) return;
const viewportTop = container.getBoundingClientRect().top;
const scrollTop = container.scrollTop;
const width = content.getBoundingClientRect().width;
const rows = Array.from(
content.querySelectorAll<HTMLElement>("[data-history-row-id]"),
(element) => {
const bounds = element.getBoundingClientRect();
const id = element.dataset.historyRowId!;
measuredRowHeights.current.set(id, { width, height: bounds.height });
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
},
);
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
if (!shouldAnchor) {
readingAnchor.reset();
return;
}
if (Math.abs(correctedTop - container.scrollTop) > 0.5) {
container.scrollTop = correctedTop;
lastKnownScrollTopRef.current = container.scrollTop;
// The DOM scroll event arrives later. Fill the corrected virtual range in
// this layout so the reader does not get a frame containing only the pin.
virtualOffsetListener.current?.(container.scrollTop, false);
}
});
// Apply row positions and their scroll correction in the same paint, including
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
useLayoutEffect(() => {
reconcileReadingPosition();
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
if (followOutputRef.current) {
@@ -800,11 +766,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
lastKnownScrollTopRef.current = currentScrollTop;
if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) {
readingAnchor.scroll(currentScrollTop);
}
updateScrollMetrics();
evaluateHistoryStart();
}, [
evaluateHistoryStart,
isJumpSettling,
readingAnchor,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
@@ -813,8 +783,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -825,7 +794,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart, props.agentId]);
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -958,9 +927,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} else {
lastObservedViewportGeometryRef.current = nextGeometry;
}
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
reconcileReadingPosition();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
@@ -979,7 +946,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
observer.disconnect();
};
}, [
applyHistoryStartPrependAnchor,
reconcileReadingPosition,
evaluateHistoryStart,
isActive,
reportReadingPosition,
@@ -1100,7 +1067,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastTouchClientYRef.current = null;
};
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
// Read user movement before the virtualizer's bubbling listener flushes a
// layout correction. Scroll events reuse the last committed row geometry.
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true, capture: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
@@ -1114,7 +1083,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
scrollContainer.removeEventListener("scroll", handleDomScroll, true);
scrollContainer.removeEventListener("wheel", handleWheel);
scrollContainer.removeEventListener("keydown", handleKeyDown);
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
@@ -1187,9 +1156,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
// The browser still anchors delayed image growth while following output.
// Detached reading has one owner: reconcileReadingPosition.
overflowAnchor: followOutput ? "auto" : "none",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
};
}, [isMobileBreakpoint, scrollEnabled]);
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",

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