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
215 changed files with 7790 additions and 2018 deletions
+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, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). 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:
+11 -6
View File
@@ -117,7 +117,12 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
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.
@@ -133,13 +138,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
still wins over source 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 install github:owner/repository
paseo plugin install owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install github:owner/monorepo:plugins/review
paseo plugin install owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -483,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`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `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.
+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
@@ -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();
+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();
});
@@ -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,
}) => {
@@ -20,6 +20,7 @@ import {
import {
leaveSettings,
openSidebarNavSettings,
seedSidebarFooterPreferences,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -154,8 +155,9 @@ test.describe("Plugin sidebar items", () => {
page,
}) => {
test.setTimeout(180_000);
// The Usage item shows only with summary data.
// 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"]);
@@ -188,6 +191,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
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);
+19 -29
View File
@@ -8,6 +8,7 @@ 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,
@@ -26,6 +27,8 @@ import {
expectPinnedUsage,
pinRow,
openUsageOptions,
closeUsageOptions,
expectUsageOptionsFitContent,
showUsageAs,
refreshAllUsage,
togglePin,
@@ -57,6 +60,11 @@ test.use({
},
});
// 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);
}
@@ -237,7 +245,7 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
});
@@ -420,7 +428,7 @@ for (const theme of ["light", "dark"] as const) {
desktop: { width: 1440, height: 900 },
compact: { width: 390, height: 844 },
})) {
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript((value) => {
const key = "@paseo:app-settings";
@@ -434,42 +442,24 @@ for (const theme of ["light", "dark"] as const) {
await page.goto("/usage");
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = screen.getByRole("button", { name: "Settings", exact: true });
const chevron = settings.locator("svg").locator("../..");
const settings = page.getByTestId("usage-options-menu");
await expect(settings).toHaveAccessibleName("Settings");
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(chevron).toHaveCSS("transform", "none");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
).toHaveCSS("opacity", "1");
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
await togglePin(screen, "Claude", "Weekly");
await page.mouse.move(0, viewport.height - 1);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
).toHaveCSS("opacity", "1");
await openUsageOptions(page);
await expect(settings).toHaveAttribute("aria-expanded", "true");
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
"aria-selected",
"true",
);
await settings.blur();
await page.mouse.move(0, viewport.height - 1);
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
await 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 settings.click();
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await openUsageOptions(page);
await closeUsageOptions(page);
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
});
@@ -95,8 +95,8 @@ test.describe("usage settings", () => {
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
// Percentages now live on the Usage screen, and still apply to the host section.
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
// 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");
@@ -5,6 +5,7 @@ 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,
@@ -13,12 +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,
@@ -57,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,
}) => {
@@ -93,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);
@@ -112,6 +144,8 @@ 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");
@@ -185,25 +219,22 @@ test.describe("Usage item", () => {
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the same inline Settings row and title-row Refresh all.
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
"aria-expanded",
"false",
);
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await openUsageOptions(page);
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
"true",
);
await qaScreenshot(page, "compact-settings-expanded");
await sheet.getByTestId("usage-options-toggle").click();
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())!;
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
// 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");
@@ -225,7 +256,7 @@ test.describe("Usage item", () => {
"true",
);
await qaScreenshot(page, "usage-settings-expanded");
await page.getByTestId("usage-options-toggle").click();
await closeUsageOptions(page);
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
@@ -298,26 +329,49 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
await expectOnUsageScreen(page);
});
test("the Usage Settings show and hide the sidebar Usage item", async ({ 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").getByRole("switch");
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 }) => {
@@ -325,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
@@ -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`) });
}
@@ -197,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");
}
@@ -101,7 +101,7 @@ 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: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
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 () => {};
@@ -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",
@@ -175,7 +218,7 @@ export async function installUsageReportsFixture(
})),
},
}
: { 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;
@@ -186,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() {
@@ -168,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();
@@ -175,13 +189,74 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
/** Expand the inline Settings row when its controls are folded. */
/** 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> {
const toggle = visible(page, "usage-options-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
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 visible(page, `usage-display-${displayAs}`).click();
@@ -189,6 +264,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
"aria-selected",
"true",
);
await closeUsageOptions(page);
}
export async function refreshAllUsage(page: Page): Promise<void> {
@@ -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
@@ -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: {
@@ -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],
},
}));
+64 -47
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, useHasUsageSummary, 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,46 +423,54 @@ 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>
);
}
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}
/>
);
}
@@ -923,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",
@@ -940,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",
@@ -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,
@@ -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);
@@ -523,6 +523,7 @@ export function TooltipContent({
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
@@ -549,6 +550,7 @@ export function TooltipContent({
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
role="tooltip"
testID={testID}
onLayout={handleLayout}
style={contentStyle}
@@ -1,16 +1,12 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Check,
@@ -27,17 +23,13 @@ import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { useHosts } from "@/runtime/host-runtime";
import {
COUNTED_CHECK_PRESENTATIONS,
@@ -48,52 +40,6 @@ import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-cop
import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view";
import { buildForgeChecksUrl } from "@/git/forge-url";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
offset: number;
}): { x: number; y: number } {
let x = triggerRect.x + triggerRect.width + offset;
let y = triggerRect.y;
// If it overflows right, try left
if (x + contentSize.width > displayArea.width - 8) {
x = triggerRect.x - contentSize.width - offset;
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
);
return { x, y };
}
const HOVER_GRACE_MS = 100;
const HOVER_CARD_WIDTH = 260;
interface WorkspaceHoverCardProps {
@@ -110,237 +56,80 @@ export function WorkspaceHoverCard({
disabled = false,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
if (!isWeb || isCompact) {
if (isCompact) {
return children;
}
return (
<WorkspaceHoverCardDesktop
workspace={workspace}
prHint={prHint}
isDragging={isDragging}
disabled={disabled}
>
{children}
</WorkspaceHoverCardDesktop>
);
}
function WorkspaceHoverCardDesktop({
workspace,
prHint,
isDragging,
disabled = false,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerHoveredRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current);
graceTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
setOpen(false);
}, HOVER_GRACE_MS);
}, []);
const handleTriggerEnter = useCallback(() => {
triggerHoveredRef.current = true;
clearGraceTimer();
if (!isDragging && !disabled) {
setOpen(true);
}
}, [clearGraceTimer, disabled, isDragging]);
const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false;
scheduleClose();
}, [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,
});
// Close while another row interaction owns attention.
useEffect(() => {
if (isDragging || disabled) {
clearGraceTimer();
setOpen(false);
}
}, [clearGraceTimer, disabled, isDragging]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearGraceTimer();
};
}, [clearGraceTimer]);
return (
<View
ref={triggerRef}
collapsable={false}
onPointerEnter={handleTriggerEnter}
onPointerLeave={handleTriggerLeave}
>
{children}
{open ? (
<WorkspaceHoverCardContent
workspace={workspace}
prHint={prHint}
triggerRef={triggerRef}
contentRef={contentRef}
/>
) : null}
</View>
<HoverCard disabled={isDragging || disabled}>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCardContent
placement="right"
role="menu"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
>
<WorkspaceHoverCardContent workspace={workspace} prHint={prHint} />
</HoverCardContent>
</HoverCard>
);
}
function WorkspaceHoverCardContent({
workspace,
prHint,
triggerRef,
contentRef,
}: {
workspace: SidebarWorkspaceEntry;
prHint: PrHint | null;
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
}): ReactElement {
const { t } = useTranslation();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
// Measure trigger — same pattern as tooltip.tsx
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect(rect);
return;
});
return () => {
cancelled = true;
};
}, [triggerRef]);
// Compute position when both measurements are available
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset: 4,
});
setPosition(result);
}, [triggerRect, contentSize]);
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],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
accessibilityRole="menu"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
frameStyle={frameStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
{workspace.diffStat ? (
<View style={styles.cardInfoRow}>
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
</View>
) : null}
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
value={workspace.currentBranch}
copyValue={workspace.currentBranch}
copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch"
/>
) : null}
{workspace.workspaceDirectoryLabel ? (
<CopyableInfoRow
icon={ThemedFolder}
value={workspace.workspaceDirectoryLabel}
copyValue={workspace.workspaceDirectory}
copyLabel={t("workspace.hoverCard.copyPath")}
testID="hover-card-workspace-cwd"
/>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable
checks={prHint.checks}
url={prHint.url}
forge={prHint.forge}
/>
</>
) : null}
</FloatingSurface>
<>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
</Portal>
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
{workspace.diffStat ? (
<View style={styles.cardInfoRow}>
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
</View>
) : null}
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
value={workspace.currentBranch}
copyValue={workspace.currentBranch}
copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch"
/>
) : null}
{workspace.workspaceDirectoryLabel ? (
<CopyableInfoRow
icon={ThemedFolder}
value={workspace.workspaceDirectoryLabel}
copyValue={workspace.workspaceDirectory}
copyLabel={t("workspace.hoverCard.copyPath")}
testID="hover-card-workspace-cwd"
/>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} forge={prHint.forge} />
</>
) : null}
</>
);
}
@@ -561,14 +350,6 @@ function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; ho
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
card: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
@@ -581,7 +362,6 @@ const styles = StyleSheet.create((theme) => ({
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
zIndex: 1000,
},
cardHeader: {
flexDirection: "row",
+22 -66
View File
@@ -22,11 +22,11 @@ import {
useImperativeHandle,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
@@ -278,37 +278,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
};
}
function renderContextWindowMeter(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
pending: boolean,
glyphSize: number,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
return null;
}
return (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
pending={pending}
glyphSize={glyphSize}
/>
);
}
function resolveContextWindowPlacement(
meter: ReactElement | null,
reserveSlot: boolean,
): ReactNode {
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
}
interface RenderLeftContentArgs {
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
@@ -1002,16 +971,6 @@ interface ComposerProps {
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
rawMax: number | null,
rawUsed: number | null,
): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } {
if (typeof rawMax === "number" && typeof rawUsed === "number") {
return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed };
}
return { contextWindowMaxTokens: null, contextWindowUsedTokens: null };
}
interface ComposerAutocompleteHandle {
onKeyPress: (event: ComposerKeyPressEvent) => boolean;
}
@@ -1308,6 +1267,7 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
const hasText = useSyncExternalStore(
@@ -2065,36 +2025,31 @@ function ComposerContentImpl({
],
);
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
);
const contextWindowPending = agentState.status === "initializing" || isAgentRunning;
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const contextWindowMeter = useMemo(
const beforeVoiceContent = useMemo(
() =>
renderContextWindowMeter(
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
contextWindowPending,
contextWindowMeterGlyphSize,
),
hasAgent ? (
<View style={styles.contextWindowMeterSlot}>
<ContextWindowMeter
serverId={serverId}
agentId={agentId}
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={contextWindowMeterGlyphSize}
/>
</View>
) : null,
[
contextWindowMaxTokens,
contextWindowUsedTokens,
hasAgent,
serverId,
agentId,
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
() => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}</>,
[contextWindowMeter, hasAgent],
);
const hasGithubAttachment = useMemo(
() =>
@@ -2375,7 +2330,8 @@ function ComposerContentImpl({
{ disabled: isSubmitLoadingVisible },
);
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
// Focusing the composer on a touch screen raises the on-screen keyboard over the conversation.
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
() =>
@@ -94,14 +94,30 @@ describe("hover safe-zone tracker", () => {
expect(handle.leaves).toBe(1);
});
it("bridges the gap to content above or below the trigger", () => {
const handle = createHandle({
trigger: { left: 100, right: 130, top: 60, bottom: 90 },
content: { left: 0, right: 300, top: 0, bottom: 52 },
});
// In the gap between the card and the trigger below it.
handle.pointerMoved(115, 56);
expect(handle.enters).toBe(1);
expect(handle.leaves).toBe(0);
// Beside the trigger, past the gap — outside.
handle.pointerMoved(200, 75);
expect(handle.leaves).toBe(1);
});
it("treats overlapping trigger and content as having no bridge", () => {
const handle = createHandle({
trigger: { left: 0, right: 200, top: 0, bottom: 50 },
content: { left: 100, right: 300, top: 60, bottom: 100 },
content: { left: 100, right: 300, top: 40, bottom: 100 },
});
// Outside both rects, in what would be a bridge — should be outside.
handle.pointerMoved(150, 55);
// Outside both rects, in the corner neither of them covers — should be outside.
handle.pointerMoved(250, 20);
expect(handle.enters).toBe(0);
expect(handle.leaves).toBe(1);
});
@@ -62,9 +62,20 @@ function isInsideSafeZone(
if (isInsideRect(trigger, x, y)) return true;
if (isInsideRect(content, x, y)) return true;
if (!trigger || !content) return false;
return (
isInsideHorizontalBridge(trigger, content, x, y) ||
isInsideVerticalBridge(trigger, content, x, y)
);
}
// Bridge: the horizontal strip connecting trigger and content, stretched
// vertically to span both. If they overlap horizontally there's no bridge.
// A card beside its trigger: the strip between them, stretched vertically to
// span both. If they overlap horizontally there's no horizontal bridge.
function isInsideHorizontalBridge(
trigger: RectLike,
content: RectLike,
x: number,
y: number,
): boolean {
const bridgeLeft = Math.min(trigger.right, content.right);
const bridgeRight = Math.max(trigger.left, content.left);
if (bridgeLeft >= bridgeRight) return false;
@@ -72,3 +83,19 @@ function isInsideSafeZone(
const bridgeBottom = Math.max(trigger.bottom, content.bottom);
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
}
// A card above or below its trigger: the strip between them, stretched
// horizontally to span both. If they overlap vertically there's no vertical bridge.
function isInsideVerticalBridge(
trigger: RectLike,
content: RectLike,
x: number,
y: number,
): boolean {
const bridgeTop = Math.min(trigger.bottom, content.bottom);
const bridgeBottom = Math.max(trigger.top, content.top);
if (bridgeTop >= bridgeBottom) return false;
const bridgeLeft = Math.min(trigger.left, content.left);
const bridgeRight = Math.max(trigger.right, content.right);
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
}
@@ -150,7 +150,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
contentMaxWidth: null,
syntaxTheme: "one",
workspaceTitleSource: "title",
sidebarWorkspaceTrailing: "diff",
sidebarWorkspaceTrailing: "timestamp",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarNavItems: [],
@@ -242,7 +242,7 @@ const StoredAppSettingsSchema = z
.catch(null),
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"),
sidebarRowItems: SidebarRowItemsSchema,
sidebarChecksDisplay: z
.enum(["iconAndText", "icon", "none"])
+2
View File
@@ -1931,6 +1931,8 @@ export const ar: TranslationResources = {
dismiss: "رفض",
},
contextWindow: {
noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
title: "نافذة السياق",
used: "تم استخدام{{percentage}}%",
tokens: "رموز{{used}}/{{max}}",
+2
View File
@@ -1956,6 +1956,8 @@ export const en = {
dismiss: "Dismiss",
},
contextWindow: {
noData: "No context data",
accessibilityNoData: "Context window: No context data",
title: "Context window",
used: "{{percentage}}% used",
tokens: "{{used}} / {{max}} tokens",
+2
View File
@@ -1979,6 +1979,8 @@ export const es: TranslationResources = {
dismiss: "Despedir",
},
contextWindow: {
noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
title: "ventana contextual",
used: "{{percentage}}% utilizado",
tokens: "Fichas{{used}}/{{max}}",
+2
View File
@@ -1984,6 +1984,8 @@ export const fr: TranslationResources = {
dismiss: "Rejeter",
},
contextWindow: {
noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
title: "Fenêtre contextuelle",
used: "{{percentage}}% utilisé",
tokens: "Jetons{{used}}/{{max}}",
+2
View File
@@ -1951,6 +1951,8 @@ export const ja: TranslationResources = {
dismiss: "閉じる",
},
contextWindow: {
noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
title: "コンテキストウィンドウ",
used: "{{percentage}}%使用",
tokens: "{{used}} / {{max}}トークン",
+2
View File
@@ -1941,6 +1941,8 @@ export const ko: TranslationResources = {
dismiss: "닫기",
},
contextWindow: {
noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
title: "컨텍스트 윈도우",
used: "{{percentage}}% 사용됨",
tokens: "{{used}} / {{max}} 토큰",
+2
View File
@@ -1964,6 +1964,8 @@ export const ptBR: TranslationResources = {
dismiss: "Dispensar",
},
contextWindow: {
noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto",
title: "Janela de contexto",
used: "{{percentage}}% usado",
tokens: "{{used}} / {{max}} tokens",
+2
View File
@@ -1964,6 +1964,8 @@ export const ru: TranslationResources = {
dismiss: "Закрыть",
},
contextWindow: {
noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте",
title: "Контекстное окно",
used: "Использовано: {{percentage}}%",
tokens: "Токены: {{used}} / {{max}}",
+2
View File
@@ -1909,6 +1909,8 @@ export const zhCN: TranslationResources = {
dismiss: "关闭",
},
contextWindow: {
noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据",
title: "上下文窗口",
used: "已使用 {{percentage}}%",
tokens: "{{used}} / {{max}} tokens",
+21 -8
View File
@@ -88,6 +88,7 @@ interface WebOverlayEntry {
getScope: () => HTMLElement | null;
getKeyHandler: () => WebOverlayKeyHandler;
restoreFocus: HTMLElement | null;
manageFocus: boolean;
}
const webOverlayEntries: WebOverlayEntry[] = [];
@@ -140,7 +141,7 @@ function focusFirstElement(scope: HTMLElement): void {
function handleWebOverlayFocus(event: FocusEvent): void {
const top = getTopWebOverlay();
const scope = top?.getScope();
if (!scope || scope.contains(event.target as Node)) return;
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
if (webOverlayFocusCheckQueued) return;
// React can autofocus a child before its parent scope ref attaches. Defer
@@ -149,8 +150,10 @@ function handleWebOverlayFocus(event: FocusEvent): void {
webOverlayFocusCheckQueued = true;
queueMicrotask(() => {
webOverlayFocusCheckQueued = false;
const currentScope = getTopWebOverlay()?.getScope();
if (!currentScope || currentScope.contains(document.activeElement)) return;
const currentTop = getTopWebOverlay();
const currentScope = currentTop?.getScope();
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
return;
focusFirstElement(currentScope);
});
}
@@ -169,7 +172,7 @@ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean {
const scope = top?.getScope();
if (!top || !scope) return false;
if (event.key === "Tab") {
if (top.manageFocus && event.key === "Tab") {
const focusable = getFocusableElements(scope);
const first = focusable[0];
const last = focusable[focusable.length - 1];
@@ -214,7 +217,12 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
const focusFrame = window.requestAnimationFrame(() => {
const scope = entry.getScope();
if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) {
if (
entry.manageFocus &&
getTopWebOverlay() === entry &&
scope &&
!scope.contains(document.activeElement)
) {
focusFirstElement(scope);
}
});
@@ -225,6 +233,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
if (index !== -1) webOverlayEntries.splice(index, 1);
detachWebOverlayListeners();
if (
entry.manageFocus &&
options?.restoreFocus !== false &&
entry.restoreFocus &&
document.contains(entry.restoreFocus)
@@ -239,17 +248,20 @@ interface WebOverlayRegistration {
layer: number;
onKeyDown: WebOverlayKeyHandler;
restoreFocusRef?: React.RefObject<unknown>;
/** Hover surfaces claim overlay keys while preserving the user's existing focus. */
manageFocus?: boolean;
}
/**
* Registers a focus scope in the same relative layer model used for painting.
* Only the highest painted overlay receives keyboard input; focus is trapped
* there and restored to the opener when that overlay closes.
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
*/
export function useWebOverlayRegistration({
active,
layer,
onKeyDown,
manageFocus = true,
restoreFocusRef: preferredRestoreFocusRef,
}: WebOverlayRegistration) {
const idRef = useRef(Symbol("web-overlay"));
@@ -305,10 +317,11 @@ export function useWebOverlayRegistration({
getScope: () => scopeRef.current,
getKeyHandler: () => keyHandlerRef.current,
restoreFocus: capturedRestoreFocusRef.current,
manageFocus,
};
removeEntryRef.current = addWebOverlay(entry);
registeredRef.current = true;
}, []);
}, [manageFocus]);
const setScope = useCallback(
(node: unknown) => {
@@ -42,7 +42,15 @@ export function MobilePanelOverlay({
const overlayAnimatedStyle = useAnimatedStyle(() => {
const isVisible = isLeft ? position.value < 0 : position.value > 0;
return { display: isVisible ? ("flex" as const) : ("none" as const) };
if (isWeb) {
return { display: isVisible ? ("flex" as const) : ("none" as const) };
}
// Preserve native scroll ranges while hidden. Collapsing layout makes Android
// spring retained scroll offsets back to zero on touch cancellation or release.
return {
opacity: isVisible ? 1 : 0,
pointerEvents: isVisible ? ("box-none" as const) : ("none" as const),
};
}, [isLeft]);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
@@ -79,10 +87,7 @@ export function MobilePanelOverlay({
pointerEvents={overlayPointerEvents}
style={styles.overlay}
>
<Animated.View
pointerEvents={overlayPointerEvents}
style={[styles.overlay, overlayAnimatedStyle]}
>
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
<Pressable
accessible={false}
accessibilityElementsHidden
+12 -32
View File
@@ -34,7 +34,6 @@ describe("resolveSidebarNavItems", () => {
it("yields builtins then plugins, all visible, when nothing is stored", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban, notes],
preferences: [],
});
@@ -59,7 +58,6 @@ describe("resolveSidebarNavItems", () => {
it("keeps the stored order and appends newly available items as visible", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [notes, kanban],
preferences: [
{ key: kanbanKey, visible: false },
@@ -81,7 +79,6 @@ describe("resolveSidebarNavItems", () => {
it("skips keys that are unknown or not currently available", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [
{ key: notesKey, visible: false },
@@ -101,7 +98,6 @@ describe("resolveSidebarNavItems", () => {
it("lets the first of duplicate keys win", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [
{ key: "history", visible: false },
@@ -118,18 +114,13 @@ describe("resolveSidebarNavItems", () => {
});
});
describe("resolveSidebarNavItems on compact layouts", () => {
describe("resolveSidebarNavItems for the Usage item", () => {
it("hides the Usage item until it is turned on", () => {
const resolve = (compact: boolean, preferences: SidebarNavPreference[]) =>
summarize(
resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }),
);
const resolve = (preferences: SidebarNavPreference[]) =>
summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences }));
expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]);
expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]);
expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([
{ key: "usage", visible: true },
]);
expect(resolve([])).toEqual([{ key: "usage", visible: false }]);
expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]);
});
});
@@ -137,7 +128,6 @@ describe("setSidebarNavItemVisible", () => {
it("toggles one item and writes the full resolved order", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban],
preferences: [],
});
@@ -160,7 +150,6 @@ describe("setSidebarNavItemVisible", () => {
];
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: previous,
});
@@ -186,7 +175,6 @@ describe("setSidebarNavItemVisible", () => {
];
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: previous,
});
@@ -204,7 +192,6 @@ describe("setSidebarNavItemVisible", () => {
summarize(
resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [notes],
preferences: next,
}),
@@ -215,7 +202,6 @@ describe("setSidebarNavItemVisible", () => {
it("returns the normalized list unchanged for an unknown key", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [],
preferences: [],
});
@@ -229,7 +215,6 @@ describe("setSidebarNavItemVisible", () => {
describe("moveSidebarNavItem", () => {
const items = resolveSidebarNavItems({
section: "header",
compact: false,
pluginGroups: [kanban],
preferences: [],
});
@@ -307,13 +292,12 @@ describe("footer section", () => {
it("resolves the Usage item first, then plugin rows", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: [],
});
expect(summarize(items)).toEqual([
{ key: "usage", visible: true },
{ key: "usage", visible: false },
{ key: syncKey, visible: true },
]);
});
@@ -321,18 +305,16 @@ describe("footer section", () => {
it("ignores header built-ins stored in footer preferences", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [],
preferences: [{ key: "history", visible: false }],
});
expect(summarize(items)).toEqual([{ key: "usage", visible: true }]);
expect(summarize(items)).toEqual([{ key: "usage", visible: false }]);
});
it("ignores the footer icon buttons, which are fixed and not items", () => {
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: [
{ key: "add-project", visible: false },
@@ -350,33 +332,31 @@ describe("footer section", () => {
]);
});
it("moves and hides footer rows while keeping an unavailable plugin's entry", () => {
it("moves and shows footer rows while keeping an unavailable plugin's entry", () => {
const notesPreference = { key: notesKey, visible: false };
const previous: SidebarNavPreference[] = [notesPreference];
const items = resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: previous,
});
const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous });
const hidden = setSidebarNavItemVisible({
const shown = setSidebarNavItemVisible({
items: resolveSidebarNavItems({
section: "footer",
compact: false,
pluginGroups: [sync],
preferences: moved,
}),
key: "usage",
visible: false,
visible: true,
previous: moved,
});
expect(hidden).toEqual([
expect(shown).toEqual([
notesPreference,
{ key: syncKey, visible: true },
{ key: "usage", visible: false },
{ key: "usage", visible: true },
]);
});
});
+5 -10
View File
@@ -69,14 +69,11 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin
return BUILTIN_SHORTCUT_ACTIONS[id];
}
/**
* Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer
* has no room to spare for the Usage summary.
*/
const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
/** Builtins that start hidden until the user turns them on: the Usage summary is opt-in. */
const HIDDEN_BY_DEFAULT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean {
return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id));
function builtinVisibleByDefault(id: BuiltinSidebarItemId): boolean {
return !HIDDEN_BY_DEFAULT.has(id);
}
export function pluginSidebarNavKey(
@@ -95,8 +92,6 @@ function isBuiltinSidebarItemId<Section extends SidebarSection>(
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
section: Section;
/** Compact layouts start some builtins hidden; a stored preference always wins. */
compact: boolean;
pluginGroups: readonly PluginSidebarGroup[];
preferences: readonly SidebarNavPreference[];
}): SidebarNavItem<Section>[] {
@@ -131,7 +126,7 @@ export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
kind: "builtin",
key: id,
id,
visible: builtinVisibleByDefault(id, input.compact),
visible: builtinVisibleByDefault(id),
});
}
for (const [key, group] of groupsByKey) {
@@ -1,5 +1,4 @@
import { useCallback, useMemo } from "react";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useAppSettings } from "@/hooks/use-settings";
import type { AppSettings } from "@/hooks/use-settings/storage";
import { useInstalledPlugins } from "@/plugins/registry";
@@ -28,15 +27,14 @@ export function useSidebarNavItems<Section extends SidebarSection>(
section: Section,
): UseSidebarNavItemsReturn<Section> {
const plugins = useInstalledPlugins();
const compact = useIsCompactFormFactor();
const { settings, updateSettings } = useAppSettings();
const field = PREFERENCE_FIELDS[section];
const preferences = settings[field];
const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]);
const items = useMemo(
() => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }),
[compact, pluginGroups, preferences, section],
() => resolveSidebarNavItems({ section, pluginGroups, preferences }),
[pluginGroups, preferences, section],
);
const setVisible = useCallback(
@@ -45,7 +43,6 @@ export function useSidebarNavItems<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -54,7 +51,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[compact, field, pluginGroups, section, updateSettings],
[field, pluginGroups, section, updateSettings],
);
const move = useCallback(
@@ -63,7 +60,6 @@ export function useSidebarNavItems<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -72,7 +68,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[compact, field, pluginGroups, section, updateSettings],
[field, pluginGroups, section, updateSettings],
);
return { items, setVisible, move };
+66
View File
@@ -0,0 +1,66 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
import { usageCopy } from "./copy";
import { useUsageDisplay } from "./display";
import { useAgentUsage } from "./queries";
import type { UsageReportEntry } from "./types";
/**
* The usage of the account an agent runs under, for its context window details: one card per
* report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an
* agent with no account to report.
*/
export function AgentUsage({
serverId,
agentId,
refreshable,
}: {
serverId: string;
agentId: string;
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
<>
<View style={styles.divider} />
{view.kind === "ready" ? (
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
</Text>
)}
</>
);
}
function AgentUsageCards({
serverId,
reports,
refreshable,
}: {
serverId: string;
reports: readonly UsageReportEntry[];
refreshable: boolean;
}) {
const { display } = useUsageDisplay(reports);
return reports.map((entry) => (
<UsageCard
key={entry.id}
serverId={serverId}
entry={entry}
display={display}
compact
pinnable={false}
refreshable={refreshable}
/>
));
}
const styles = StyleSheet.create((theme) => ({
divider: { height: 1, backgroundColor: theme.colors.borderAccent },
message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
}));
+27 -10
View File
@@ -47,17 +47,23 @@ export function UsageCard({
serverId,
entry,
display,
pinnable,
refreshable,
compact = false,
}: {
serverId: string;
entry: UsageReportEntry;
display: UsageDisplay;
/** Whether each window row pins the window to the sidebar. */
pinnable: boolean;
/** Whether the header has a Refresh button. Without one the freshness shows on the card. */
refreshable: boolean;
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact;
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
@@ -93,13 +99,15 @@ export function UsageCard({
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
{refreshable ? (
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
) : null}
</View>
{message ? (
@@ -111,7 +119,13 @@ export function UsageCard({
{windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{windows.map((window) => (
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
<CardWindowBar
key={window.id}
entry={entry}
window={window}
display={display}
pinnable={pinnable}
/>
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} />
@@ -158,14 +172,16 @@ export function UsageCard({
);
}
function PinnableWindowBar({
function CardWindowBar({
entry,
window,
display,
pinnable,
}: {
entry: UsageReportEntry;
window: UsageWindow;
display: UsageDisplay;
pinnable: boolean;
}) {
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
@@ -177,6 +193,7 @@ function PinnableWindowBar({
<UsageWindowBar
window={window}
displayAs={display.displayAs}
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
+6 -2
View File
@@ -3,6 +3,7 @@ import { StyleSheet } from "react-native-unistyles";
import { HostFilter } from "@/components/hosts/host-filter";
import { useMemo, type ReactElement } from "react";
import { UsageRefreshButton } from "./refresh-button";
import { UsageOptions } from "./options";
import type { UsageHost } from "./model";
import { useHostUsage } from "./queries";
import type { UsageView } from "./types";
@@ -16,7 +17,7 @@ export interface UsageHostSelection {
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, and Refresh all. A host that cannot report usage keeps only the host filter.
* host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter.
*/
export function UsageControls({
view,
@@ -41,7 +42,10 @@ export function UsageControls({
/>
) : null}
{view.kind === "unavailable" ? null : (
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<>
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
</>
)}
</View>
);
+1
View File
@@ -25,6 +25,7 @@ export const usageCopy = {
empty: "No usage data",
noHosts: "No connected hosts",
errorTitle: "Unable to load usage",
agentError: (reason: string) => `Unable to load usage: ${reason}`,
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
clientUnavailable: "Host connection is not ready",
+12 -5
View File
@@ -10,6 +10,7 @@ import {
type UsagePin,
type UsagePreferences,
} from "./preferences";
import type { UsageReportEntry } from "./types";
/** What usage views need from the preferences: how to read percents, and the pins. */
export interface UsageDisplay {
@@ -19,15 +20,21 @@ export interface UsageDisplay {
togglePin: (pin: UsagePin) => void;
}
/** The device's usage preferences, and the commands that change them. */
export function useUsagePreferences(serverId?: string): {
interface UsagePreferencesState {
preferences: UsagePreferences;
display: UsageDisplay;
} {
}
/** The device's usage preferences, with pins resolved against a host's reports. */
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
const defaultServerId = useUsageHostId();
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
}
/** The device's usage preferences, with pins resolved against the given reports. */
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
const { settings, updateSettings } = useAppSettings();
const preferences = settings.usage;
const defaultServerId = useUsageHostId();
const reports = useUsageHostReports(serverId ?? defaultServerId);
const setDisplayAs = useCallback(
(displayAs: UsageDisplayAs) => {
void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) }));
+19
View File
@@ -0,0 +1,19 @@
import { useCallback } from "react";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
/**
* Whether the sidebar footer shows the Usage summary: the same switch as Settings > Sidebar.
* Window rows pin to the summary only while it is on.
*/
export function useUsageInSidebar(): {
inSidebar: boolean;
setInSidebar: (visible: boolean) => void;
} {
const { items, setVisible } = useSidebarNavItems("footer");
const inSidebar = items.some((item) => item.key === "usage" && item.visible);
const setInSidebar = useCallback(
(visible: boolean) => setVisible("usage", visible),
[setVisible],
);
return { inSidebar, setInSidebar };
}
+8 -1
View File
@@ -1,3 +1,10 @@
export { AgentUsage } from "./agent-usage";
export { HostUsageSection } from "./host-usage-section";
export { useHostReportsUsage } from "./queries";
export { UsageScreen } from "./usage-screen";
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
export {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
+10 -2
View File
@@ -3,9 +3,10 @@ import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { UsageCard } from "./card";
import type { UsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageReportEntry } from "./types";
/** One card per report (source + account). */
/** One card per report (source + account). Window rows pin while the sidebar summary is on. */
export function UsageList({
serverId,
reports,
@@ -15,11 +16,18 @@ export function UsageList({
reports: UsageReportEntry[];
display: UsageDisplay;
}) {
const { inSidebar } = useUsageInSidebar();
return (
<View style={styles.list}>
{reports.map((entry) => (
<View key={entry.id} style={settingsStyles.card}>
<UsageCard serverId={serverId} entry={entry} display={display} />
<UsageCard
serverId={serverId}
entry={entry}
display={display}
pinnable={inSidebar}
refreshable
/>
</View>
))}
</View>
+93
View File
@@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest";
import {
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
resolveUsageHostId,
resolveUsageScreenHostId,
resolveUsageView,
settleReports,
upsertReport,
usageWindowRowLabel,
type UsageHost,
type UsageQueryState,
@@ -216,3 +219,93 @@ describe("replaceReport", () => {
expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
});
});
describe("upsertReport", () => {
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
const beta = entry({ sourceId: "beta" });
it("starts a list from the first streamed report", () => {
expect(upsertReport(undefined, alpha)).toEqual([alpha]);
});
it("appends a report the list does not have yet", () => {
expect(upsertReport([alpha], beta)).toEqual([alpha, beta]);
});
it("replaces a report the list already has, in place", () => {
const streamed = entry({ sourceId: "alpha", planLabel: "New" });
expect(upsertReport([alpha, beta], streamed)).toEqual([streamed, beta]);
});
it("keeps a copy fetched after the streamed one, such as a Refresh that landed first", () => {
const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" };
expect(upsertReport([refreshed, beta], alpha)).toEqual([refreshed, beta]);
});
});
describe("settleReports", () => {
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
const beta = entry({ sourceId: "beta" });
it("takes the finished list, dropping reports the host no longer has", () => {
const streamed = entry({ sourceId: "alpha", planLabel: "New" });
expect(settleReports([alpha, beta], [streamed])).toEqual([streamed]);
});
it("keeps a report refreshed while the list streamed over its older copy in the list", () => {
const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" };
expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]);
});
it("takes the finished list as is when nothing was shown yet", () => {
expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]);
});
});
describe("resolveAgentUsageView", () => {
const report = entry({ sourceId: "claude" });
const idle: UsageQueryState = { data: undefined, error: null, isFetching: false };
it("shows nothing on a host that cannot report usage", () => {
expect(resolveAgentUsageView({ canReport: false, query: idle })).toEqual({ kind: "none" });
});
it("loads until the first report streams in", () => {
expect(
resolveAgentUsageView({ canReport: true, query: { ...idle, isFetching: true } }),
).toEqual({ kind: "loading" });
});
it("shows each report as it streams in, before the request finishes", () => {
expect(
resolveAgentUsageView({
canReport: true,
query: { data: [report], error: null, isFetching: true },
}),
).toEqual({ kind: "ready", reports: [report] });
});
it("shows nothing for an agent with no account to report", () => {
expect(resolveAgentUsageView({ canReport: true, query: { ...idle, data: [] } })).toEqual({
kind: "none",
});
});
it("says why a failed request has nothing to show", () => {
expect(
resolveAgentUsageView({
canReport: true,
query: { ...idle, error: new Error("Unknown agent") },
}),
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
});
it("says the request failed instead of showing the reports from before it", () => {
expect(
resolveAgentUsageView({
canReport: true,
query: { data: [report], error: new Error("Unknown agent"), isFetching: false },
}),
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
});
});
+59
View File
@@ -61,6 +61,40 @@ export function replaceReport(
return reports.map((report) => (report.id === reportId ? refreshed : report));
}
/**
* The later-fetched of two copies of one report. A list request's copies were fetched when it
* started, so a Refresh that lands while it streams is newer than what the request still delivers.
*/
function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry {
return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving;
}
/**
* A report list with one streamed report in place of its previous copy, or appended if new. A
* copy fetched after the streamed one stays.
*/
export function upsertReport(
reports: readonly UsageReportEntry[] | undefined,
report: UsageReportEntry,
): UsageReportEntry[] {
if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report];
return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry));
}
/**
* The list a finished request leaves: its reports, dropping any the host no longer has, except
* that a copy on screen fetched after the request's copy stays.
*/
export function settleReports(
shown: readonly UsageReportEntry[] | undefined,
finished: readonly UsageReportEntry[],
): UsageReportEntry[] {
return finished.map((report) => {
const copy = shown?.find((entry) => entry.id === report.id);
return copy ? laterFetched(copy, report) : report;
});
}
export interface UsageQueryState {
data: UsageReportEntry[] | undefined;
error: unknown;
@@ -90,6 +124,31 @@ export function resolveUsageView(input: {
return { kind: "loading" };
}
/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */
export type AgentUsageView =
| { kind: "none" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[] };
export function resolveAgentUsageView(input: {
canReport: boolean;
query: UsageQueryState;
}): AgentUsageView {
const { canReport, query } = input;
if (!canReport) return { kind: "none" };
// A failed request keeps the reports from before it, or those that streamed in before it failed;
// shown alone they would pass for the agent's complete, current usage.
if (query.error) {
const reason = query.error instanceof Error ? query.error.message : String(query.error);
return { kind: "error", message: usageCopy.agentError(reason) };
}
if (query.data) {
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
}
return { kind: "loading" };
}
export interface UsageHost {
serverId: string;
label: string;
+49 -36
View File
@@ -1,16 +1,16 @@
import { useCallback } from "react";
import { View } from "react-native";
import {
SettingsCard,
SettingsCollapsibleRow,
SettingsRow,
SettingsSwitch,
} from "@/components/settings";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
@@ -18,22 +18,33 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
/**
* The usage options, folded above the reports: whether the sidebar shows the summary (the same
* switch as Settings > Sidebar) and whether percents read as used or remaining.
*/
export function UsageOptions({ display }: { display: UsageDisplay }) {
const sidebarItems = useSidebarNavItems("footer");
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
const { setVisible } = sidebarItems;
const setInSidebar = useCallback(
(visible: boolean) => setVisible("usage", visible),
[setVisible],
);
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<View style={settingsStyles.section}>
<SettingsCard>
<SettingsCollapsibleRow label={usageCopy.options} testID="usage-options">
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
<ThemedSettings
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={340}
sheetTitle={usageCopy.options}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
@@ -41,17 +52,19 @@ export function UsageOptions({ display }: { display: UsageDisplay }) {
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
testID="usage-display-as"
/>
</SettingsRow>
</SettingsCollapsibleRow>
</SettingsCard>
</View>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
testID="usage-display-as"
/>
</SettingsRow>
</View>
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
+103 -11
View File
@@ -1,6 +1,12 @@
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import { skipToken, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
skipToken,
useMutation,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useShallow } from "zustand/shallow";
import { useFetchQuery } from "@/data/query";
import {
@@ -13,11 +19,15 @@ import { useSessionStore, type SessionState } from "@/stores/session-store";
import { usageCopy } from "./copy";
import {
replaceReport,
resolveAgentUsageView,
resolveUsageRefresh,
resolveUsageView,
settleReports,
type UsageHost,
type UsageQueryState,
type AgentUsageView,
type UsageRefresh,
upsertReport,
} from "./model";
import type { UsageReportEntry, UsageView } from "./types";
@@ -25,8 +35,17 @@ import type { UsageReportEntry, UsageView } from "./types";
// an explicit refresh passes `forceRefresh` and reaches the source's API.
const REPORTS_STALE_TIME_MS = 60_000;
/** Every report list of a host: its own and each agent's. */
function hostUsageQueryKey(serverId: string) {
return ["usage", serverId] as const;
}
function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
return [...hostUsageQueryKey(serverId), "reports"] as const;
}
function agentUsageQueryKey(serverId: string, agentId: string) {
return [...hostUsageQueryKey(serverId), "agent", agentId] as const;
}
function requireClient(serverId: string) {
@@ -35,8 +54,44 @@ function requireClient(serverId: string) {
return client;
}
async function listReports(serverId: string, forceRefresh = false): Promise<UsageReportEntry[]> {
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
/**
* Lists a host's reports, or one agent's, writing each into `queryKey` as it streams in so a slow
* source never holds back the others. The finished list then replaces the streamed one, dropping
* any report the host no longer has, but never a report refreshed while the list streamed.
*/
async function streamReports(input: {
queryClient: QueryClient;
queryKey: QueryKey;
serverId: string;
agentId?: string;
forceRefresh?: boolean;
/** Once aborted, reports still on their way are dropped instead of written. */
signal?: AbortSignal;
}): Promise<UsageReportEntry[]> {
const { queryClient, queryKey, serverId, agentId, forceRefresh = false, signal } = input;
const { reports } = await requireClient(serverId).listUsageReports(
{ agentId, forceRefresh },
(report) => {
if (signal?.aborted) return;
queryClient.setQueryData<UsageReportEntry[]>(queryKey, (current) =>
upsertReport(current, report),
);
},
);
return settleReports(queryClient.getQueryData<UsageReportEntry[]>(queryKey), reports);
}
function listReports(
queryClient: QueryClient,
serverId: string,
forceRefresh = false,
): Promise<UsageReportEntry[]> {
return streamReports({
queryClient,
queryKey: usageReportsQueryKey(serverId),
serverId,
forceRefresh,
});
}
async function getReport(
@@ -57,7 +112,7 @@ function supportsUsage(session: SessionState | undefined): boolean {
async function refreshReports(queryClient: QueryClient, serverId: string): Promise<void> {
await queryClient.fetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(serverId, true),
queryFn: () => listReports(queryClient, serverId, true),
staleTime: 0,
});
}
@@ -77,7 +132,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(serverId),
queryFn: () => listReports(queryClient, serverId),
enabled: isConnected && isSupported,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
@@ -102,15 +157,51 @@ const NO_REPORTS: UsageReportEntry[] = [];
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const queryClient = useQueryClient();
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(serverId) : skipToken,
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return query.data ?? NO_REPORTS;
}
/**
* The reports of the account an agent runs under, as its meter's tooltip or sheet shows them.
* Fetched while either is mounted, so only while it is open.
*/
export function useAgentUsage(serverId: string, agentId: string): AgentUsageView {
const queryClient = useQueryClient();
const canReport = useHostReportsUsage(serverId);
const queryKey = agentUsageQueryKey(serverId, agentId);
const query = useFetchQuery({
queryKey,
// Consuming the signal cancels the request when the details close, so a reopen sends a new
// request instead of joining one scoped to the login the agent ran under before.
queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }),
enabled: canReport,
// Another agent's reports never stand in while this one's load.
dataShape: "value",
// Dropped as soon as the details close: an agent resumed under another login keeps its ID, so
// reports kept from an earlier open could show the old login. The daemon's cache keeps each
// open's request cheap.
gcTime: 0,
// The daemon's errors (an unknown agent) do not heal on retry, and reopening the details
// fetches again; retrying would hold the loading sentence for seconds instead.
retry: false,
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return resolveAgentUsageView({ canReport, query: toQueryState(query) });
}
/** Whether a host is connected and reports usage, read without fetching anything. */
export function useHostReportsUsage(serverId: string): boolean {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
return isConnected && isSupported;
}
/** Every host with whether it is connected and reports usage, in host order. */
export function useUsageHosts(): UsageHost[] {
const hosts = useHosts();
@@ -133,8 +224,8 @@ export function useUsageHosts(): UsageHost[] {
/**
* Forces the source to fetch one report, and only that report. The result replaces the report
* in its host's list, so every surface showing it moves together; until then the previous report
* stays on screen.
* in every list of its host that holds it, the host's and each agent's, so every surface showing
* it moves together; until then the previous report stays on screen.
*/
export function useReportRefresh(
serverId: string,
@@ -144,8 +235,9 @@ export function useReportRefresh(
const mutation = useMutation({
mutationFn: () => getReport(serverId, reportId, true),
onSuccess: (report) => {
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
reports ? replaceReport(reports, reportId, report) : reports,
queryClient.setQueriesData<UsageReportEntry[]>(
{ queryKey: hostUsageQueryKey(serverId) },
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
);
},
});
+42 -41
View File
@@ -1,5 +1,13 @@
import { router } from "expo-router";
import { Fragment, useCallback, useMemo, useState } from "react";
import {
createContext,
Fragment,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
@@ -12,7 +20,6 @@ import { StyleSheet } from "react-native-unistyles";
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
import { useIsCompactFormFactor } from "@/constants/layout";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { usePanelStore } from "@/stores/panel-store";
import { buildUsageRoute } from "@/utils/host-routes";
import { useHostUsageWithControls } from "./controls";
import { useUsagePreferences, type UsageDisplay } from "./display";
@@ -29,7 +36,6 @@ import {
type PinnedUsageSource,
} from "./pinned";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody } from "./usage-section";
/** Each summary window with data on the usage host, under its source; empty while none has. */
@@ -50,55 +56,51 @@ export function useHasUsageSummary(): boolean {
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
*/
export function UsageSidebarItem() {
const { display } = useUsagePreferences();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <UsageEntry sources={sources} display={display} />;
}
/** Opens the Usage screen, over the sidebar on compact layouts. */
export function useOpenUsageScreen(): () => void {
const isCompact = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
return useCallback(() => {
if (isCompact) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompact, showMobileAgent]);
}
function UsageEntry({
sources,
display,
}: {
sources: readonly PinnedUsageSource[];
display: UsageDisplay;
}) {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { display } = useUsagePreferences();
const isCompact = useIsCompactFormFactor();
const openUsageScreen = useOpenUsageScreen();
const [open, setOpen] = useState(false);
// The sheet mounts on first open; the summary already owns the report query.
const [sheetMounted, setSheetMounted] = useState(false);
const handlePress = useCallback(() => {
if (!isCompact) {
openUsageScreen();
const openUsage = useCallback(() => {
if (isCompact) {
setSheetMounted(true);
setOpen(true);
return;
}
setSheetMounted(true);
setOpen(true);
}, [isCompact, openUsageScreen]);
router.push(buildUsageRoute());
}, [isCompact]);
const trigger = <PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />;
if (!isCompact) return trigger;
return (
<SidebarPopoverRoot open={open} onOpenChange={setOpen}>
{trigger}
{sheetMounted ? <UsageSheet title={label} display={display} /> : null}
</SidebarPopoverRoot>
<OpenSidebarUsageContext.Provider value={openUsage}>
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
{children}
{isCompact && sheetMounted ? (
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
) : null}
</SidebarPopoverRoot>
</OpenSidebarUsageContext.Provider>
);
}
/** Both sidebar entry points send the same open command. */
export function useOpenSidebarUsage(): () => void {
const openUsage = useContext(OpenSidebarUsageContext);
if (!openUsage) throw new Error("Sidebar Usage must be inside UsageSidebarRoot.");
return openUsage;
}
/**
* The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls
* in its title row.
@@ -144,7 +146,6 @@ function HostUsageSheet({
testID="sidebar-usage-sheet"
>
<View style={styles.sheetBody} testID="usage-expanded">
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</SidebarPopoverSurface>
-2
View File
@@ -8,7 +8,6 @@ import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody, UsageMessage } from "./usage-section";
// The screen is reachable by URL, so there may be no history to go back to.
@@ -66,7 +65,6 @@ function HostUsage({
return (
<UsagePage actions={controls}>
<View testID={`usage-host-${serverId}`}>
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</UsagePage>
+26 -8
View File
@@ -21,6 +21,7 @@ function highlightStyle(pinned: boolean, hovered: boolean) {
export function UsageWindowBar({
window,
displayAs,
pinnable,
pinned,
onTogglePin,
pinLabel,
@@ -28,6 +29,8 @@ export function UsageWindowBar({
}: {
window: UsageWindow;
displayAs: UsageDisplayAs;
/** Whether the row pins the window to the sidebar. When false the row is a plain bar. */
pinnable: boolean;
pinned: boolean;
onTogglePin: () => void;
/** What the row pins, naming the source and window: "Pin Claude Session". */
@@ -44,6 +47,24 @@ export function UsageWindowBar({
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
label: window.label,
value,
trailing,
isAtRisk,
percent: shownPct ?? 0,
tone,
pinned,
};
// Same padding as the pinnable row, so bars line up in both modes.
if (!pinnable) {
return (
<View style={styles.row}>
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
</View>
);
}
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
// padding so toggling only changes the background.
@@ -59,14 +80,9 @@ export function UsageWindowBar({
>
{({ hovered }: { hovered?: boolean }) => (
<WindowRowContent
{...content}
highlight={highlightStyle(pinned, Boolean(hovered))}
label={window.label}
value={value}
trailing={trailing}
isAtRisk={isAtRisk}
percent={shownPct ?? 0}
tone={tone}
pinned={pinned}
pinnable
/>
)}
</Pressable>
@@ -81,6 +97,7 @@ function WindowRowContent({
isAtRisk,
percent,
tone,
pinnable,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
@@ -90,6 +107,7 @@ function WindowRowContent({
isAtRisk: boolean;
percent: number;
tone: UsageTone;
pinnable: boolean;
pinned: boolean;
}) {
return (
@@ -110,7 +128,7 @@ function WindowRowContent({
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
<UsagePinGlyph pinned={pinned} />
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
</View>
</>
);
+63 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
@@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js";
const agent = {
id: "11111111-1111-4111-8111-111111111111",
status: "running",
archivedAt: null,
archivedAt: null as string | null,
cwd: "/tmp/project",
};
const archivedAgent = {
id: "22222222-2222-4222-8222-222222222222",
status: "closed",
archivedAt: "2026-10-01T00:00:00.000Z",
cwd: "/tmp/project/nested",
};
const archivedOutsideAgent = {
id: "33333333-3333-4333-8333-333333333333",
status: "closed",
archivedAt: "2026-10-01T00:00:00.000Z",
cwd: "/tmp/elsewhere",
};
let daemonAgents: (typeof agent)[] = [agent];
const toEntry = (entry: typeof agent) => ({ agent: entry });
// The fake daemon pages history two agents at a time.
const historyPage = (cursor: string | undefined) => {
const start = Number(cursor ?? 0);
const end = start + 2;
return {
entries: daemonAgents.slice(start, end).map(toEntry),
pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null },
};
};
const cancelAgent = vi.fn(async () => {
throw new Error("active run cancellation was not acknowledged");
});
const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) =>
historyPage(options.page.cursor),
);
const deleteAgent = vi.fn(async () => undefined);
const close = vi.fn(async () => undefined);
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })),
fetchAgents: vi.fn(async () => historyPage(undefined)),
fetchAgentHistory,
fetchAgent: vi.fn(async () => ({ agent })),
cancelAgent,
deleteAgent,
@@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({
}));
describe("runDeleteCommand", () => {
beforeEach(() => {
daemonAgents = [agent];
deleteAgent.mockClear();
fetchAgentHistory.mockClear();
});
it("force-deletes a running agent when graceful cancellation is refused", async () => {
const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never);
expect(fetchAgentHistory).not.toHaveBeenCalled();
expect(cancelAgent).toHaveBeenCalledWith(agent.id);
expect(deleteAgent).toHaveBeenCalledWith(agent.id);
expect(result.data).toEqual({
@@ -38,4 +72,30 @@ describe("runDeleteCommand", () => {
agentIds: [agent.id],
});
});
it("deletes archived agents on every history page with --all", async () => {
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never);
expect(result.data).toEqual({
deletedCount: 3,
agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id],
});
});
it("deletes archived agents inside the directory with --cwd", async () => {
daemonAgents = [agent, archivedAgent, archivedOutsideAgent];
const result = await runDeleteCommand(
undefined,
{ daemonTarget, cwd: "/tmp/project" },
{} as never,
);
expect(result.data).toEqual({
deletedCount: 2,
agentIds: [agent.id, archivedAgent.id],
});
});
});
+23 -11
View File
@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions {
export type AgentDeleteResult = SingleResult<DeleteResult>;
// History includes archived agents and agents whose provider is unavailable.
async function fetchHistoryAgents(client: DaemonClient) {
const agents: FetchAgentHistoryEntry["agent"][] = [];
let cursor: string | undefined;
do {
const payload = await client.fetchAgentHistory({
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
agents.push(...payload.entries.map((entry) => entry.agent));
cursor = payload.pageInfo.nextCursor ?? undefined;
} while (cursor);
return agents;
}
export async function runDeleteCommand(
id: string | undefined,
options: AgentDeleteOptions,
@@ -50,19 +65,16 @@ export async function runDeleteCommand(
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const deletedIds: string[] = [];
let agents: FetchAgentHistoryEntry["agent"][];
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent({ agentId: id });
if (options.all || options.cwd) {
agents = await fetchHistoryAgents(client);
if (!options.all) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
} else {
const fetchResult = await client.fetchAgent({ agentId: id! });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
@@ -146,7 +146,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary
expect(await f.ok(["project", "ls", "--home", a])).toEqual([]);
expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1);
const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned);
expect(stream.stderr).toContain("Agent not found: missing-agent");
expect(stream.stderr).toContain("No agent found matching: missing-agent");
const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned);
expect(restart.supervisorPid).toBe(launchB.pid);
expect(restart.workerPid).not.toBe(beforeB.workerPid);
@@ -429,6 +429,37 @@ test("empty explicit selectors never select the ambient daemon or create local s
}
}, 60_000);
test.skipIf(process.platform === "win32")(
"restart confirms the replacement worker when a provider probe is slow",
async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const provider = path.join(f.root, "slow-provider");
await writeFile(
provider,
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), 1800);\n`,
{ mode: 0o700 },
);
const configPath = path.join(home, "config.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
config.agents = {
...config.agents,
providers: { claude: { command: { mode: "replace", argv: [provider] } } },
};
await writeFile(configPath, JSON.stringify(config));
const launch = await f.ok(["start", "--home", home, "--timeout", "30"]);
const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]);
expect(restarted.supervisorPid).toBe(launch.pid);
expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid);
} finally {
await f.close();
}
},
60_000,
);
test("IPv6 publication supports home-selected query and worker restart", async () => {
const f = await fixture();
const home = f.homes[0]!;
@@ -472,7 +503,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
const launchA = await f.ok(["start", "--home", a]);
await f.ok(["start", "--home", b]);
const absent = await f.run(["logs", agentId, "--home", a]);
expect(absent.stderr).toContain("Agent not found");
expect(absent.stderr).toContain(`No agent found matching: ${agentId}`);
follower = spawn(
process.execPath,
[cli, "logs", agentId, "--follow", "--tail", "0", "--home", b],
@@ -491,7 +522,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy
errors += data;
});
await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;");
expect(errors).not.toContain("Agent not found");
expect(errors).not.toContain("No agent found matching");
} finally {
if (follower && follower.exitCode === null) {
const exited = new Promise((resolve) => follower!.once("exit", resolve));
+2 -1
View File
@@ -73,7 +73,8 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma
try {
if (replacement.getLastServerInfoMessage()?.serverId !== serverId)
throw new Error("Connected peer identity changed");
const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) });
// Status probes every provider, so it can take seconds on a healthy worker.
const status = await replacement.getDaemonStatus({ timeout: remaining() });
if (status.pid !== workerPid) {
await checkSupervisor();
return {
+12 -3
View File
@@ -17,6 +17,15 @@ function inputFrom(value: string) {
};
}
function claudeNotification(notificationType: string) {
return JSON.stringify({
session_id: "session-1",
hook_event_name: "Notification",
message: "Claude is waiting for your input",
notification_type: notificationType,
});
}
function ttyInput() {
return {
isTTY: true,
@@ -123,7 +132,7 @@ describe("runHooksCommand", () => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom('{"reason":"idle_prompt"}'),
inputFrom(claudeNotification("idle_prompt")),
);
expectPostedState(send, "needs-input");
@@ -131,11 +140,11 @@ describe("runHooksCommand", () => {
it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])(
"ignores Claude %s notifications",
async (reason) => {
async (notificationType) => {
const send = await runHook(
claudeProvider.id,
claudeProvider.events[4].event,
inputFrom(JSON.stringify({ reason })),
inputFrom(claudeNotification(notificationType)),
);
expect(send.calls).toEqual([]);
+1 -1
View File
@@ -218,7 +218,7 @@ export function createPluginCommand(): Command {
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.argument(
"<source>",
"Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
+23
View File
@@ -19,6 +19,7 @@
import assert from "node:assert";
import { getAvailablePort } from "./helpers/network.ts";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
@@ -129,6 +130,28 @@ try {
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with stop\n");
}
{
console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID");
const daemon = await startTestDaemon();
try {
const result = await runPaseoCli(daemon, [
"agent",
"stop",
"does-not-exist",
"--host",
`127.0.0.1:${daemon.port}`,
"--json",
]);
assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID");
const { error } = JSON.parse(result.stderr);
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
assert.match(error.details, /paseo ls/);
} finally {
await daemon.stop();
}
console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });
@@ -13,6 +13,7 @@
import assert from "node:assert";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { getAvailablePort } from "./helpers/network.ts";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
@@ -112,6 +113,28 @@ try {
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with delete\n");
}
{
console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID");
const daemon = await startTestDaemon();
try {
const result = await runPaseoCli(daemon, [
"agent",
"delete",
"does-not-exist",
"--host",
`127.0.0.1:${daemon.port}`,
"--json",
]);
assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID");
const { error } = JSON.parse(result.stderr);
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
assert.match(error.details, /paseo ls/);
} finally {
await daemon.stop();
}
console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n");
}
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
+81 -6
View File
@@ -7206,11 +7206,15 @@ test("uses modern usage RPC when both capabilities are advertised", async () =>
const connected = client.connect();
mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } });
await connected;
const result = client.listUsageReports({
requestId: "modern-usage",
reportIds: ["claude:work"],
forceRefresh: true,
});
const updates: unknown[] = [];
const result = client.listUsageReports(
{
requestId: "modern-usage",
reportIds: ["claude:work"],
forceRefresh: true,
},
(report) => updates.push(report),
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "usage.list_reports.request",
requestId: "modern-usage",
@@ -7230,8 +7234,35 @@ test("uses modern usage RPC when both capabilities are advertised", async () =>
},
],
};
mock.triggerMessage(wrapSessionMessage({ type: "usage.list_reports.response", payload }));
const report = payload.reports[0];
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: "foreign", report },
}),
);
expect(updates).toEqual([]);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: payload.requestId, report },
}),
);
expect(updates).toEqual([report]);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.response",
payload: { requestId: payload.requestId, error: null },
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: payload.requestId, report },
}),
);
expect(await result).toStrictEqual(payload);
expect(updates).toEqual([report]);
});
test("rejects usage requests when the host has neither capability", async () => {
@@ -7250,3 +7281,47 @@ test("rejects usage requests when the host has neither capability", async () =>
await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage.");
expect(mock.sent).toEqual([]);
});
test("usage request timeout detaches its update listener", async () => {
vi.useFakeTimers();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ features: { usageSources: true } });
await connected;
const updates: unknown[] = [];
const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) =>
updates.push(report),
);
const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)");
await vi.advanceTimersByTimeAsync(60_001);
await rejected;
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: {
requestId: "timeout-usage",
report: {
id: "source:late",
account: {},
sourceId: "source",
sourceLabel: "Source",
fetchedAt: "2026-01-01T00:00:00.000Z",
report: { status: "available", windows: [] },
},
},
}),
);
expect(updates).toEqual([]);
} finally {
vi.useRealTimers();
}
});
+57 -14
View File
@@ -95,7 +95,7 @@ import type {
RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage,
ProviderUsageListResponseMessage,
UsageListReportsResponseMessage,
UsageReportEntry,
DaemonGetStatusResponse,
DaemonGetPairingOfferResponse,
DaemonConfigReloadResponse,
@@ -562,7 +562,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
type UsageListReportsPayload = UsageListReportsResponseMessage["payload"];
interface UsageListReportsPayload {
requestId: string;
reports: UsageReportEntry[];
}
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
type DiagnosticsPayload = DiagnosticsResponse["payload"];
@@ -5259,17 +5262,25 @@ export class DaemonClient {
});
}
async listUsageReports(options?: {
requestId?: string;
forceRefresh?: boolean;
reportIds?: string[];
}): Promise<UsageListReportsPayload> {
async listUsageReports(
options?: {
agentId?: string;
requestId?: string;
forceRefresh?: boolean;
reportIds?: string[];
},
onReport?: (report: UsageReportEntry) => void,
): Promise<UsageListReportsPayload> {
const features = this.getLastServerInfoMessage()?.features;
if (!supportsUsageReports(features)) {
throw new Error("Update the host to see usage.");
}
if (options?.agentId !== undefined && options.reportIds !== undefined)
throw new Error("agentId and reportIds cannot be combined");
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
if (features?.usageSources !== true) {
if (options?.agentId !== undefined)
return { requestId: this.createRequestId(options.requestId), reports: [] };
// Released hosts serve a five-minute cache and have no forceRefresh option.
const payload = await this.listProviderUsage({ requestId: options?.requestId });
return {
@@ -5310,14 +5321,46 @@ export class DaemonClient {
}),
};
}
return this.sendNamespacedCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "usage.list_reports.request",
forceRefresh: options?.forceRefresh,
reportIds: options?.reportIds,
},
const requestId = this.createRequestId(options?.requestId);
const reports: UsageReportEntry[] = [];
let active = true;
const unsubscribe = this.subscribeRawMessages((message) => {
if (
!active ||
!("payload" in message) ||
!("requestId" in message.payload) ||
message.payload.requestId !== requestId
)
return;
if (message.type === "usage.list_reports.response" || message.type === "rpc_error") {
active = false;
return;
}
if (message.type !== "usage.list_reports.update") return;
reports.push(message.payload.report);
onReport?.(message.payload.report);
});
try {
const response = await this.sendRequest({
requestId,
message: {
type: "usage.list_reports.request",
requestId,
forceRefresh: options?.forceRefresh,
reportIds: options?.reportIds,
agentId: options?.agentId,
},
select: (message) =>
message.type === "usage.list_reports.response" && message.payload.requestId === requestId
? message.payload
: null,
});
if (response.error !== null) throw new Error(response.error);
return { requestId, reports };
} finally {
active = false;
unsubscribe();
}
}
async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>;
+14 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import type { ZodType } from "zod";
import { z, type ZodType } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
export interface UsageWindow {
@@ -62,13 +62,24 @@ export interface UsageAccount {
input: JsonValue;
}
export const UsageScopeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("global") }),
z.object({
kind: z.literal("session"),
provider: z.string(),
model: z.string().optional(),
env: z.record(z.string(), z.string()),
}),
]);
export type UsageScope = z.infer<typeof UsageScopeSchema>;
export interface UsageSourceRegistration {
id: string;
label: string;
icon?: string;
input: ZodType;
/** Every account whose login exists on this machine. Empty when none. */
discover(): Promise<UsageAccount[]>;
/** Accounts for this scope only. The same key in any scope identifies the same account. */
discover(scope: UsageScope): Promise<UsageAccount[]>;
/** Re-reads the login store; never writes it. */
fetch(input: unknown): Promise<UsageReport>;
}
+7 -1
View File
@@ -1788,6 +1788,7 @@ export const ProviderUsageListRequestMessageSchema = z.object({
export const UsageListReportsRequestMessageSchema = z.object({
type: z.literal("usage.list_reports.request"),
agentId: z.string().optional(),
requestId: z.string(),
reportIds: z.array(z.string()).optional(),
forceRefresh: z.boolean().optional(),
@@ -6289,9 +6290,13 @@ export const UsageReportEntrySchema = z.object({
icon: z.string().optional(),
report: UsageReportSchema,
});
export const UsageListReportsUpdateMessageSchema = z.object({
type: z.literal("usage.list_reports.update"),
payload: z.object({ requestId: z.string(), report: UsageReportEntrySchema }),
});
export const UsageListReportsResponseMessageSchema = z.object({
type: z.literal("usage.list_reports.response"),
payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }),
payload: z.object({ requestId: z.string(), error: z.string().nullable() }),
});
const AgentSlashCommandSchema = z.object({
@@ -6979,6 +6984,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema,
ProviderUsageListResponseMessageSchema,
UsageListReportsUpdateMessageSchema,
UsageListReportsResponseMessageSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
@@ -5034,6 +5034,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous
test("session config drift events update state through the stream channel", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
class ConfigEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
@@ -5046,6 +5047,7 @@ test("session config drift events update state through the stream channel", asyn
clients: {
codex: new ConfigEventClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000133",
});
@@ -5100,6 +5102,7 @@ test("session config drift events update state through the stream channel", asyn
const agent = manager.getAgent(snapshot.id);
expect(agent?.currentModeId).toBe("build");
expect(agent?.config.modeId).toBe("build");
expect(agent?.config.thinkingOptionId).toBe("high");
expect(agent?.availableModes).toEqual([
{ id: "plan", label: "Plan" },
@@ -5111,6 +5114,10 @@ test("session config drift events update state through the stream channel", asyn
thinkingOptionId: "high",
});
expect(streams.map((event) => event.type)).toEqual([]);
const persisted = await storage.get(snapshot.id);
expect(persisted?.lastModeId).toBe("build");
expect(persisted?.config?.modeId).toBe("build");
});
test("setLabels merges and persists labels", async () => {
@@ -9287,6 +9294,7 @@ test("respondToPermission updates currentModeId after plan approval", async () =
class PlanModeTestClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
resumedModeId: string | null | undefined;
async isAvailable(): Promise<boolean> {
return true;
@@ -9296,14 +9304,19 @@ test("respondToPermission updates currentModeId after plan approval", async () =
return new PlanModeTestSession();
}
async resumeSession(): Promise<AgentSession> {
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumedModeId = overrides?.modeId;
return new PlanModeTestSession();
}
}
const client = new PlanModeTestClient();
const manager = new AgentManager({
clients: {
codex: new PlanModeTestClient(),
codex: client,
},
registry: storage,
logger,
@@ -9347,6 +9360,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
await manager.flush();
const persisted = await storage.get(snapshot.id);
expect(persisted?.lastModeId).toBe("acceptEdits");
// The provider-managed switch must also reach config.modeId — otherwise a
// reload resumes the session with the stale creation-time mode ("plan").
expect(persisted?.config?.modeId).toBe("acceptEdits");
const reloaded = await manager.reloadAgentSession(snapshot.id);
expect(reloaded.config.modeId).toBe("acceptEdits");
expect(client.resumedModeId).toBe("acceptEdits");
});
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
@@ -11417,3 +11437,33 @@ test("failed startup history closes the session without registering an agent", a
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
}
});
test("usage session is a pure read of the live adapter and disappears on close", async () => {
const descriptor = {
provider: "claude",
model: "opus",
env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" },
sessionKey: "launch-1",
};
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new (class extends TestAgentSession {
usageSession() {
return descriptor;
}
})(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
expect(manager.usageSession("missing")).toBeNull();
const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, {
workspaceId: undefined,
});
try {
expect(manager.usageSession(agent.id)).toBe(descriptor);
expect(manager.usageSession(agent.id)).toBe(descriptor);
} finally {
await manager.closeAgent(agent.id);
}
expect(manager.usageSession(agent.id)).toBeNull();
});
@@ -190,10 +190,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
provider: record.provider,
cwd: record.cwd,
};
// lastModeId is the last live mode — it also covers provider-side switches
// that never reach record.config.modeId.
const modeId = record.lastModeId ?? record.config?.modeId;
if (modeId != null) config.modeId = modeId;
if (!record.config) {
return config;
}
if (record.config.modeId != null) config.modeId = record.config.modeId;
if (record.config.model != null) config.model = record.config.model;
if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId;
@@ -1157,6 +1160,11 @@ export class AgentManager {
}
}
usageSession(id: string) {
const agent = this.agents.get(id);
return agent?.session?.usageSession?.() ?? null;
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;
@@ -3909,6 +3917,19 @@ export class AgentManager {
return this.registry;
}
/**
* Provider-side mode switches (ACP current_mode_update, in-session
* commands, permission-driven transitions) must land in config.modeId too —
* reloadAgentSession and the persisted record derive the resumed session's
* mode from it, so leaving it stale silently downgrades the mode on resume.
*/
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
agent.currentModeId = modeId;
if (modeId != null) {
agent.config.modeId = modeId;
}
}
private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
@@ -3921,7 +3942,7 @@ export class AgentManager {
}
try {
agent.currentModeId = await agent.session.getCurrentMode();
this.applyObservedMode(agent, await agent.session.getCurrentMode());
} catch {
agent.currentModeId = null;
}
@@ -4301,7 +4322,7 @@ export class AgentManager {
this.emitState(agent);
return undefined;
case "mode_changed":
agent.currentModeId = event.currentModeId;
this.applyObservedMode(agent, event.currentModeId);
agent.availableModes = event.availableModes;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
@@ -4317,7 +4338,7 @@ export class AgentManager {
agent.cwd,
);
}
agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId;
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
@@ -647,7 +647,15 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentUsageSession {
provider: string;
model?: string;
env: Record<string, string>;
sessionKey: string;
}
export interface AgentSession {
usageSession?(): AgentUsageSession | null;
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
describe("agent timeline content", () => {
test("limits terminal input to the tool-call content budget", () => {
test("marks truncated plain-text tool content within the content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
const item = limitAgentTimelineItemContent({
@@ -19,6 +19,7 @@ describe("agent timeline content", () => {
},
});
expect(limitAgentTimelineItemContent(item)).toEqual(item);
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
@@ -27,7 +28,7 @@ describe("agent timeline content", () => {
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024),
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
icon: "square_terminal",
},
});
@@ -42,11 +42,12 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
) {
return item;
}
const notice = "\n\n[Content truncated]";
return {
...item,
detail: {
...item.detail,
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
},
};
}
@@ -806,7 +806,6 @@ function createPaseoWorktreeForMcpTest(options: {
emit: () => {},
sessionLogger: createTestLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
@@ -449,6 +449,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get features() {
return inner.features;
},
usageSession: inner.usageSession?.bind(inner),
run: (prompt, options) => inner.run(prompt, options),
startTurn: (prompt, options) => inner.startTurn(prompt, options),
steerActiveTurn: inner.steerActiveTurn?.bind(inner),
@@ -825,6 +825,46 @@ describe("mapACPUsage", () => {
});
});
describe("ACP context-window usage", () => {
async function emitUsageUpdate(update: {
used: number;
size: number;
}): Promise<{ events: unknown[] }> {
const session = createSessionWithConfig({ provider: "dsh" });
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
const events: unknown[] = [];
session.subscribe((event) => {
if (event.type === "usage_updated") events.push(event);
});
await session.sessionUpdate({
sessionId: "session-1",
update: { sessionUpdate: "usage_update", used: update.used, size: update.size },
});
return { events };
}
test("forwards usage_update as context-window usage state", async () => {
const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 });
expect(events).toEqual([
{
type: "usage_updated",
provider: "dsh",
usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 },
},
]);
});
test("emits nothing when size and used cannot both drive a meter", async () => {
await expect(
emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events),
).resolves.toEqual([]);
await expect(
emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events),
).resolves.toEqual([]);
});
});
describe("deriveModesFromACP", () => {
test("prefers explicit ACP mode state", () => {
const result = deriveModesFromACP(
@@ -2455,10 +2455,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
this.cancelPendingPermissions();
if (this.activeForegroundTurnId) {
await this.connection.cancel({ sessionId: this.sessionId });
@@ -2474,10 +2471,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
this.cancelPendingPermissions();
if (this.connection && this.sessionId) {
try {
@@ -3178,8 +3172,29 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
// ACP reports context occupancy out of band from the prompt response, so the
// composer's context meter cannot read it from `turn_completed`. Forward it as
// the same `usage_updated` snapshot the native adapters emit, preserving the
// turn usage this session already recorded. The guards match the meter's own
// validity rules, so a provider cannot push a window the meter would reject.
private handleUsageUpdate(update: UsageUpdate): void {
void update;
const contextWindowMaxTokens =
Number.isFinite(update.size) && update.size > 0 ? update.size : undefined;
const contextWindowUsedTokens =
Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined;
if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) {
return;
}
this.pushEvent({
type: "usage_updated",
provider: this.provider,
usage: {
...this.currentTurnUsage,
contextWindowMaxTokens,
contextWindowUsedTokens,
},
turnId: this.activeForegroundTurnId ?? undefined,
});
}
private handlePromptResponse(response: PromptResponse, turnId: string): void {
@@ -3277,6 +3292,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3285,6 +3305,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
if (!this.bootstrapThreadEventPending || !this.sessionId) {
return;
@@ -314,6 +314,127 @@ describe("background Claude subagents", () => {
});
});
test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => {
// Claude Code answers a background Agent call at once ("Agent launched") and only then streams
// the child's frames, so they arrive after the parent's tool call has its result.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
},
],
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const rows: AgentTimelineRow[] = events
.filter((event) => event.type === "timeline")
.map((event, index) => ({
seq: index + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(),
item: event.item,
}));
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
},
});
});
test.each([
{
source: "system task protocol",

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