Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 49b622bd14 test(opencode): reproduce v2 misdetected as v1 when --version answers after 5 s
Failing reproduction for #6051. No fix.
2026-10-05 03:45:37 +02:00
paseo-bot[bot] 36e6ccd6c7 fix(claude): show plan mode when Claude enters it on its own (#6074)
When Claude Code calls its EnterPlanMode tool, it reports the new mode
through a system status message with permissionMode "plan". The Claude
provider only read the compacting status from those messages, so the
session kept the previous mode and never emitted mode_changed: the app,
`paseo inspect`, and the stored agent record all kept showing the old
mode.

The provider now records the mode carried by a status message and emits
mode_changed when it differs, sharing the bookkeeping with the init
message so the plan-approval resume mode stays the mode Claude left.

Closes #6063

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 03:04:07 +02:00
Mohamed Boudra fa0f04058d Keep account usage accessible before the first turn and restore compact sheets
* fix(usage): keep details hoverable and restore compact sidebar sheets

Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet.

* fix(usage): restore settings cog with a fitted popover and compact sheet

Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout.

* fix(app): show account usage before the first agent turn

Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive.

* fix(usage): preserve keyboard access and hover card positioning

Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation.

* chore: remove QA screenshots from the source tree

* fix(app): let hover cards claim Escape before agent shortcuts

Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn.

* fix(app): reopen focused hover cards with button keys

Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts.

* fix(app): release overlay ownership when hover cards close

Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns.

* test(app): pin Linux keyboard identity for composer Find

* test(app): synchronize context usage hover journeys

Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
2026-10-05 01:59:15 +01:00
James Hu d517b3314e fix(opencode): wait for the server to listen before opening the event stream (#5873)
* fix(opencode): wait for the server to listen before opening the event stream

OpenCode can accept TCP connections before its HTTP handler is ready. Starting the event consumer at spawn lets a retry land in that window and hang without receiving server.connected.

Gate the first request on the listening promise. Use a five-second watchdog before connection readiness and abandon stalled attempts independently of fetch abort, ignoring any late records. Keep the existing thirty-second watchdog after readiness.

* fix(opencode): forward the listening gate from the agent client

The first startup fix passed the listening gate from the server manager, but the agent client factory dropped it before building the consumer. Lab logs showed four generations retrying before listening and then hitting the first-record watchdog.

Forward the gate through that factory. Exercise the client-owned manager with a real spawned process in the existing provider dispatch test, and assert that no event request arrives before listening.
2026-10-05 02:18:32 +02:00
Mohamed Boudra a8e6c57f15 fix(app): match compact sidebar footer glyphs to the composer toolbar size
#6044 enlarged the compact footer tap targets to 44x44 and also bumped the
glyphs to 24px. Keep the targets and return the glyphs to 20px, the composer
toolbar glyph size. The compact footer spec now asserts the glyph size.
2026-10-05 01:07:16 +02:00
Mohamed Boudra 1f8a4559a8 fix(providers): show custom context in expandable tool rows (#6090)
* fix(providers): show custom context in expandable tool rows

Preserve custom message type and data instead of attributing extension context to the assistant. Apply the same mapping in live output and history for OMP and Pi, and honor hidden Pi messages without suppressing structured extension updates.

* fix(server): mark truncated plain-text tool content

Keep the existing timeline content budget while making omitted text visible to readers of extension context and other plain-text tools.
2026-10-05 00:03:20 +01:00
paseo-bot[bot] 2187124642 Answer an ACP agent's open permission request when its turn fails (#6045)
* fix(acp): answer open permission requests when a turn fails

When an ACP prompt failed while one of its session/request_permission
requests was still open, Paseo stopped showing the request but never
answered it, so agents that refuse new prompts until the request is
settled stayed stuck until the agent was reloaded.

A turn that ends without completing now answers its open permission
requests with cancelled, as interrupt and close already do.

Closes #6040

* test(acp): cover permission requests left open by a cancelled turn

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 23:31:39 +01:00
Mohamed Boudra 154a088d6e feat(app): default sidebar workspace slots to last activity (#6087) 2026-10-04 23:04:17 +01:00
Bae Seongmin 71b236d38a fix(server): update the running npm installation prefix (#5649) 2026-10-04 23:07:22 +02:00
Mohamed Boudra 8a4ce5a482 fix(website): replace the native search clear control with a muted X button (#6082) 2026-10-04 21:15:23 +01:00
paseo-bot[bot] b3ed7363c6 fix(claude): keep a background subagent's card labeled after its child streams (#6067)
Claude Code answers a background Agent call right away, then streams the
child's frames. Each frame re-emitted the parent's card from the sidechain
tracker after the tool-input cache had dropped the call, replacing the
settled, labeled card with a bare running "Task". The tracker now emits the
parent card only while the parent's tool call is still open.

Closes #6059

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 22:07:57 +02:00
Mohamed Boudra c062280429 Ship the plugin directory redesign and keep registry installs behind a daemon flag (#6080)
* website: plugin page design mockups (four directions, throwaway routes)

* website: final plugin page mockup (A directory, B browse frame, list cards, D detail)

* website: browse pages use a four-column card grid in the final plugin mockup

* feat(plugins): keep registry installs behind a daemon flag, off by default

Off restores the pre-registry behavior: bare owner/repo is GitHub shorthand and the
daemon never resolves or contacts a registry on install, update preview, or update.
Enable with pluginRegistryEnabled: true or PASEO_PLUGIN_REGISTRY_ENABLED=1.

* feat(website): ship the plugin directory, browse pages, and single-column detail

Directory: count, build/submit links, What's new, the nine categories, and Most installed
with a This week / This month / All time switch. Browse pages at /plugins/all and
/plugins/category/<slug> with a category sidebar, tabs, and a four-column grid; old
/plugins?category= links redirect. Install counts gain daily KV buckets kept 60 days.

* fix(website): keep a README's leading GitHub alert on the plugin page

* feat(website): search plugins from the directory and browse pages

Typing replaces the URL with /plugins/all?q=<term> (or the current category
with ?q=), which renders matching plugins on the server. Matching covers
name, description, ID, and author. Old /plugins?q= links redirect.

* fix(website): keep the directory's ranking window when searching
2026-10-04 20:38:39 +01:00
Phieu DinhandPhieu Dinh febe9611bf fix(app): stop composer autofocus on touch screens (#5477)
A tablet or an unfolded foldable is wide enough for the desktop layout,
so opening an agent autofocused the composer and raised the on-screen
keyboard over the conversation. Autofocus on open now also requires a
fine pointer, the same signal the app already uses for hover.

A mouse-driven window still focuses the composer when an agent opens,
and sending a message still keeps the composer focused.

Co-authored-by: Phieu Dinh <phieu@fiu.one>
2026-10-04 21:04:30 +02:00
Bae Seongmin 642d69b142 fix(app): cache file links with their line locations (#5653) 2026-10-04 19:00:31 +02:00
Mohamed Boudra 5aee41952e feat(website): browse plugins by nine community categories (#6068)
Organize community plugins by what Paseo does and verify the category layout with a static registry fixture.
2026-10-04 17:12:11 +01:00
Galaxy Roaming 48329facc3 fix(acp): surface context-window usage from the ACP usage_update notification (#4848)
* fix(acp): show context-window usage for generic ACP agents

Generic ACP providers never populated Paseo's context meter: the standard ACP
`usage_update` notification was discarded by `handleUsageUpdate` (`void
update`), while the meter only reads `lastUsage.contextWindowMaxTokens` /
`contextWindowUsedTokens`, which `usage_updated` stream events write.

Forward `size` and `used` on a `usage_updated` event for the active turn,
guarded by the same validity rules the meter applies so a provider cannot push
a window the meter rejects, and preserve the turn usage already recorded.

Verified with vitest acp-agent.test.ts (106 passed), the server typecheck, and
an isolated daemon run where a dsh agent reported
`lastUsage={contextWindowMaxTokens:1000000,contextWindowUsedTokens:13623}`.

* fix(acp): emit context-window usage only with both meter fields

Require a valid size and used together so a partial usage_update cannot
wipe the composer meter. Drive the tests through sessionUpdate instead
of the private translator.
2026-10-04 18:06:41 +02:00
paseo-bot[bot] 97083dd736 fix(pi,omp): open sessions whose model was removed (#6061)
* fix(pi,omp): open sessions whose model was removed

Importing or reopening a Pi or OMP session failed with
`Model "<id>" not found` when the session's model was no longer
configured, because the resume launch always passed `--model`.

Resume now launches on the session's own model, which Pi and OMP
replace with their default when it is gone, and switches to the
requested model afterwards only when it differs and is available.

Closes #6039

* fix(pi,omp): fall back only when the requested model is unavailable

Check the provider's model list before switching on resume, so a
failed switch to an available model reaches the caller instead of
silently keeping the session's model.

* fix(pi,omp): match a requested provider:id model on resume

Compare the parsed provider and model id against the session and
available models, so a reference written as provider:id switches
instead of being treated as unavailable.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 17:16:14 +02:00
Petr PlenkovandDevin 5c2c85ebc8 fix(server): resume with last live mode, not creation-time mode (#5140)
* fix(server): resume with last live mode, not creation-time mode

Provider-initiated mode switches (ACP current_mode_update, in-session
commands like /bypass, permission-driven plan->acceptEdits transitions)
update currentModeId/lastModeId but never reach config.modeId. On
resume, buildConfigOverrides restored the stale creation-time mode and
applyConfiguredOverrides pushed it back via set_mode — silently
downgrading the session (e.g. bypass -> acceptEdits after restart).

- buildConfigOverrides + buildStoredAgentConfig prefer lastModeId
  (the last observed live mode) over config.modeId
- applyObservedMode syncs config.modeId on every provider-reported
  mode change (mode_changed, model_changed runtimeInfo, and the
  refreshSessionState poll) so in-memory reloadAgentSession carries
  the live mode as well

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(server): assert config.modeId sync on mode_changed/model_changed events

Extends the config-drift stream test with a registry so both the
in-memory agent.config.modeId and the persisted record.config.modeId
are verified — covering the event paths Greptile flagged as untested.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 16:12:33 +02:00
Mohamed Boudra 30fb66c8cb fix(app): preserve mobile sidebar scroll position when reopening (#6058)
* fix(app): preserve mobile sidebar scroll position when reopening

Keep hidden native overlays laid out so Android touch cancellation cannot spring retained scroll offsets through a zero scroll range. Hide and gate touch input on the UI thread to preserve panel motion during JS stalls.

* docs(app): quote sidebar replay workspace examples

* test(website): read plugin HTML without waiting for remote images

* test(website): exercise built worker in browser checks

The ordered plugin spec reproduces a no-response navigation timeout against a cold development server. Build and preview the published worker for E2E checks; preserve the original content and metadata assertions.
2026-10-04 14:55:24 +01:00
paseo-bot[bot] 05b074764d fix(cli): report an unknown agent ID as AGENT_NOT_FOUND (#6053)
* fix(server): report an unknown agent as not found to CLI commands

fetch_agent_request answered an unknown agent ID with an error string, so
DaemonClient.fetchAgent threw instead of returning null. Every CLI command
that looks up one agent checks for null to report AGENT_NOT_FOUND, so
delete, stop, inspect, logs and the rest reported their generic failure
code and skipped the "paseo ls" hint.

The daemon now answers an unknown agent with a null agent and no error.
An empty or ambiguous identifier is still an error.

* test(cli): expect the not-found message agent logs now prints

* test(cli): move the unknown agent ID cases into the delete and stop suites

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 14:55:22 +02:00
paseo-bot[bot] 5285b7e502 fix(server): keep a stuck directory search from blocking every other search (#6041)
Directory search shares two filesystem slots across all searches. Searches
over one tree walk it in the same order, so two Add Project searches that
reach the same folder whose read never returns held both slots, and every
later directory search queued behind them: command center file search,
@-file autocomplete, and file-link lookups in unrelated folders all hung
until the read returned.

Searches waiting on the same path now share its pending filesystem call, so
one stuck folder holds one slot and the other stays free.

Refs #4879

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 13:11:40 +02:00
409b8f9312 fix(server): keep a new worktree open when its paseo.json cannot be parsed (#6036)
* fix(server): keep workspaces available after setup config errors

* refactor(server): drop the archive dependency from worktree setup

Setup no longer archives a workspace when it fails, so the setup
workflows no longer take archiveWorkspaceRecord.

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

* test(server): stop passing the removed archive dependency to the worktree workflow

* test(server): check paseo.json survives without comparing line endings

Git checks the fixture out with CRLF on Windows, so comparing its exact
contents failed there.

---------

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-04 12:20:44 +02:00
Mohamed Boudra 485221b739 fix(app): enlarge compact sidebar footer tap targets (#6044)
* fix(app): enlarge compact sidebar footer tap targets

The compact drawer reused 28px desktop buttons, making footer actions hard to tap. Use 44px targets with larger glyphs in compact layouts while preserving desktop geometry.

* test(app): wait for compact footer tap stability
2026-10-04 10:55:25 +01:00
paseo-bot[bot]andLeandro Ardissone ed48655a77 fix(server): stop directory search from taking every filesystem thread (#6029)
Each pending fs call holds one of libuv's four shared threadpool threads,
and a read on a hung mount, a protected macOS folder, or a huge network
directory does not return. Add Project starts a new search per keystroke
and nothing cancels the old ones, so four stuck searches took every
thread: logging stopped and `paseo project create` timed out until the
read returned or the daemon was killed.

Directory search now runs its filesystem calls through one shared limit
of two, leaving the rest of the pool to the daemon.

Refs #4879

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com>
2026-10-04 10:18:19 +02:00
Jared cc8fe41e28 Send display: summarized so Claude thinking renders (#4355)
The Agent SDK request never set thinking.display, so on Opus 4.7 and
later the API default of "omitted" applied and every thinking block
arrived with empty text. Paseo drops empty blocks, so Claude agents
never produced reasoning timeline items.

Closes #4342
2026-10-04 09:15:14 +02:00
paseo-bot[bot]and3ae3ae 8216e86ecb fix(cli): delete archived agents with agent delete --all and --cwd (#6027)
* fix(cli): delete archived agents in bulk commands

* test(cli): cover archived agents in bulk delete from the CLI suite

The daemon-backed test imported server test utilities from a CLI test,
which the CI contract in scripts/ci-workflow.test.mjs forbids. Cover
--all and --cwd against archived agents in the runDeleteCommand unit
test instead.

* fix(cli): read every history page when bulk-deleting agents

agent delete --all and --cwd read one page of fetch_agents, so agents
past the first 200 were skipped, and fetch_agents leaves out saved
agents whose provider is unavailable. List agents from the paged agent
history, which the History view uses, so bulk delete covers both.

* fix(cli): delete an agent by ID without listing history

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 07:55:21 +02:00
paseo-bot[bot] 79a3942393 fix(server): mark Claude terminals as needing input when Claude goes idle (#6022)
Claude Code reports the kind of notification in `notification_type`, but
the hook parser only read `matcher` and `reason`, which Claude never sends
in Notification input. Every idle_prompt notification resolved to nothing,
so a Claude terminal waiting for the user never showed as needing input.

Closes #5845

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 06:10:19 +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
458 changed files with 18588 additions and 4634 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"
+41
View File
@@ -1,5 +1,40 @@
# 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
@@ -72,6 +107,12 @@
- 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
+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
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
@@ -92,7 +92,7 @@ portal can cover them.
Painting and keyboard ownership use the same relative layer model. Register
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
highest painted scope alone receives overlay keys, traps focus, and restores
focus when it closes. Do not add component-local global Escape listeners: two
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
If an overlay is rendered by a global host rather than beneath its opener in
+2 -2
View File
@@ -33,8 +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 (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). 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`.
- **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 hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. 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 remains available); both entry points open the Usage screen on wide layouts and the same 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 -2
View File
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
@@ -150,5 +150,5 @@ Before opening a PR that touches hover:
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
+2 -2
View File
@@ -197,7 +197,7 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+4 -2
View File
@@ -32,8 +32,10 @@ The UI worklet owns transient motion:
React publishes the active panel only when the canonical target and the UI-thread position agree at
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
release. Native panel hosts and their dependent draggable lists
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
+20
View File
@@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row
IDs. Choose one row near the top and one initially below the viewport. Replace
the quoted example values below with those IDs. Start in a workspace with the
sidebar closed and keep the list stable during the run. The same flow checks
reopening by swipe and button:
```bash
agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \
--maestro --platform android --serial emulator-5556 --metro-port 8099 \
--env TOP_WORKSPACE='server-id:top-workspace-id' \
--env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id'
```
Pass this checkout's Metro port so the debug app uses the intended bundle.
For iOS, compare the scrolled row's screen bounds and screenshots across reopening.
Agent Device's iOS visibility lookup includes offscreen scroll descendants, so
its `assertNotVisible` cannot establish the starting scroll position for this flow.
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
screens launch on their own, take input focus, and the run fails with
`IME did not become visible` while the app still looks focused:
+40 -16
View File
@@ -93,6 +93,15 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`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.
@@ -106,27 +115,42 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
[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
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. 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 repositories use `owner/repository` or `github:owner/repository`. 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 owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install 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
@@ -464,7 +488,7 @@ 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.
+4 -9
View File
@@ -187,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 required `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.
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.
---
+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
@@ -51,7 +51,7 @@ Claude hook mapping:
- `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
while terminal activity is working clears the activity without finished attention. The same
+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.11.0-beta.1",
"version": "0.11.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"version": "0.11.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/server": "0.11.0-beta.1",
"@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.11.0-beta.1",
"version": "0.11.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/relay": "0.11.0-beta.1",
"@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.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"version": "0.11.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@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.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@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.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"@getpaseo/highlight": "0.11.0-beta.1",
"@getpaseo/plugin": "0.11.0-beta.1",
"@getpaseo/protocol": "0.11.0-beta.1",
"@getpaseo/relay": "0.11.0-beta.1",
"@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.11.0-beta.1",
"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.11.0-beta.1",
"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.11.0-beta.1",
"version": "0.11.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
@@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent
"3m ago",
);
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
@@ -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);
});
@@ -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();
+10 -1
View File
@@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
test("opens and refocuses Find with Control+f from the composer on Linux", async ({
page,
}, testInfo) => {
// Chromium retains the host navigator.platform even when its user agent is overridden.
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",
});
await findFromComposer(page, "Control+f", testInfo);
});
@@ -1,7 +1,8 @@
import { test } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import {
expectComposerDraft,
expectComposerFocused,
expectComposerNotFocused,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
await agent.cleanup();
}
});
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-open-",
title: "Composer focus on open",
});
try {
await openAgentRoute(page, agent);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
});
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
await page.evaluate(async (frames) => {
for (let frame = 0; frame < frames; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}, count);
}
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test("opening an agent leaves the composer unfocused", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-touch-",
title: "Composer focus on touch",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
expect(
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
// Autofocus makes its first attempt on the next animation frame.
await waitForAnimationFrames(page, 4);
await expectComposerNotFocused(page);
} finally {
await agent.cleanup();
}
});
});
@@ -1,7 +1,46 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import type { Locator, Page } from "@playwright/test";
import { expect, test as base } from "../support/fixtures";
import {
composerLocator,
expectComposerVisible,
submitMessageWithButton,
} from "../support/helpers/composer";
import {
type AgentUsageScript,
closeContextWindowSheet,
contextWindowDetails,
expectRefreshButtons,
expiredLogin,
gate,
hoverContextWindowMeter,
leaveContextWindowMeter,
type MockAgentSession,
moveOntoContextWindowDetails,
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";
import { installLoginUsage } from "../support/helpers/usage-login";
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 +108,342 @@ 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\.$/;
for (const layout of [
{
name: "desktop",
viewport: DESKTOP,
open: hoverContextWindowMeter,
close: leaveContextWindowMeter,
},
{
name: "compact",
viewport: COMPACT,
open: pressContextWindowMeter,
close: closeContextWindowSheet,
},
]) {
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
test.setTimeout(180_000);
const source = await installLoginUsage({
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 31 }],
});
try {
const agent = await seedMockAgentWorkspace({
repoPrefix: "context-window-empty-",
title: "Agent before its first turn",
});
try {
await page.setViewportSize(layout.viewport);
await openAgent(page, agent);
await test.step("the empty meter opens context details and account usage", async () => {
const details = await layout.open(page, "Context window: No context data");
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
await test.step("the first context reading replaces the empty state", async () => {
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
const details = await layout.open(page);
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
).toBeVisible();
});
} finally {
await agent.cleanup();
}
} finally {
await source.cleanup();
}
});
}
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);
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 hover card 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, `hover-card-desktop-${theme}-${state}`);
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
await expectRefreshButtons(card, 2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("the card stays open while the pointer moves onto it", async () => {
const card = contextWindowDetails(page);
await moveOntoContextWindowDetails(page, card);
await page.waitForTimeout(300);
await expect(card).toBeVisible();
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
});
await test.step("after a resume under another login, only that login shows", async () => {
// Refresh can retain focus; return to the composer before testing pointer dismissal.
await composerLocator(page).focus();
// 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 card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(card.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(card, "claude:personal").getByText("personal@example.com", { exact: true }),
).toBeVisible();
await expect(card.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 card = await hoverContextWindowMeter(page);
await expect(card.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);
// Without hover, each card prints its freshness instead of a Refresh tooltip.
await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(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 = contextWindowDetails(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);
});
});
}
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
}) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stop).toBeVisible();
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await expect(trigger).toHaveAttribute("tabindex", "0");
await trigger.focus();
const details = contextWindowDetails(page);
await expect(details).toBeVisible();
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
await trigger.press("ArrowDown");
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
for (const key of ["Enter", " "]) {
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press(key);
await expect(details).toBeVisible();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
}
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press("ArrowDown");
await expect(details).toBeVisible();
await page.setViewportSize({ width: 1280, height: 850 });
await expect(details).toHaveCount(0);
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const input = composerLocator(page);
await input.focus();
await hoverContextWindowMeter(page);
await expect(input).toBeFocused();
await leaveContextWindowMeter(page);
await expect(input).toBeFocused();
});
test("Find remains available after dismissing context details", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await trigger.focus();
await expect(contextWindowDetails(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press("ControlOrMeta+f");
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
});
@@ -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();
+36 -1
View File
@@ -96,6 +96,36 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
title: "Relative line references",
initialPrompt: "Show both references",
featureValues: {
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
},
});
try {
await mkdir(path.join(session.cwd, "src"));
await writeFile(
path.join(session.cwd, "src/router.go"),
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
);
await openAgentRoute(page, session);
for (const line of [46, 403, 46]) {
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
.filter({ visible: true })
.click();
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
}
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
} finally {
await session.cleanup();
}
});
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
page,
}) => {
@@ -279,7 +309,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}`)
@@ -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();
}
@@ -20,10 +20,13 @@ import {
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";
@@ -152,6 +155,9 @@ test.describe("Plugin sidebar items", () => {
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");
@@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as
test.describe("compact sidebar help", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFingerSizedFooterTargets(page);
await tapHelpNearTargetEdge(page);
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
await page.getByTestId("sidebar-help-diagnostics").click();
await expectDiagnosticReport(page);
});
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).click();
@@ -165,3 +177,26 @@ test.describe("compact sidebar help", () => {
await expectDiagnosticReport(page);
});
});
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
}
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
const bounds = await button.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(44);
expect(bounds?.height).toBeGreaterThanOrEqual(44);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
}
}
async function tapHelpNearTargetEdge(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Help and support", exact: true });
await button.tap({ position: { x: 40, y: 40 } });
}
@@ -34,7 +34,10 @@ import {
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 }]);
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"]);
@@ -184,8 +187,11 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
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 },
@@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expect(workspaceRow(page)).toBeVisible();
await toggleTrailing(page, "Diff stats", true);
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
}
@@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) {
await gotoAppShell(page);
const row = workspaceRow(page);
await expect(row).toBeVisible();
await toggleTrailing(page, "Diff stats");
await page.mouse.move(0, 0);
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
return titleWidth(row);
@@ -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();
+234 -3
View File
@@ -1,3 +1,4 @@
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { Page } from "@playwright/test";
@@ -7,12 +8,62 @@ 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 { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
installUsageReportsFixture,
type UsageListRequest,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { expectPinnedUsage, usageItem } from "../support/helpers/usage-sidebar-item";
import {
installLoginUsage,
installCodexWindowUsage,
expectCodexReportedWindows,
openUsage,
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
expectPinnedUsage,
pinRow,
openUsageOptions,
closeUsageOptions,
expectUsageOptionsFitContent,
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);
@@ -73,7 +124,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" },
},
},
],
],
@@ -184,12 +238,15 @@ test.describe("usage screen", () => {
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 .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
});
@@ -243,3 +300,177 @@ test.describe("usage screen", () => {
}
});
});
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(`Usage Settings cog ${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 = page.getByTestId("usage-options-menu");
await expect(settings).toHaveAccessibleName("Settings");
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
await openUsageOptions(page);
await expectUsageOptionsFitContent(page);
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
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 openUsageOptions(page);
await closeUsageOptions(page);
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" });
}
@@ -18,7 +18,9 @@ function forcedRefreshCount(usage: UsageReportsFixture): number {
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`,
@@ -27,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,
},
};
}
@@ -90,7 +95,12 @@ test.describe("usage settings", () => {
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
// The shared percentages setting applies to the host section.
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
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();
});
@@ -5,6 +5,8 @@ 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,
@@ -12,11 +14,19 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
closeUsageSheet,
closeUsageOptions,
openUsageSheetFromIcon,
expectNoUsageItem,
expectOnUsageScreen,
expectPinnedUsage,
expectSummaryInSidebar,
expectUnpinnableRows,
leaveUsageScreen,
openCompactSidebar,
openUsageScreenFromIcon,
pinRow,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
togglePin,
@@ -55,6 +65,33 @@ async function footerClip(page: Page) {
}
test.describe("Usage item", () => {
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]);
await gotoAppShell(page);
await openCompactSidebar(page);
const startingUrl = page.url();
await expectNoUsageItem(page);
await test.step("the icon opens a sheet without leaving the current screen", async () => {
await openUsageSheetFromIcon(page);
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-icon-sheet");
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await closeUsageSheet(page);
});
await test.step("the summary opens the same sheet", async () => {
await usageItem(page).click();
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
await closeUsageSheet(page);
});
});
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
page,
}) => {
@@ -91,18 +128,15 @@ test.describe("Usage item", () => {
await gotoAppShell(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
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);
// Phones start with the Usage item off; turning it on stores the choice.
await expect(
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
).toHaveAttribute("aria-checked", "false");
await setFooterItemVisible(page, "usage", true);
await expectFooterItemSetting(page, "usage", true);
await qaScreenshot(page, "compact-settings-sidebar-footer");
await gotoAppShell(page);
await openCompactSidebar(page);
@@ -110,8 +144,15 @@ test.describe("Usage item", () => {
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 () => {
@@ -120,13 +161,17 @@ test.describe("Usage item", () => {
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
0,
);
await togglePin(screen, "Claude", "Session");
await expectPinnedUsage(page, ["31% 5h"]);
await togglePin(screen, "Codex", "Weekly");
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: "Usage options", exact: true })).toBeVisible();
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(".."),
@@ -174,22 +219,23 @@ test.describe("Usage item", () => {
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the Usage screen's controls.
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
// The options open as a sheet of their own over the usage sheet.
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
"aria-selected",
"true",
);
await qaScreenshot(page, "compact-options-sheet");
// Tap the backdrop above both sheets; only the options sheet closes.
await page.mouse.click(COMPACT.width / 2, 40);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await qaScreenshot(page, "compact-settings-expanded");
await closeUsageOptions(page);
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())!;
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
// The Usage sheet 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");
@@ -206,11 +252,11 @@ test.describe("Usage item", () => {
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
"aria-selected",
"true",
);
await qaScreenshot(page, "usage-options-menu");
await page.keyboard.press("Escape");
await qaScreenshot(page, "usage-settings-expanded");
await closeUsageOptions(page);
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
@@ -224,16 +270,18 @@ test.describe("Usage item", () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step("unpinning both brings back default windows, and that survives a reload", async () => {
await test.step("unpinning everything stays empty after a reload", async () => {
await togglePin(screen, "Claude", "Session");
await togglePin(screen, "Codex", "Weekly");
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
await expectNoUsageItem(page);
await page.reload();
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
await gotoAppShell(page);
await usageItem(page).click();
await expectOnUsageScreen(page);
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 () => {
@@ -248,26 +296,82 @@ test.describe("Usage item", () => {
});
});
test("the Usage options show and hide the sidebar Usage item", async ({ 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);
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
await usageItem(page).click();
await expectOnUsageScreen(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
const claude = screen.getByTestId("usage-report-claude:default");
await openUsageOptions(page);
const toggle = page.getByTestId("usage-show-in-sidebar");
await expect(toggle).toHaveAttribute("aria-checked", "true");
await toggle.click();
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
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 page.reload();
await expectOnUsageScreen(page);
await openUsageOptions(page);
await expect(toggle).toHaveAttribute("aria-checked", "false");
await toggle.click();
await expect(usageItem(page)).toBeVisible();
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 }) => {
@@ -275,6 +379,7 @@ test("released hosts supply source logos through the client conversion", async (
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"]);
@@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => {
}
});
test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-invalid-config-", {
files: [{ path: "paseo.json", content: "{ invalid json\n" }],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed",
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-invalid-config",
});
const failedPayload = await failed;
expect(failedPayload.error).toContain("Failed to parse paseo.json");
expect(failedPayload.detail.commands).toEqual([]);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible();
await closeSetupTab(page, workspace.id);
await clickNewChat(page);
await expectComposerVisible(page);
await openFileExplorer(page);
await expectExplorerEntryVisible(page, "paseo.json");
} finally {
await client.close();
await repo.cleanup();
}
});
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
@@ -0,0 +1,4 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
@@ -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(
@@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise<void> {
await expect(composerInput(page)).toBeFocused();
}
export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -59,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
await input.press("Enter");
}
/** The Send button works on compact screens, where Enter inserts a newline. */
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
await fillComposerDraft(page, text);
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
@@ -0,0 +1,178 @@
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> {
// Reload must not reopen the card under a stationary pointer.
await page.mouse.move(0, 0);
await page.reload({ waitUntil: "domcontentloaded" });
await expectComposerVisible(page);
}
/** Wide screens show the meter's details in a hover card while the pointer is on it. */
export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
await expect(card).toBeInViewport();
return card;
}
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
const box = await card.boundingBox();
if (!meter || !box) throw new Error("The meter and its hover card must be on screen.");
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(contextWindowDetails(page)).toHaveCount(0);
}
/** Compact screens open the meter's details in a sheet. */
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
const sheet = contextWindowDetails(page);
await expect(sheet).toBeVisible();
return sheet;
}
export async function closeContextWindowSheet(page: Page): Promise<void> {
const sheet = contextWindowDetails(page);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
/** The hover card on wide screens and the sheet on compact ones are both this dialog. */
export function contextWindowDetails(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 hover card 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(
@@ -163,7 +163,6 @@ async function expectVerticalOrder<Key extends string>(
/** Persisted footer row key -> the testID the app shell renders that row with. */
function shellFooterTestID(key: string): string {
// With nothing pinned the Usage item is the plain Usage row.
if (key === "usage") return "sidebar-usage";
const [, pluginId, itemId] = key.split(":");
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
@@ -198,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
.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 = page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-toggle-${key}`);
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");
}
@@ -226,6 +236,9 @@ const FOOTER_ICON_TEST_IDS = [
"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.
@@ -241,10 +254,12 @@ export async function expectFooterIconRow(page: Page): Promise<void> {
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
expect(box.width).toBe(first!.width);
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));
expect(gaps).toEqual([0, 0, expect.any(Number), 0]);
for (const index of [0, 1, 3]) {
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
}
expect(gaps[2]).toBeGreaterThan(first!.width);
}
@@ -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/,
);
}
}
@@ -5,6 +5,7 @@ import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
export interface UsageListRequest {
forceRefresh: boolean;
reportIds?: string[];
agentId?: string;
}
export interface UsageReportsFixture {
@@ -19,15 +20,25 @@ interface UsageReportsFixtureOptions {
* `fetchedAt`, or a different answer to a forced refresh).
*/
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
/** False simulates a host with no usage reporting capability. */
usageSupported?: boolean;
/**
* 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;
@@ -114,6 +125,8 @@ export async function installUsageReportsFixture(
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const usageSupported = options.usageSupported ?? true;
const isUsageSupported = () =>
typeof usageSupported === "function" ? usageSupported() : usageSupported;
const providerUsageListOnly = options.providerUsageListOnly ?? false;
const requestType = providerUsageListOnly
? "provider.usage.list.request"
@@ -131,6 +144,7 @@ export async function installUsageReportsFixture(
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);
@@ -139,22 +153,51 @@ export async function installUsageReportsFixture(
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType,
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;
if (!providerUsageListOnly) {
for (const report of reports) sendUpdate(report);
}
const reply = providerUsageListOnly
? {
type: "provider.usage.list.response",
@@ -165,12 +208,17 @@ export async function installUsageReportsFixture(
providerId: entry.sourceId,
displayName: entry.sourceLabel,
fetchedAt: entry.fetchedAt,
...entry.report,
planLabel: entry.report.planLabel ?? null,
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, reports } };
: { type: "usage.list_reports.response", payload: { requestId, error: null } };
ws.send(JSON.stringify({ type: "session", message: reply }));
listCounter.increment();
return;
@@ -181,7 +229,7 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
@@ -6,6 +6,7 @@ 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";
import { waitForSettledPosition } from "./sheet-layout";
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
export async function installTallUsageSource() {
@@ -36,8 +37,7 @@ import { z } from "zod";
export default function contribute(server) {
server.registerUsageSource({
id: "tall-usage", label: "Scrolling account", input: z.object({}),
discover: async () => [{}],
identify: async () => ({ key: "scrolling-account" }),
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,
})) }),
@@ -137,7 +137,7 @@ function visible(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
/** The sidebar footer's Usage item: each summary window with data. */
export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
@@ -157,10 +157,10 @@ export async function expectPinnedUsage(page: Page, windows: string[]): Promise<
await expect(pinned).toHaveText(windows);
}
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
export async function expectNoPinnedUsage(page: Page): Promise<void> {
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
await expect(usageItem(page)).toHaveText("Usage");
/** 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". */
@@ -169,6 +169,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
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();
@@ -176,21 +189,86 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
/** The usage title row's options menu: Refresh and Used/Remaining. */
/** 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);
}
/** Both footer entry points open Usage over the current screen on a phone. */
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Usage", exact: true }).click();
await expect(usageSheet(page)).toBeVisible();
}
export async function closeUsageSheet(page: Page): Promise<void> {
const sheet = usageSheet(page);
await waitForSettledPosition(sheet);
const bounds = await sheet.boundingBox();
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
await expect(sheet).toHaveCount(0);
}
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 });
await closeUsageOptions(page);
}
/** Open the cog's settings popover or compact sheet. */
export async function openUsageOptions(page: Page): Promise<void> {
await page.locator('[data-testid="usage-options-menu"]:visible').first().click();
await expect(page.getByTestId("usage-display-used")).toBeVisible();
if (!(await visible(page, "usage-display-used").isVisible())) {
await visible(page, "usage-options-menu").click();
}
await expect(visible(page, "usage-display-used")).toBeVisible();
}
export function usageOptionsSurface(page: Page): Locator {
return page
.locator(
'[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible',
)
.first();
}
export async function expectUsageOptionsFitContent(page: Page): Promise<void> {
await waitForSettledPosition(usageOptionsSurface(page));
const surface = await usageOptionsSurface(page).boundingBox();
const fields = await page.getByTestId("usage-options-fields").boundingBox();
if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit.");
expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10);
}
export async function closeUsageOptions(page: Page): Promise<void> {
await waitForSettledPosition(usageOptionsSurface(page));
await page.mouse.click(0, 0);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
}
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
await openUsageOptions(page);
await page.getByTestId(`usage-display-${displayAs}`).click();
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await visible(page, `usage-display-${displayAs}`).click();
await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute(
"aria-selected",
"true",
);
await closeUsageOptions(page);
}
export async function refreshAllUsage(page: Page): Promise<void> {
await openUsageOptions(page);
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
await visible(page, "usage-refresh-all").click();
}
/** Opens the compact sidebar drawer. */
@@ -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",
});
}
@@ -0,0 +1,53 @@
appId: sh.paseo.debug
---
# Start in a workspace with the sidebar closed. Both workspace IDs come from
# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts
# below the viewport. The list must remain unchanged throughout the flow.
- tapOn:
id: menu-button
- scrollUntilVisible:
element:
id: sidebar-workspace-row-${TOP_WORKSPACE}
direction: UP
timeout: 60000
- assertNotVisible:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- scrollUntilVisible:
element:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
direction: DOWN
timeout: 60000
- waitForAnimationToEnd
- assertVisible:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- assertNotVisible:
id: sidebar-workspace-row-${TOP_WORKSPACE}
- tapOn:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- waitForAnimationToEnd
- assertNotVisible:
id: sidebar-project-workspace-list-scroll
- swipe:
start: "5%,50%"
end: "90%,50%"
duration: 300
- waitForAnimationToEnd
- assertVisible:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- assertNotVisible:
id: sidebar-workspace-row-${TOP_WORKSPACE}
# Selecting the current workspace removes workspace switching as a cause.
# Reopening by button removes horizontal movement as a cause.
- tapOn:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- waitForAnimationToEnd
- tapOn:
id: menu-button
- waitForAnimationToEnd
- assertVisible:
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
- assertNotVisible:
id: sidebar-workspace-row-${TOP_WORKSPACE}
- tapOn:
id: sidebar-close
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0-beta.1",
"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",
@@ -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;
},
};
}
@@ -491,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(() => {
@@ -504,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");
@@ -516,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");
@@ -535,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>();
@@ -585,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(
@@ -621,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;
@@ -682,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));
});
@@ -724,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;
@@ -786,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));
});
@@ -798,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"));
});
@@ -831,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;
@@ -892,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));
});
@@ -901,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"));
});
@@ -937,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;
@@ -992,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));
});
@@ -1004,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"));
});
@@ -1038,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 });
@@ -1084,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,
@@ -1124,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();
@@ -1132,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 }),
@@ -1159,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 });
@@ -1204,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,
@@ -1232,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();
@@ -1248,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 = () =>
@@ -1276,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();
@@ -1295,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,
@@ -1329,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();
@@ -1390,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 });
@@ -1447,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();
@@ -1483,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"));
+149 -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",
@@ -304,6 +287,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
imageContext,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -348,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);
@@ -389,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,
@@ -404,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, contentMaxWidth) : 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 => {
@@ -442,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,
};
});
@@ -453,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();
@@ -512,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) {
@@ -543,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);
},
});
@@ -569,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;
@@ -660,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;
@@ -731,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(() => {
@@ -769,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) {
@@ -801,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,
]);
@@ -814,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();
@@ -826,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) {
@@ -959,9 +927,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} else {
lastObservedViewportGeometryRef.current = nextGeometry;
}
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
reconcileReadingPosition();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
@@ -980,7 +946,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
observer.disconnect();
};
}, [
applyHistoryStartPrependAnchor,
reconcileReadingPosition,
evaluateHistoryStart,
isActive,
reportReadingPosition,
@@ -1101,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 });
@@ -1115,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);
@@ -1188,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",
@@ -1,3 +1,4 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
@@ -94,6 +95,7 @@ export interface StreamRenderInput {
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export interface ResolveStreamRenderStrategyInput {
+1
View File
@@ -1131,6 +1131,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
imageContext: { serverId: resolvedServerId, workspaceRoot },
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -12,7 +12,6 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
shouldAdjustScrollForVirtualRowResize,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./web-virtualization";
@@ -124,8 +123,18 @@ describe("splitWebVirtualizedHistory", () => {
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
expect(
estimateStreamItemHeight({
item: toolCall("tool", 1),
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
).toBe(40);
expect(
estimateStreamItemHeight({
item: thought("thought", 2),
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
@@ -147,7 +156,40 @@ describe("estimateStreamItemHeight", () => {
],
};
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
expect(estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH })).toBe(
220,
);
});
it("uses local image dimensions only from the row's server and workspace", () => {
clearAssistantImageMetadataCache();
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
setAssistantImageMetadata(
{ source: "portrait.png", ...imageContext },
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "local-image",
text: "![Portrait](portrait.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
}),
).toBe(464);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, serverId: "server-two" },
}),
).toBe(464);
});
it("uses cached assistant image metadata when available", () => {
@@ -166,56 +208,9 @@ describe("estimateStreamItemHeight", () => {
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
});
});
describe("virtual row resize anchoring", () => {
it("does not move the viewport when a visible row expands while detached", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 1200,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(false);
});
it("keeps the reading position when a row above the viewport changes size", () => {
expect(
shouldAdjustScrollForVirtualRowResize({
isHistoryStartPrependActive: false,
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
}),
).toBe(true);
});
it("leaves history prepend and bottom following to their existing anchors", () => {
const baseInput = {
rowStart: 800,
scrollOffset: 1000,
remainingDistanceFromBottom: 5000,
bottomThreshold: 64,
};
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: true,
}),
).toBe(false);
expect(
shouldAdjustScrollForVirtualRowResize({
...baseInput,
isHistoryStartPrependActive: false,
remainingDistanceFromBottom: 64,
}),
).toBe(false);
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
).toBeGreaterThan(220);
});
});
@@ -1,3 +1,4 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
@@ -10,21 +11,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
export function shouldAdjustScrollForVirtualRowResize(input: {
isHistoryStartPrependActive: boolean;
rowStart: number;
scrollOffset: number;
remainingDistanceFromBottom: number;
bottomThreshold: number;
}): boolean {
if (input.isHistoryStartPrependActive) {
return false;
}
return (
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
);
}
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
};
@@ -58,12 +44,28 @@ export interface WebVirtualizedHistoryWindow {
mountedEntries: IndexedStreamItem[];
}
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
interface StreamItemHeightEstimateInput {
item: StreamItem;
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export function estimateStreamItemHeight({
item,
contentMaxWidth,
imageContext,
}: StreamItemHeightEstimateInput): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
return (
estimateAssistantMessageHeightFromCache({
markdown: item.text,
contentMaxWidth,
imageContext,
}) ?? 220
);
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
+9 -6
View File
@@ -1,6 +1,7 @@
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
@@ -113,7 +114,7 @@ import {
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme";
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
@@ -972,11 +973,13 @@ function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</LucideProvider>
</View>
</GestureHandlerRootView>
);
@@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
ambiguousQuery: capturedResolution.ambiguousQuery,
token: capturedResolution.token,
});
const run = async () => {
@@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
ambiguousQuery: string;
token: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
input.ambiguousQuery,
];
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
}
async function dispatchResolvedLink(input: {
@@ -415,8 +415,8 @@ export function useAssistantImage({
const preview = dataImage ? dataImagePreview : filePreview;
const previewUri = preview.status === "loaded" ? preview.uri : null;
const uri = directUri ?? previewUri;
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
const cachedAspectRatio = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
[serverId, source, workspaceRoot],
);
const [lifecycle, dispatchLifecycle] = useReducer(
@@ -454,9 +454,9 @@ export function useAssistantImage({
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedMetadata?.aspectRatio ?? null,
aspectRatio: cachedAspectRatio,
});
}, [cachedMetadata, dispatch, uri]);
}, [cachedAspectRatio, dispatch, uri]);
const handleImageError = useCallback(() => {
if (uri) {
@@ -484,7 +484,7 @@ export function useAssistantImage({
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
if (!aspectRatio) {
dispatch({
type: "failed",
@@ -495,7 +495,7 @@ export function useAssistantImage({
}
dispatch({ type: "image_loaded", uri, aspectRatio });
},
[cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot],
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
);
const acquisitionFailure = getAcquisitionFailure({
@@ -539,6 +539,6 @@ export function useAssistantImage({
return {
status: "loading",
binding,
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
};
}
@@ -20,7 +20,7 @@ export interface AudioEngine {
toggleMute(): boolean;
isMuted(): boolean;
play(audio: AudioPlaybackSource): Promise<number>;
play(audio: AudioPlaybackSource, signal?: AbortSignal): Promise<number>;
stop(): void;
clearQueue(): void;
isPlaying(): boolean;
@@ -0,0 +1,224 @@
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
import { createPlaybackQueue } from "./playback";
import { playFile } from "./file-playback";
import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
const refs: {
initialized: boolean;
captureActive: boolean;
muted: boolean;
destroyed: boolean;
} = {
initialized: false,
captureActive: false,
muted: false,
destroyed: false,
};
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
"onMicrophoneData",
(event: { data: Uint8Array }) => {
if (!refs.captureActive || refs.muted) {
return;
}
const pcm = event.data;
callbacks.onCaptureData(pcm);
},
);
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
"onInputVolumeLevelData",
(event: { data: number }) => {
if (!refs.captureActive) {
return;
}
const level = refs.muted ? 0 : event.data;
callbacks.onVolumeLevel(level);
},
);
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data !== "blocked") {
return;
}
const wasCaptureActive = refs.captureActive;
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
if (wasCaptureActive) {
callbacks.onInterruption?.();
}
},
);
async function ensureInitialized(): Promise<void> {
if (refs.initialized) {
return;
}
const success = await native.initialize();
if (!success) {
throw new Error("expo-two-way-audio: native initialize() returned false");
}
refs.initialized = true;
}
/**
* Release the OS audio session as soon as we are neither capturing nor playing.
* Holding it keeps the user's background music paused — on iOS the non-mixing
* `.playAndRecord` category survives backgrounding and is re-asserted on every
* foreground, so an unreleased session means their music never comes back.
*/
function releaseSessionIfIdle(): void {
if (!refs.initialized || refs.destroyed) {
return;
}
if (refs.captureActive || playback.isPlaying()) {
return;
}
// The wrapper no-ops on binaries whose native module predates this function.
native.releaseAudioSession();
}
async function ensureMicrophonePermission(): Promise<void> {
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
if (!permission?.granted) {
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
}
if (!permission?.granted) {
throw new Error(
"Microphone permission is required to capture audio. Please enable microphone access in system settings.",
);
}
}
let nextFileId = 0;
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
const bytes = new Uint8Array(await audio.arrayBuffer());
if (signal.aborted) throw new Error("Playback stopped");
if (audio.type.startsWith("audio/pcm")) {
await ensureInitialized();
return playPcm16(bytes, audio.type, signal, native);
}
// Capture owns its audio session while active. File playback alone must not
// initialize the microphone or the native two-way engine.
if (!refs.captureActive) {
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: false,
interruptionMode: "duckOthers",
interruptionModeAndroid: "duckOthers",
});
}
if (signal.aborted) throw new Error("Playback stopped");
// AVPlayer needs a file extension to recognize local encoded audio on iOS.
const extension =
{
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/wave": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/ogg": "ogg",
"audio/flac": "flac",
}[audio.type.split(";")[0].trim()] ?? "audio";
const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`);
try {
file.write(bytes);
const player = createAudioPlayer(file.uri, {
updateInterval: 100,
keepAudioSessionActive: refs.captureActive,
});
return await playFile(player, signal);
} finally {
if (file.exists) file.delete();
}
}
const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle);
return {
async initialize() {
await ensureInitialized();
},
async destroy() {
if (refs.destroyed) {
return;
}
refs.destroyed = true;
playback.destroy();
if (refs.captureActive) {
native.toggleRecording(false);
refs.captureActive = false;
}
refs.muted = false;
callbacks.onVolumeLevel(0);
if (refs.initialized) {
native.tearDown();
refs.initialized = false;
}
microphoneSubscription.remove();
volumeSubscription.remove();
interruptionSubscription.remove();
},
async startCapture() {
if (refs.captureActive) {
return;
}
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording = native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
);
}
refs.captureActive = true;
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
callbacks.onError?.(wrapped);
throw wrapped;
}
},
async stopCapture() {
if (refs.captureActive) {
native.toggleRecording(false);
}
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
releaseSessionIfIdle();
},
toggleMute() {
refs.muted = !refs.muted;
if (refs.muted) {
callbacks.onVolumeLevel(0);
}
return refs.muted;
},
isMuted() {
return refs.muted;
},
play: playback.play,
stop: playback.stop,
clearQueue: playback.clearQueue,
isPlaying: playback.isPlaying,
};
}
@@ -1,15 +1,8 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioPlaybackSource,
} from "@/voice/audio-engine-types";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
interface QueuedAudio {
audio: AudioPlaybackSource;
resolve: (duration: number) => void;
reject: (error: Error) => void;
}
import { createPlaybackQueue } from "./playback";
function getAudioContextCtor(): typeof AudioContext | null {
if (typeof window === "undefined") {
@@ -21,40 +14,6 @@ function getAudioContextCtor(): typeof AudioContext | null {
return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null;
}
function floatToInt16(sample: number): number {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
}
function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array {
if (input.length === 0) {
return new Uint8Array(0);
}
const ratio = inputRate / outputRate;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const output = new Int16Array(outputLength);
for (let i = 0; i < outputLength; i += 1) {
const sourceIndex = i * ratio;
const i0 = Math.floor(sourceIndex);
const i1 = Math.min(input.length - 1, i0 + 1);
const frac = sourceIndex - i0;
const sample = input[i0] * (1 - frac) + input[i1] * frac;
output[i] = floatToInt16(sample);
}
return new Uint8Array(output.buffer, output.byteOffset, output.byteLength);
}
function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
return null;
}
const rate = Number(match[1]);
return Number.isFinite(rate) && rate > 0 ? rate : null;
}
function pcm16LeToAudioBuffer(
context: AudioContext,
bytes: Uint8Array,
@@ -98,14 +57,6 @@ export function createAudioEngine(
gain: GainNode | null;
started: boolean;
muted: boolean;
queue: QueuedAudio[];
processingQueue: boolean;
activePlayback: {
source: AudioBufferSourceNode;
resolve: (duration: number) => void;
reject: (error: Error) => void;
settled: boolean;
} | null;
} = {
playbackContext: null,
captureContext: null,
@@ -115,29 +66,31 @@ export function createAudioEngine(
gain: null,
started: false,
muted: false,
queue: [],
processingQueue: false,
activePlayback: null,
};
async function ensurePlaybackContext(): Promise<AudioContext> {
if (refs.playbackContext) {
if (refs.playbackContext.state === "suspended") {
await refs.playbackContext.resume().catch(() => undefined);
}
return refs.playbackContext;
if (!refs.playbackContext) {
const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) throw new Error("AudioContext unavailable");
refs.playbackContext = new AudioContextCtor();
}
const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) {
throw new Error("AudioContext unavailable");
}
const context = new AudioContextCtor();
const context = refs.playbackContext;
if (context.state === "suspended") {
await context.resume().catch(() => undefined);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
context.resume(),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error("Audio playback requires a user interaction")),
2000,
);
}),
]);
} finally {
clearTimeout(timeout);
}
}
refs.playbackContext = context;
return context;
}
@@ -162,7 +115,7 @@ export function createAudioEngine(
return context;
}
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
const context = await ensurePlaybackContext();
const arrayBuffer = await audio.arrayBuffer();
const type = (audio.type || "").toLowerCase();
@@ -174,53 +127,39 @@ export function createAudioEngine(
)
: await decodeAudioData(context, arrayBuffer);
if (signal.aborted) throw new Error("Playback stopped");
const durationSec = audioBuffer.duration;
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
return await new Promise<number>((resolve, reject) => {
refs.activePlayback = { source, resolve, reject, settled: false };
const settle = (fn: () => void) => {
const active = refs.activePlayback;
if (!active || active.source !== source || active.settled) {
return;
}
active.settled = true;
refs.activePlayback = null;
fn();
const abort = () => {
source.stop();
source.disconnect();
reject(new Error("Playback stopped"));
};
source.addEventListener("ended", () => {
settle(() => resolve(durationSec));
});
signal.addEventListener("abort", abort, { once: true });
source.addEventListener(
"ended",
() => {
signal.removeEventListener("abort", abort);
source.disconnect();
resolve(durationSec);
},
{ once: true },
);
try {
source.start();
} catch (error) {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
signal.removeEventListener("abort", abort);
source.disconnect();
reject(error);
}
});
}
async function processQueue(): Promise<void> {
if (refs.processingQueue || refs.queue.length === 0) {
return;
}
refs.processingQueue = true;
while (refs.queue.length > 0) {
const item = refs.queue.shift()!;
try {
const duration = await playAudio(item.audio);
item.resolve(duration);
} catch (error) {
item.reject(error instanceof Error ? error : new Error(String(error)));
}
}
refs.processingQueue = false;
}
const playback = createPlaybackQueue(playAudio);
async function stopCapture(): Promise<void> {
refs.started = false;
@@ -263,8 +202,7 @@ export function createAudioEngine(
},
async destroy() {
this.stop();
this.clearQueue();
playback.destroy();
await stopCapture();
const playbackContext = refs.playbackContext;
@@ -334,7 +272,8 @@ export function createAudioEngine(
return;
}
callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000));
const pcm = resampleToPcm16(input, context.sampleRate, 16000);
callbacks.onCaptureData(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
};
source.connect(processor);
@@ -370,40 +309,9 @@ export function createAudioEngine(
return refs.muted;
},
async play(audio: AudioPlaybackSource) {
return await new Promise<number>((resolve, reject) => {
refs.queue.push({ audio, resolve, reject });
if (!refs.processingQueue) {
void processQueue();
}
});
},
stop() {
if (refs.activePlayback) {
const active = refs.activePlayback;
refs.activePlayback = null;
try {
active.source.stop();
} catch {
// Ignore best-effort stop errors.
}
if (!active.settled) {
active.settled = true;
active.reject(new Error("Playback stopped"));
}
}
},
clearQueue() {
while (refs.queue.length > 0) {
refs.queue.shift()!.reject(new Error("Playback stopped"));
}
refs.processingQueue = false;
},
isPlaying() {
return refs.activePlayback !== null;
},
play: playback.play,
stop: playback.stop,
clearQueue: playback.clearQueue,
isPlaying: playback.isPlaying,
};
}
+61
View File
@@ -0,0 +1,61 @@
export interface FilePlaybackStatus {
playbackState: string;
isLoaded: boolean;
didJustFinish: boolean;
duration: number;
}
export interface FilePlayer {
play(): void;
remove(): void;
addListener(
event: "playbackStatusUpdate",
listener: (status: FilePlaybackStatus) => void,
): { remove(): void };
}
/** Own completion, load failure and resource release for the native file backend. */
export function playFile(player: FilePlayer, signal: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
let settled = false;
// expo-audio 1.x does not report Android decoder errors. A local file that
// never becomes loaded must still release its queue slot and temporary file.
let timeout: ReturnType<typeof setTimeout> | undefined;
const finish = (error?: unknown, duration = 0) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
signal.removeEventListener("abort", abort);
subscription.remove();
player.remove();
if (error) reject(error);
else resolve(duration);
};
const abort = () => finish(new Error("Playback stopped"));
let loaded = false;
const subscription = player.addListener("playbackStatusUpdate", (status) => {
if (status.playbackState === "error" || status.playbackState === "failed") {
finish(new Error("Audio file could not be decoded"));
} else if (status.didJustFinish) {
finish(undefined, status.duration);
} else if (status.isLoaded && status.duration > 0 && !loaded) {
loaded = true;
clearTimeout(timeout);
timeout = setTimeout(
() => finish(new Error("Audio playback timed out")),
(status.duration + 30) * 1000,
);
}
});
timeout = setTimeout(() => finish(new Error("Audio file could not be loaded")), 15000);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
else {
try {
player.play();
} catch (error) {
finish(error);
}
}
});
}
+3
View File
@@ -0,0 +1,3 @@
export { createAudioEngine } from "./audio-engine";
export type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
export { createPlayAudio } from "./play-audio";
+124
View File
@@ -0,0 +1,124 @@
const floatToInt16 = (sample: number): number => {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
};
export const resampleToPcm16 = (
input: Float32Array,
inputRate: number,
outputRate: number,
): Int16Array => {
if (input.length === 0) {
return new Int16Array(0);
}
if (inputRate === outputRate) {
const out = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
out[i] = floatToInt16(input[i]);
}
return out;
}
const ratio = inputRate / outputRate;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const out = new Int16Array(outputLength);
for (let i = 0; i < outputLength; i++) {
const sourceIndex = i * ratio;
const i0 = Math.floor(sourceIndex);
const i1 = Math.min(input.length - 1, i0 + 1);
const frac = sourceIndex - i0;
const sample = input[i0] * (1 - frac) + input[i1] * frac;
out[i] = floatToInt16(sample);
}
return out;
};
export function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
return null;
}
const rate = Number(match[1]);
return Number.isFinite(rate) && rate > 0 ? rate : null;
}
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
if (fromRate === toRate) {
return pcm;
}
const inputSamples = Math.floor(pcm.length / 2);
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
const out = new Uint8Array(outputSamples * 2);
const ratio = fromRate / toRate;
const readInt16 = (sampleIndex: number): number => {
const i = sampleIndex * 2;
if (i + 1 >= pcm.length) {
return 0;
}
const lo = pcm[i];
const hi = pcm[i + 1];
let value = (hi << 8) | lo;
if (value & 0x8000) {
value = value - 0x10000;
}
return value;
};
const writeInt16 = (sampleIndex: number, value: number): void => {
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
const i = sampleIndex * 2;
out[i] = clamped & 0xff;
out[i + 1] = (clamped >> 8) & 0xff;
};
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const i0 = Math.floor(srcPos);
const frac = srcPos - i0;
const s0 = readInt16(i0);
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
writeInt16(i, s0 + (s1 - s0) * frac);
}
return out;
}
interface PcmOutput {
resumePlayback(): void;
playPCMData(bytes: Uint8Array): void;
stopPlayback(): void;
}
/** Keep voice output on the native communication engine and its echo reference. */
export function playPcm16(
bytes: Uint8Array,
mimeType: string,
signal: AbortSignal,
output: PcmOutput,
): Promise<number> {
if (signal.aborted) return Promise.reject(new Error("Playback stopped"));
const pcm = resamplePcm16(bytes, parsePcmSampleRate(mimeType) ?? 24000, 16000);
const duration = pcm.length / 2 / 16000;
return new Promise((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const abort = () => {
clearTimeout(timeout);
output.stopPlayback();
reject(new Error("Playback stopped"));
};
signal.addEventListener("abort", abort, { once: true });
try {
output.resumePlayback();
output.playPCMData(pcm);
timeout = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve(duration);
}, duration * 1000);
} catch (error) {
signal.removeEventListener("abort", abort);
reject(error);
}
});
}
+202
View File
@@ -0,0 +1,202 @@
import { expect, test } from "vitest";
import { createPlayAudio } from "./play-audio";
import { playPcm16, parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { playFile, type FilePlaybackStatus, type FilePlayer } from "./file-playback";
const source = { base64: "UklGRg==", mimeType: "audio/wav" };
test("passes RPC file bytes to the shared engine and awaits completion", async () => {
const owner = new AbortController();
let finish!: () => void;
const played = new Promise<void>((resolve) => {
finish = resolve;
});
let completed = false;
const play = createPlayAudio(
{
async play(audio, signal) {
expect(signal).toBe(owner.signal);
expect(audio.type).toBe("audio/wav");
expect(audio.size).toBe(4);
expect(new Uint8Array(await audio.arrayBuffer())).toEqual(new Uint8Array([82, 73, 70, 70]));
await played;
return 1;
},
},
owner.signal,
);
const result = play(source).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
finish();
await result;
expect(completed).toBe(true);
});
test.each([
{ ...source, base64: "garbage!" },
{ ...source, base64: "" },
{ ...source, mimeType: "text/plain" },
])("rejects invalid input before reaching a decoder: %o", async (input) => {
const play = createPlayAudio(
{
play() {
throw new Error("Reached decoder");
},
},
new AbortController().signal,
);
await expect(play(input)).rejects.toThrow(/^Audio must/);
});
test("rejects calls from an unloaded plugin", async () => {
const owner = new AbortController();
owner.abort(new Error("Plugin unloaded"));
const play = createPlayAudio(
{
play() {
throw new Error("Reached decoder");
},
},
owner.signal,
);
await expect(play(source)).rejects.toThrow("Playback stopped");
});
test("voice PCM retains its native output and 16 kHz samples", async () => {
const pcm = new Uint8Array([0, 0, 255, 127, 0, 128]);
let written: Uint8Array | undefined;
const duration = await playPcm16(
pcm,
"audio/pcm;rate=16000;bits=16",
new AbortController().signal,
{
resumePlayback() {},
playPCMData(bytes) {
written = bytes;
},
stopPlayback() {},
},
);
expect(written).toEqual(pcm);
expect(duration).toBe(3 / 16000);
});
test("voice PCM cancellation stops native output and rejects", async () => {
let stopped = false;
const owner = new AbortController();
const result = playPcm16(new Uint8Array(32000), "audio/pcm;rate=16000;bits=16", owner.signal, {
resumePlayback() {},
playPCMData() {},
stopPlayback() {
stopped = true;
},
});
owner.abort();
await expect(result).rejects.toThrow("Playback stopped");
expect(stopped).toBe(true);
});
function filePlayer() {
let listener: (status: FilePlaybackStatus) => void = () => {};
let removed = false;
let started = false;
const player: FilePlayer = {
play() {
started = true;
},
remove() {
removed = true;
},
addListener(_event, callback) {
listener = callback;
return {
remove() {
listener = () => {};
},
};
},
};
return {
player,
status: (status: Partial<FilePlaybackStatus>) =>
listener({
playbackState: "ready",
duration: 1,
didJustFinish: false,
isLoaded: true,
...status,
}),
removed: () => removed,
started: () => started,
};
}
test("native file playback waits for completion and releases its player", async () => {
const h = filePlayer();
const promise = playFile(h.player, new AbortController().signal);
expect(h.started()).toBe(true);
h.status({ didJustFinish: true });
await expect(promise).resolves.toBe(1);
expect(h.removed()).toBe(true);
});
test("native decoder errors reject and release the player", async () => {
const h = filePlayer();
const promise = playFile(h.player, new AbortController().signal);
h.status({ playbackState: "failed" });
await expect(promise).rejects.toThrow("could not be decoded");
expect(h.removed()).toBe(true);
});
test("native cancellation releases playback and settles the caller", async () => {
const h = filePlayer();
const owner = new AbortController();
const promise = playFile(h.player, owner.signal);
owner.abort(new Error("Plugin unloaded"));
await expect(promise).rejects.toThrow("Playback stopped");
expect(h.removed()).toBe(true);
});
test.each(["audio/pcm;rate=16000;bits=16", "AUDIO/PCM; rate=16000"])(
"rejects raw PCM instead of initializing voice capture: %s",
async (mimeType) => {
const play = createPlayAudio(
{
play: async () => {
throw new Error("Reached engine");
},
},
new AbortController().signal,
);
await expect(play({ base64: "AAAAAA==", mimeType })).rejects.toThrow(
"Pass an audio file, such as WAV or MP3",
);
},
);
// Voice capture and browser dictation use this same PCM boundary.
test("capture PCM preserves clipping, signed samples, and empty input", () => {
expect(resampleToPcm16(new Float32Array([-2, -1, -0.5, 0, 0.5, 1, 2]), 16000, 16000)).toEqual(
new Int16Array([-32768, -32768, -16384, 0, 16384, 32767, 32767]),
);
expect(resampleToPcm16(new Float32Array(), 48000, 16000)).toEqual(new Int16Array());
});
test("capture PCM resamples with interpolation and preserves the last sample", () => {
expect(resampleToPcm16(new Float32Array([-1, 0, 1]), 24000, 16000)).toEqual(
new Int16Array([-32768, 16384]),
);
expect(resampleToPcm16(new Float32Array([0, 1]), 8000, 16000)).toEqual(
new Int16Array([0, 16384, 32767, 32767]),
);
});
test("voice playback shares PCM sample-rate parsing", () => {
expect(parsePcmSampleRate("audio/pcm;rate=16000;bits=16")).toBe(16000);
expect(parsePcmSampleRate("audio/PCM;RATE=48000")).toBe(48000);
expect(parsePcmSampleRate("audio/pcm")).toBeNull();
expect(parsePcmSampleRate("audio/pcm;rate=0")).toBeNull();
});
+38
View File
@@ -0,0 +1,38 @@
import { Buffer } from "buffer";
import type { AudioEngine } from "./audio-engine-types";
/** RPC-friendly file input. No browser Blob, URL, or platform knowledge is needed. */
export function createPlayAudio(engine: Pick<AudioEngine, "play">, signal: AbortSignal) {
return async (source: { base64: string; mimeType: string }): Promise<void> => {
if (signal.aborted) throw new Error("Playback stopped");
if (
!source ||
typeof source.base64 !== "string" ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(source.base64) ||
source.base64.length % 4 !== 0 ||
!source.base64.length
) {
throw new Error("Audio must contain a non-empty base64-encoded file");
}
if (
typeof source.mimeType !== "string" ||
!/^audio\/[\w.+-]+(?:[ \t]*;[^\r\n]+)?$/i.test(source.mimeType)
) {
throw new Error("Audio must have an audio MIME type, such as audio/wav");
}
const bytes = Uint8Array.from(Buffer.from(source.base64, "base64"));
const [mediaType, ...parameters] = source.mimeType.split(";");
const type = [mediaType.trim().toLowerCase(), ...parameters].join(";");
if (mediaType.trim().toLowerCase() === "audio/pcm") {
throw new Error("Pass an audio file, such as WAV or MP3; raw PCM is not supported");
}
await engine.play(
{
type,
size: bytes.byteLength,
arrayBuffer: async () => bytes.buffer,
},
signal,
);
};
}
+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from "vitest";
import { createPlaybackQueue } from "./playback";
function harness() {
const started: string[] = [];
const finishes: (() => void)[] = [];
const queue = createPlaybackQueue<string>((source, signal) => {
started.push(source);
return new Promise<number>((resolve, reject) => {
finishes.push(() => resolve(1));
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { once: true });
});
});
return { queue, started, finishes };
}
test("plays in order and resolves only after playback completes", async () => {
const h = harness();
const first = h.queue.play("first");
const second = h.queue.play("second");
expect(h.started).toEqual(["first"]);
h.finishes[0]();
await expect(first).resolves.toBe(1);
expect(h.started).toEqual(["first", "second"]);
h.finishes[1]();
await expect(second).resolves.toBe(1);
});
test("cancels one owner's queued and active work without stopping another owner", async () => {
const h = harness();
const owner = new AbortController();
const first = h.queue.play("first", owner.signal);
const queued = h.queue.play("cancelled", owner.signal);
const other = h.queue.play("other");
const rejected = Promise.all([
expect(first).rejects.toThrow("Playback stopped"),
expect(queued).rejects.toThrow("Playback stopped"),
]);
owner.abort(new Error("unloaded"));
await rejected;
expect(h.started).toEqual(["first", "other"]);
h.finishes[1]();
await expect(other).resolves.toBe(1);
});
test("stop during loading cannot start cancelled audio or strand the next call", async () => {
const h = harness();
const first = h.queue.play("loading");
const rejected = expect(first).rejects.toThrow("Playback stopped");
h.queue.stop();
await rejected;
const second = h.queue.play("next");
h.finishes[1]();
await expect(second).resolves.toBe(1);
});
test("a failed file does not block the queue", async () => {
const queue = createPlaybackQueue<string>(async (source) => {
if (source === "invalid") throw new Error("Invalid audio");
return 2;
});
const failed = queue.play("invalid");
const next = queue.play("valid");
await expect(failed).rejects.toThrow("Invalid audio");
await expect(next).resolves.toBe(2);
});
test("ending the voice waiting cue leaves a plugin's active audio playing", async () => {
const { createVoiceRuntime } = await import("../voice/voice-runtime");
let finishPlugin!: () => void;
let cueQueued!: () => void;
const queued = new Promise<void>((resolve) => {
cueQueued = resolve;
});
const output = createPlaybackQueue<{ type: string }>(
(_source, signal) =>
new Promise((resolve, reject) => {
finishPlugin = () => resolve(1);
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), {
once: true,
});
}),
);
const engine = {
initialize: async () => {},
destroy: async () => {
output.destroy();
},
startCapture: async () => {},
stopCapture: async () => {},
toggleMute: () => false,
isMuted: () => false,
play(source: { type: string }, signal?: AbortSignal) {
if (source.type.startsWith("audio/pcm")) cueQueued();
return output.play(source, signal);
},
stop: output.stop,
clearQueue: output.clearQueue,
isPlaying: output.isPlaying,
};
const runtime = createVoiceRuntime({
engine,
getServerInfo: () => ({
serverId: "host",
hostname: "host",
version: "1.0.0",
capabilities: {
voice: { dictation: { enabled: true, reason: "" }, voice: { enabled: true, reason: "" } },
},
}),
activateKeepAwake: async () => {},
deactivateKeepAwake: async () => {},
});
runtime.registerSession({
serverId: "host",
setVoiceMode: async () => {},
sendVoiceAudioChunk: async () => {},
audioPlayed: async () => {},
abortRequest: async () => {},
setAssistantAudioPlaying: () => {},
});
try {
await runtime.startVoice("host", "agent");
const result = engine.play({ type: "audio/wav" }).then(
() => "finished",
() => "stopped",
);
runtime.onTurnEvent("host", "agent", "turn_started");
await queued;
runtime.onAssistantAudioStarted("host");
finishPlugin();
expect(await result).toBe("finished");
} finally {
await runtime.destroy();
}
});
+81
View File
@@ -0,0 +1,81 @@
/** One queue for voice and plugin audio, including cancellation while loading. */
export function createPlaybackQueue<Source>(
start: (source: Source, signal: AbortSignal) => Promise<number>,
onIdle: () => void = () => {},
) {
interface Item {
source: Source;
controller: AbortController;
resolve: (duration: number) => void;
reject: (error: unknown) => void;
release: () => void;
}
const pending: Item[] = [];
let active: Item | null = null;
let destroyed = false;
async function drain() {
if (active) return;
const item = pending.shift();
if (!item) {
onIdle();
return;
}
active = item;
try {
if (item.controller.signal.aborted) throw new Error("Playback stopped");
item.resolve(await start(item.source, item.controller.signal));
} catch (error) {
item.reject(error);
} finally {
item.release();
active = null;
void drain();
}
}
const stopped = () => new Error("Playback stopped");
return {
play(source: Source, signal?: AbortSignal): Promise<number> {
if (destroyed) return Promise.reject(new Error("Audio engine destroyed"));
if (signal?.aborted) return Promise.reject(new Error("Playback stopped"));
return new Promise((resolve, reject) => {
const controller = new AbortController();
const cancel = () => {
controller.abort();
const index = pending.indexOf(item);
if (index !== -1) {
pending.splice(index, 1);
item.release();
reject(new Error("Playback stopped"));
}
};
const item: Item = {
source,
controller,
resolve,
reject,
release: () => signal?.removeEventListener("abort", cancel),
};
signal?.addEventListener("abort", cancel, { once: true });
pending.push(item);
void drain();
});
},
stop() {
active?.controller.abort();
},
clearQueue() {
for (const item of pending.splice(0)) {
item.release();
item.reject(stopped());
}
},
isPlaying: () => active !== null,
destroy() {
destroyed = true;
this.clearQueue();
this.stop();
},
};
}
@@ -6,7 +6,6 @@ import {
Folder,
FolderOpen,
FolderPlus,
Github,
HardDrive,
Plus,
Search,
@@ -34,6 +33,7 @@ import {
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
applyAvailableAddProjectHosts,
backAddProjectPage,
@@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) {
}
function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] {
if (method === "github") return Github;
if (method === "github") return GitHubIcon;
if (method === "browse") return FolderOpen;
if (method === "new-directory") return FolderPlus;
return Search;
@@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
: repository.nameWithOwner,
subtitle: repository.description,
icon: Github,
icon: GitHubIcon,
testID: `add-project-flow-repository-${repository.id}`,
select: () =>
setState((current) => openGithubLocationPage(current, page.hostId, repository)),
@@ -0,0 +1,82 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AgentUsage } from "@/usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowDetailsProps {
serverId: string;
agentId: string;
context: {
percentage: number;
usedTokens: number;
maxTokens: number;
} | null;
sessionCost: string | null;
/** The hover card and tooltip title themselves; the sheet's header carries the title instead. */
showTitle: boolean;
/** Whether the usage cards have a Refresh button; without one they show their freshness. */
refreshable: boolean;
}
/**
* What the context window meter opens: how full the window is, then the usage of the agent's
* account. The desktop hover card, the native wide tooltip, and the compact sheet all render it.
*/
export function ContextWindowDetails({
serverId,
agentId,
context,
sessionCost,
showTitle,
refreshable,
}: ContextWindowDetailsProps) {
const { t } = useTranslation();
return (
<>
<View style={styles.summary}>
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
{context ? (
<>
<Text style={styles.text}>
{t("contextWindow.used", { percentage: context.percentage })}
</Text>
<Text style={styles.detail}>
{t("contextWindow.tokens", {
used: formatTokenCount(context.usedTokens),
max: formatTokenCount(context.maxTokens),
})}
</Text>
</>
) : (
<Text style={styles.detail}>{t("contextWindow.noData")}</Text>
)}
{sessionCost ? (
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
) : null}
</View>
<AgentUsage serverId={serverId} agentId={agentId} refreshable={refreshable} />
</>
);
}
const styles = StyleSheet.create((theme) => ({
summary: {
gap: theme.spacing[1.5],
minWidth: 200,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
}));
@@ -1,28 +1,34 @@
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { formatTokenCount } from "./context-window-meter.utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
interface ContextWindowMeterProps {
serverId: string;
agentId: string;
maxTokens: number | null;
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const USAGE_POPOVER_WIDTH = 300;
const COMPACT_SVG_SIZE = 12;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
@@ -53,28 +59,22 @@ function formatSessionCost(value: number): string | null {
return `$${value.toFixed(2)}`;
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
function getProgressColor(percentage: number, theme: Theme): string {
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
return theme.colors.destructive;
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
return theme.colors.palette.amber[500];
}
return { progress: theme.colors.foregroundMuted, track };
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
@@ -82,131 +82,221 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
containerStyle: styles.container,
};
}
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
const ContextWindowRing = withUnistyles(function ContextWindowRing({
size,
radius,
strokeWidth,
percentage,
trackColor,
progressColor,
}: {
size: number;
radius: number;
strokeWidth: number;
percentage: number | null;
trackColor: string;
progressColor: string;
}) {
const center = size / 2;
const circumference = 2 * Math.PI * radius;
return (
<Svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{percentage !== null ? (
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={progressColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference - (clampPercentage(percentage) / 100) * circumference}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
) : null}
</Svg>
);
});
export function ContextWindowMeter({
serverId,
agentId,
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const openSheet = useCallback(() => setIsSheetOpen(true), []);
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, glyphSize);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const context = useMemo(
() =>
percentage !== null && maxTokens !== null && usedTokens !== null
? { percentage: Math.round(percentage), maxTokens, usedTokens }
: null,
[percentage, maxTokens, usedTokens],
);
const meterColors = useCallback(
(theme: Theme) => ({
progressColor: getProgressColor(percentage ?? 0, theme),
trackColor: theme.colors.surface3,
}),
[percentage],
);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const ring = (
<ContextWindowRing
size={geometry.svgSize}
radius={geometry.radius}
strokeWidth={geometry.strokeWidth}
percentage={percentage}
uniProps={meterColors}
/>
);
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
: t("contextWindow.accessibilityNoData");
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
if (isCompact) {
return (
<>
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={openSheet}
>
<Svg
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={strokeWidth}
/>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.progress}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
{showPercentage ? (
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
) : null}
{ring}
{percentageLabel}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8} testID="context-window-meter-tooltip">
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
<Text style={styles.tooltipText}>
{t("contextWindow.used", { percentage: roundedPercentage })}
</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ContextWindowSheet open={isSheetOpen} onClose={closeSheet}>
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
context={context}
sessionCost={formattedSessionCost}
showTitle={false}
refreshable
/>
</ContextWindowSheet>
</>
);
}
const popoverStyle = showsUsage
? [styles.usagePopover, { width: popoverWidth }]
: styles.plainPopover;
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
// takes no presses, so its usage cards have no Refresh.
if (isNative) {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
>
{ring}
{percentageLabel}
</Pressable>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
offset={8}
maxWidth={showsUsage ? popoverWidth : undefined}
style={popoverStyle}
testID="context-window-meter-tooltip"
>
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
context={context}
sessionCost={formattedSessionCost}
showTitle
refreshable={false}
/>
</TooltipContent>
</Tooltip>
);
}
return (
<HoverCard>
<HoverCardTrigger focusable accessibilityLabel={accessibilityLabel}>
<View
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
>
{ring}
{percentageLabel}
</View>
</TooltipContent>
</Tooltip>
</HoverCardTrigger>
<HoverCardContent
placement="top"
offset={8}
role="dialog"
accessibilityLabel={t("contextWindow.title")}
testID="context-window-details"
style={popoverStyle}
>
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
context={context}
sessionCost={formattedSessionCost}
showTitle
refreshable
/>
</HoverCardContent>
</HoverCard>
);
}
@@ -231,28 +321,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.base,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
minWidth: 200,
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
// Plain details use a small inset; account usage cards have their own content density.
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
}));
@@ -0,0 +1,80 @@
import { useMemo, type ReactNode } from "react";
import { View, useWindowDimensions } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
// track, so the sheet is wrapped rather than reading the theme through a hook.
// See docs/unistyles.md.
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
}));
/**
* The context window meter's details on compact screens, in a sheet as tall as its content.
* Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once
* the sheet closes.
*/
export function ContextWindowSheet({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
const { height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]);
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: open,
onClose,
});
return (
<ThemedBottomSheetModal
ref={sheetRef}
contextBridge={null}
// Content-sized rather than fixed snap points: the usage cards stream in one at a time.
enableDynamicSizing
maxDynamicContentSize={height * 0.8}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropOpacity={0.45}
enablePanDownToClose
>
<BottomSheetScrollView
contentContainerStyle={scrollContentStyle}
showsVerticalScrollIndicator={false}
>
<View role="dialog" aria-label={header.title}>
<SheetHeaderView header={header} onClose={onClose} />
{/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */}
<View style={styles.body}>{children}</View>
</View>
</BottomSheetScrollView>
</ThemedBottomSheetModal>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
}));
+72 -49
View File
@@ -26,6 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -52,7 +53,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, useOpenUsageScreen } from "@/usage";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -275,7 +281,7 @@ function FooterIconButton({
testID,
label,
icon: Icon,
iconSize,
iconSizeAdjustment = 0,
shortcutKeys,
theme,
}: {
@@ -284,17 +290,20 @@ function FooterIconButton({
label: string;
icon: typeof FolderPlus;
/** Only for a glyph that reads larger than the others at the same size. */
iconSize?: number;
iconSizeAdjustment?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
style={styles.footerIconButton(isCompact)}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -305,7 +314,7 @@ function FooterIconButton({
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
size={iconSize + iconSizeAdjustment}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -367,7 +376,7 @@ function SidebarHostPicker({
label={label}
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSize={theme.iconSize.md - 1}
iconSizeAdjustment={-1}
theme={theme}
/>
</HostPicker>
@@ -414,54 +423,68 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const openUsageScreen = useOpenUsageScreen();
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
return (
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={openUsageScreen}
testID="sidebar-usage-icon"
label={labels.usage}
icon={CircleGauge}
theme={theme}
/>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
</View>
</UsageSidebarRoot>
);
}
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
onPress={openUsage}
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
/**
* The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The
* Usage item is left out while it has no summary to show.
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const hasUsageSummary = useHasUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter((item) => item.visible);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
);
if (visibleItems.length === 0) return null;
return (
<>
@@ -917,7 +940,7 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
// Buttons sit edge to edge; their own inset spaces the glyphs.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
@@ -934,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: {
width: 28,
height: 28,
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -113,6 +113,85 @@ describe("splitHtmlishMarkdown", () => {
]);
});
it("renders a linked picture inside a heading without exposing its HTML", () => {
const source = [
'<h2><a href="https://example.com/retrigger?id=73851569"><picture>',
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
'<source media="(prefers-color-scheme: light)" srcset="https://example.com/light.svg">',
'<img alt="Retrigger" src="https://example.com/default.svg" width="85" height="24">',
"</picture></a></h2>",
"",
"Confidence Score: 5/5",
"",
"<details><summary>Summary</summary>No outstanding findings.</details>",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/default.svg",
href: "https://example.com/retrigger?id=73851569",
width: 85,
height: 24,
},
{ kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" },
{ kind: "details", summary: "Summary", body: "No outstanding findings." },
]);
});
it.each([
["", ""],
["<h3>", "</h3>"],
['<a href="javascript:alert(1)">', "</a>"],
])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => {
const picture =
'<picture><source srcset="https://example.com/alternate.svg"/><img src="https://example.com/default.svg" alt="Image"></picture>';
expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([
{ kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" },
]);
});
it("keeps picture examples literal inside inline and fenced code", () => {
const picture =
'<picture><source srcset="https://example.com/alternate.svg"><img src="https://example.com/default.svg"></picture>';
const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => {
const source = `<${wrapper}><a href="https://example.com/page"><picture><source srcset="https://example.com/dark.svg"><img src="https://example.com/default.svg" alt="Image"></picture></a></${wrapper}>`;
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
src: "https://example.com/default.svg",
alt: "Image",
href: "https://example.com/page",
},
]);
});
it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => {
const source = `<${wrapper}><img src="https://example.com/image.svg" alt="Image"></${wrapper}>Next section`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" },
{ kind: "markdown", text: "Next section" },
]);
});
it("handles deeply nested image wrappers without exhausting the stack", () => {
const source = `${"<div>".repeat(10000)}<a href="https://example.com/page"><picture><img src="https://example.com/image.svg" alt="Image"></picture></a>${"</div>".repeat(10000)}`;
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
src: "https://example.com/image.svg",
alt: "Image",
href: "https://example.com/page",
},
]);
});
it("preserves inline image parts inside details bodies", () => {
expect(
splitHtmlishMarkdown(
@@ -29,7 +29,7 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
const VOID_HTML_TAGS = new Set(["br", "img"]);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
@@ -140,25 +140,73 @@ function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImagePars
return image ? { part: image, end: start + 1 } : null;
}
if (token.name !== "a") {
if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, "a");
if (closeIndex === null) {
return null;
}
return parseWrappedImageAt(tokens, start);
}
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
if (!image) {
return null;
}
function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
// Walk an image-only wrapper chain once. Recursing over copied child slices
// makes deeply nested review HTML quadratic and can exhaust the JS stack.
const wrappers: string[] = [];
let image: MarkdownInlineImagePart | null = null;
let href: string | undefined;
let hasLink = false;
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
for (let index = start; index < tokens.length; index += 1) {
const child = tokens[index];
if (child.kind === "comment" || isWhitespaceText(child)) {
continue;
}
if (child.kind !== "tag") {
return null;
}
if (child.closing) {
if (!image || wrappers.pop() !== child.name) {
return null;
}
if (wrappers.length === 0) {
return { part: image, end: index + 1 };
}
continue;
}
// Pictures use their img fallback, not responsive source selection.
if (child.name === "source" && wrappers.at(-1) === "picture") {
continue;
}
if (image) {
return null;
}
if (child.name === "img") {
image = imageTokenToInlineImage(child, href);
if (!image) {
return null;
}
continue;
}
if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) {
return null;
}
if (child.name === "a" && !hasLink) {
href = safeHref(child.attributes.href);
hasLink = true;
}
wrappers.push(child.name);
}
return null;
}
function isImageBlockWrapper(token: HtmlToken | undefined): boolean {
return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div");
}
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
if (isImageBlockWrapper(tokens[start])) {
return false;
}
const previous = tokens[start - 1];
// At line start if nothing precedes this image, or the preceding token ends with only
// whitespace since the last newline (covers a bare space between two images on the same line).
@@ -191,6 +239,9 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
}
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
if (token.kind === "tag") {
if (isImageBlockWrapper(token)) {
return false;
}
const imageResult = parseInlineImageAt(tokens, cursor);
if (imageResult) {
cursor = imageResult.end;
@@ -8,7 +8,6 @@ export interface InlineImageExplicitDimensions {
height?: number;
}
const INLINE_IMAGE_FALLBACK_SIZE = 16;
const INLINE_IMAGE_MAX_WIDTH = 240;
const INLINE_IMAGE_MAX_HEIGHT = 160;
@@ -59,5 +58,5 @@ function resolveInlineImageDimensions(input: {
return { width: input.explicit.height, height: input.explicit.height };
}
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT };
}
@@ -63,10 +63,10 @@ describe("resolveInlineImageSize", () => {
});
});
it("uses a generic small fallback when no dimensions are known", () => {
it("reserves a capped 3:2 placeholder when no dimensions are known", () => {
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
width: 16,
height: 16,
width: 240,
height: 160,
});
});
});
@@ -39,6 +39,10 @@ import {
type MarkdownInlineImagePart,
} from "./html-ish";
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
import { colorMarkdownLinkChildren } from "./link-children";
import { MarkdownLinkText } from "./link-text";
@@ -201,11 +205,16 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
failed: boolean;
setFailed: (failed: boolean) => void;
} {
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]);
const [resolved, setResolved] = useState<{
source: string;
dimensions: InlineImageDimensions;
} | null>(null);
const natural = resolved?.source === part.src ? resolved.dimensions : cached;
const [failed, setFailed] = useState(false);
useEffect(() => {
if (part.width && part.height) {
if ((part.width && part.height) || cached) {
return;
}
@@ -213,8 +222,9 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
Image.getSize(
part.src,
(width, height) => {
setAssistantImageMetadata({ source: part.src }, { width, height });
if (!cancelled) {
setNatural({ width, height });
setResolved({ source: part.src, dimensions: { width, height } });
}
},
() => {
@@ -227,7 +237,7 @@ function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
return () => {
cancelled = true;
};
}, [part.height, part.src, part.width]);
}, [cached, part.height, part.src, part.width]);
return { natural, failed, setFailed };
}
+6 -9
View File
@@ -1,3 +1,4 @@
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { TaskListRow } from "@/components/task-list-row";
import {
@@ -814,8 +815,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
function AssistantMarkdownImage({
source,
occurrenceKey,
@@ -860,11 +859,9 @@ function AssistantMarkdownImage({
[containerStyle],
);
const imageSizeStyle = useMemo<ViewStyle>(() => {
if (aspectRatio) {
return { aspectRatio };
}
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
}, [aspectRatio]);
if (image.status === "failed") return { height: 160 };
return { aspectRatio: aspectRatio ?? ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO };
}, [aspectRatio, image.status]);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
[imageSizeStyle],
@@ -882,10 +879,10 @@ function AssistantMarkdownImage({
() => [
assistantMessageStylesheet.imageFrame,
containerStyle,
{ height: ASSISTANT_IMAGE_MIN_HEIGHT },
imageSizeStyle,
assistantMessageStylesheet.imageState,
],
[containerStyle],
[containerStyle, imageSizeStyle],
);
if (image.status === "failed") {
@@ -0,0 +1,102 @@
import { ChevronRight } from "lucide-react-native";
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { isWeb } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
interface SettingsCollapsibleRowProps {
label: string;
defaultExpanded?: boolean;
testID?: string;
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
children: ReactNode;
}
/**
* A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a
* trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any
* other row.
*/
export function SettingsCollapsibleRow({
label,
defaultExpanded = false,
testID,
children,
}: SettingsCollapsibleRowProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
const webExpandedProps = useMemo(
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
hovered && styles.headerHovered,
pressed && styles.headerPressed,
],
[],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<View testID={testID}>
<Pressable
{...webExpandedProps}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
onPress={toggle}
style={headerStyle}
testID={testID ? `${testID}-toggle` : undefined}
>
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</View>
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
</>
)}
</Pressable>
{expanded
? Children.toArray(children).map((child, index) => (
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
{child}
</View>
))
: null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerHovered: {
backgroundColor: theme.colors.surface2,
},
headerPressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
@@ -26,6 +26,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
@@ -14,6 +14,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -103,14 +106,14 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
style={styles.trigger(isCompact)}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<ThemedCircleHelp
size={ICON_SIZE.md}
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
@@ -181,14 +184,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
+13 -14
View File
@@ -397,18 +397,6 @@ export function SplitContainer({
},
[workspaceKey],
);
const handleCreateExplorerTab = useCallback(
() => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }),
[explorerSidebarPaneId, onCreateNewTab],
);
const handleMoveExplorerTabToMain = useCallback(
(tabId: string) => {
if (layout.focusedPaneId) {
onMoveTabToPane(tabId, layout.focusedPaneId);
}
},
[layout.focusedPaneId, onMoveTabToPane],
);
const splitRoot = useMemo(
() =>
resolveSplitContainerRoot({
@@ -725,8 +713,19 @@ export function SplitContainer({
closingTabIds={closingTabIds}
onSelectTab={onSelectTabInPane}
onCloseTab={onCloseTab}
onCreateNewTab={handleCreateExplorerTab}
onMoveTabToMain={handleMoveExplorerTabToMain}
onCreateNewTab={onCreateNewTab}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onExitFocusMode={onExitFocusMode}
buildPaneContentModel={buildPaneContentModel}
onReorderTabsInPane={onReorderTabsInPane}
activeDragTabId={activeDragTabId}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -55,6 +55,18 @@ describe("computePosition", () => {
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
});
it("lets hover cards flip horizontally when the opposite side has room", () => {
const result = position({
triggerRect: { ...TRIGGER, x: 850 },
placement: "right",
flipHorizontal: true,
});
expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" });
expect(
position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement,
).toBe("right");
});
it("keeps the surface inside a display area that does not start at the origin", () => {
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
@@ -1,10 +1,8 @@
import type { View } from "react-native";
/**
* Where a floating menu surface goes relative to the thing that opened it.
*
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
* Submenus need a third caller, so it lives here now and the menus import it.
* Where a floating surface goes relative to the thing that opened it. Menus and hover cards
* place themselves with it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
@@ -38,14 +36,15 @@ export function measureElement(element: View): Promise<Rect> {
* there is more room above. Both conditions matter: flipping into a side that is equally
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
*
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
* caller choosing its side up front, because flipping it mid-open would move the surface out
* from under the pointer that is travelling toward it.
* Horizontal flipping is opt-in for hover cards. Submenus choose their direction up front so
* they do not move out from under the pointer travelling toward them.
*/
function flipPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentHeight: number;
contentWidth: number;
flipHorizontal: boolean;
displayArea: Rect;
}): Placement {
const { placement, triggerRect, contentHeight, displayArea } = input;
@@ -58,6 +57,14 @@ function flipPlacement(input: {
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
return "bottom";
}
if (input.flipHorizontal) {
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
if (placement === "right" && spaceRight < input.contentWidth && spaceLeft > spaceRight)
return "left";
if (placement === "left" && spaceLeft < input.contentWidth && spaceRight > spaceLeft)
return "right";
}
return placement;
}
@@ -120,6 +127,7 @@ export function computePosition({
placement,
alignment,
offset,
flipHorizontal = false,
}: {
triggerRect: Rect;
contentSize: Size;
@@ -127,11 +135,15 @@ export function computePosition({
placement: Placement;
alignment: Alignment;
offset: number;
/** Hover cards can choose either side; submenu direction is owned by the menu. */
flipHorizontal?: boolean;
}): { x: number; y: number; actualPlacement: Placement } {
const actualPlacement = flipPlacement({
placement,
triggerRect,
contentHeight: contentSize.height,
contentWidth: contentSize.width,
flipHorizontal,
displayArea,
});
const anchored = anchorToPlacement({
@@ -0,0 +1,405 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import {
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import {
computePosition,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "./anchor";
const CLOSE_GRACE_MS = 100;
interface HoverCardContextValue {
open: boolean;
layer: number;
setTriggerRef: (node: View | null) => void;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
openNow: () => void;
scheduleClose: () => void;
}
const HoverCardContext = createContext<HoverCardContextValue | null>(null);
/**
* A card that opens while the pointer is on its trigger and stays open while the pointer crosses
* onto it, so its content can be pressed. Hover only exists on web; elsewhere the trigger renders
* alone and the content never mounts. See docs/hover.md.
*
* ```tsx
* <HoverCard>
* <HoverCardTrigger>{trigger}</HoverCardTrigger>
* <HoverCardContent placement="top">{details}</HoverCardContent>
* </HoverCard>
* ```
*/
export function HoverCard({
disabled = false,
children,
}: {
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
disabled?: boolean;
children: ReactNode;
}): ReactNode {
if (!isWeb) return children;
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
}
function WebHoverCard({
disabled,
children,
}: {
disabled: boolean;
children: ReactNode;
}): ReactElement {
const layer = useOverlayLayer("floating");
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current);
graceTimerRef.current = null;
}
}, []);
const focusInside = useCallback(() => {
if (!isWeb) return false;
const active = document.activeElement;
return [triggerRef.current, contentRef.current].some((view) =>
(view as unknown as HTMLElement | null)?.contains(active),
);
}, []);
const scheduleClose = useCallback(() => {
if (focusInside()) return;
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
setOpen(false);
}, CLOSE_GRACE_MS);
}, [focusInside]);
const openNow = useCallback(() => {
clearGraceTimer();
if (!disabled) {
setOpen(true);
}
}, [clearGraceTimer, disabled]);
const keyPressed = useCallback(
(event: KeyboardEvent): boolean => {
if (!isWeb) return false;
const trigger = triggerRef.current as unknown as HTMLElement | null;
if (!trigger) return false;
if (event.key === "Escape" && open) {
event.preventDefault();
if (focusInside()) trigger.focus();
clearGraceTimer();
setOpen(false);
return true;
} else if (
focusInside() &&
event.target === trigger &&
["ArrowDown", "Enter", " "].includes(event.key)
) {
event.preventDefault();
openNow();
requestAnimationFrame(() => {
(contentRef.current as unknown as HTMLElement | null)
?.querySelector<HTMLElement>(
'button, [role="button"][tabindex="0"], a[href], input, [tabindex="0"]',
)
?.focus();
});
return true;
}
return false;
},
[clearGraceTimer, focusInside, open, openNow],
);
const setOverlayScope = useWebOverlayRegistration({
active: !disabled && open,
layer,
onKeyDown: keyPressed,
manageFocus: false,
});
const setTriggerRef = useCallback(
(node: View | null) => {
triggerRef.current = node;
setOverlayScope(node);
},
[setOverlayScope],
);
useEffect(() => {
if (!isWeb) return;
const trigger = triggerRef.current as unknown as HTMLElement | null;
if (!trigger) return;
const close = () => {
clearGraceTimer();
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Node;
if (trigger.contains(target)) {
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
};
const focusLeft = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (
!trigger.contains(next) &&
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
) {
scheduleClose();
}
};
const activateClosedTrigger = (event: KeyboardEvent) => {
if (
!open &&
!event.defaultPrevented &&
!event.isComposing &&
["ArrowDown", "Enter", " "].includes(event.key)
)
keyPressed(event);
};
const scrolled = (event: Event) => {
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
close();
};
document.addEventListener("focusin", focusEntered);
document.addEventListener("focusout", focusLeft);
trigger.addEventListener("keydown", activateClosedTrigger);
document.addEventListener("scroll", scrolled, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("focusin", focusEntered);
document.removeEventListener("focusout", focusLeft);
trigger.removeEventListener("keydown", activateClosedTrigger);
document.removeEventListener("scroll", scrolled, true);
window.removeEventListener("resize", close);
};
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering
// it (including the bridge) cancels the pending close.
useHoverSafeZone({
enabled: open,
triggerRef,
contentRef,
onEnterSafeZone: clearGraceTimer,
onLeaveSafeZone: scheduleClose,
});
useEffect(() => {
if (disabled) {
clearGraceTimer();
setOpen(false);
}
}, [clearGraceTimer, disabled]);
useEffect(() => clearGraceTimer, [clearGraceTimer]);
const value = useMemo<HoverCardContextValue>(
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
[open, layer, setTriggerRef, openNow, scheduleClose],
);
return (
<OverlayLayerProvider layer={layer}>
<HoverCardContext.Provider value={value}>{children}</HoverCardContext.Provider>
</OverlayLayerProvider>
);
}
export function HoverCardTrigger({
children,
focusable = false,
accessibilityLabel,
}: {
children: ReactNode;
focusable?: boolean;
accessibilityLabel?: string;
}): ReactNode {
const ctx = useContext(HoverCardContext);
if (!ctx) return children;
return (
<View
ref={ctx.setTriggerRef}
tabIndex={focusable ? 0 : undefined}
accessibilityRole={focusable ? "button" : undefined}
accessibilityLabel={accessibilityLabel}
collapsable={false}
onPointerEnter={ctx.openNow}
onPointerLeave={ctx.scheduleClose}
>
{children}
</View>
);
}
interface HoverCardContentProps {
placement: Placement;
alignment?: Alignment;
offset?: number;
/** Layered over the default popover surface. */
style?: StyleProp<ViewStyle>;
role?: Role;
accessibilityLabel?: string;
testID?: string;
/** Mounted only while the card is open, so whatever it fetches runs only then. */
children: ReactNode;
}
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
const ctx = useContext(HoverCardContext);
if (!ctx?.open) return null;
return (
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
);
}
function HoverCardSurface({
layer,
triggerRef,
contentRef,
placement,
alignment = "center",
offset = 4,
style,
role,
accessibilityLabel,
testID,
children,
}: HoverCardContentProps & {
layer: number;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect(rect);
return undefined;
});
return () => {
cancelled = true;
};
}, [triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
triggerRect,
contentSize,
displayArea: { x: 0, y: 0, width, height },
placement,
flipHorizontal: true,
alignment,
offset,
});
setPosition({ x, y });
}, [alignment, contentSize, offset, placement, triggerRect]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
surface: {
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.xl,
...theme.shadow.md,
zIndex: 1000,
},
}));
+1 -1
View File
@@ -35,4 +35,4 @@ export {
type MenuPresentation,
} from "./menu-context";
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
export type { Alignment, Placement, Rect, Size } from "../anchor";
@@ -9,7 +9,7 @@ import {
openSubPage,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "./menu-anchor";
import type { Rect } from "../anchor";
/**
* How a menu draws itself once it is open.
@@ -38,7 +38,7 @@ import {
type Placement,
type Rect,
type Size,
} from "./menu-anchor";
} from "../anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
const CONTENT_ENTERING_DURATION_MS = 150;
@@ -26,7 +26,7 @@ import { MenuPage } from "./menu-item";
import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
import type { Alignment, Placement } from "./menu-anchor";
import type { Alignment, Placement } from "../anchor";
import type { KeyboardFocusScope } from "@/keyboard/actions";
const ThemedChevronLeft = withUnistyles(ChevronLeft);

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