Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 8d96ee1522 docs(app): state why pane sizing reads the root layout width 2026-09-30 16:23:03 +02:00
Mohamed Boudra aa50151fa1 fix(app): handle Android fold configuration in place
Android fold transitions change smallestScreenWidthDp. Without declaring that configuration change, MainActivity is recreated and the foreground History route is lost. Keep the activity mounted so the existing responsive runtime receives the new geometry.

(cherry picked from commit ad3394ea5e582119ab5e907f0e614fe54c535428)
2026-09-30 15:42:15 +02:00
Mohamed Boudra bf4979fd62 fix(app): adapt Android orientation and tabs to fold geometry
Use physical display size for orientation and rendered window width for pane layout. Keep the native tab row usable when text measurements do not arrive during a fold.

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: Sam Ng <hongsheng@gmail.com>
(cherry picked from commit 83f0a8219e0afee1f1e6b5084d6a37346b5c98ca)
2026-09-30 15:42:07 +02:00
Mohamed Boudra 7b7685678d feat(antigravity): add Antigravity provider plugin (#5714)
* test(antigravity): record agy stream-json fixtures

* feat(antigravity): add Antigravity provider plugin

* fix(antigravity): address Phase 1 review findings

* test(antigravity): record parity discovery blockers

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

* docs(antigravity): document provider setup and limitations

* test(antigravity): respect Windows interrupt semantics

* docs(plugins): avoid duplicate provider override guidance

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

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

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

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

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

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

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

Closes #5615

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(plugins): plugins title their screens

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(usage): show usage from released hosts

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

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

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

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

---------

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

Closes #5742

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

Closes #5721

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

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

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

Closes #5616

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

Closes #5701

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

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

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

Closes #5690

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

Closes #5643

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

Closes #5673

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

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

Closes #5667

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

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

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

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

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

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

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

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

Closes #5613

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

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

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

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

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 11:19:19 +02:00
617 changed files with 19079 additions and 4919 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.10.2 - 2026-09-30
### Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
## 0.10.1 - 2026-09-29
### Added
+2 -2
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
### Cancellation
+1 -1
View File
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
+2 -1
View File
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -34,6 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **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`).
+108 -12
View File
@@ -259,12 +259,23 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Greeting } from "./client/greeting";
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
return (
<SidebarRow
icon="MessageCircle"
active={currentScreen?.screenId === "main"}
onPress={() => openScreen({ screenId: "main" })}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
return () => {};
}
```
@@ -277,15 +288,33 @@ errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
that id, so links saved before a plugin migrated keep working.
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
keys are stored under a `param.` prefix; plugins never see it and may use any key.
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
disposed per mounted surface. See the
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
@@ -321,7 +350,7 @@ existing agent-context instances, but it cannot create an agent panel without an
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
@@ -352,9 +381,38 @@ export default function contribute(server: PluginServerContext) {
}
```
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
environment. Spawn with those values; do not merge the plugin process's environment back in.
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
Without a declared command, existing providers keep their connection behavior and receive no launch.
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
that effective launch. Return `{ available: true }` or
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
settles (including disabled plugins), then on each installed registry generation.
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
remain valid for plugin registration but cannot be used as config provider IDs.
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
connection receives the new launch without restarting the daemon. Running sessions keep their
current launch until refreshed, as native providers do.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target. Return a key covering effective configuration and execution environment, or
workspace target plus the resolved `launch` when a command is declared. Return a key covering
effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
@@ -365,12 +423,15 @@ Provider settings are toggle/select data that Paseo renders in the composer. Kee
the opaque `providerOptions` config object.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
@@ -392,6 +453,41 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
one section's order from built-ins, plugin groups, and the section's preference
(`sidebarNavItems` or `sidebarFooterItems`).
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
the responder system, so the row's own press anchors too. Until a press, the first mounted row
holds the anchor. An item that renders no kit component
anchors to its section container.
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
the line runs edge to edge in both sections.
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
row; a button inside it wins its own press because only the innermost pressable responds.
- A new-API item renders from the current route's host, else the host remembered under
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
set. One key per plugin: switching host on any of its screens moves all its items.
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
@@ -461,7 +557,7 @@ be rendered intact. The daemon advertises this RPC through
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
+22 -4
View File
@@ -1,6 +1,22 @@
# Adding a New Provider to Paseo
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
server integrations.
## Plugin providers
Keep a bundled provider in `plugins/<id>/` and register it through
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
For Antigravity installation, modes, and limitations, see
[Supported providers](../public-docs/supported-providers.md#antigravity).
## Provider-native session options
@@ -46,7 +62,7 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Two Integration Patterns
## Core adapter patterns
### ACP (Agent Client Protocol) -- recommended
@@ -99,6 +115,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
@@ -197,7 +215,7 @@ promise for completion: equal results, including equal discovery timestamps, emi
## Usage sources
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
@@ -293,7 +311,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+11
View File
@@ -21273,6 +21273,16 @@
"node": ">=10"
}
},
"node_modules/expo-screen-orientation": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz",
"integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@@ -36473,6 +36483,7 @@
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-screen-orientation": "~9.0.9",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
+10 -1
View File
@@ -3,6 +3,7 @@ const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
@@ -100,7 +101,8 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
// Android chooses its full window before the runtime phone/fold/tablet policy applies.
orientation: "default",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
@@ -108,6 +110,12 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
// Keep the existing iPhone restriction while the iPad orientation key
// generated by Expo continues to permit rotation.
UISupportedInterfaceOrientations: [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
],
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
@@ -142,6 +150,7 @@ export default {
plugins: [
"expo-router",
withPasteInput,
withAndroidFoldConfiguration,
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
@@ -0,0 +1,122 @@
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
// The real daemon discovers both through the same Codex model/list boundary.
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
},
},
},
});
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
if (!(await speed.isVisible())) {
if (await features.isVisible()) await features.click();
else await modelSettings.click();
}
await expect(speed).toBeVisible({ timeout: 30_000 });
await speed.click();
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
}
async function selectSpeed(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).last().click();
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
if (choices.includes(choice)) {
await expect(option).toBeVisible();
await expect(option).toBeInViewport({ ratio: 1 });
} else await expect(option).not.toBeVisible();
}
}
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
const client = await connectDaemonClient<
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
>({
clientIdPrefix: "codex-speed-model",
});
try {
await client.setAgentModel(agentId, modelId);
} finally {
await client.close();
}
}
for (const viewport of [
{ name: "desktop", width: 1280, height: 850 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
try {
const agent = await workspace.client.createAgent({
provider: "codex",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Speed settings",
model: "gpt-6.1-sol",
modeId: "full-access",
initialPrompt: "Get ready to work on this project.",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
if (viewport.name === "desktop") {
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toHaveText("");
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast"]);
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
}
} finally {
await workspace.cleanup();
}
});
}
@@ -0,0 +1,70 @@
import { test } from "../support/fixtures";
import {
expectContentColumnWidth,
expectCustomContentWidthSetting,
expectDefaultContentWidthSetting,
openAppearanceSettings,
resetContentWidth,
setContentWidth,
} from "../support/helpers/content-width";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "content-width-",
title: "Content width",
initialPrompt: "Write a long paragraph.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
const assistantMessage = page.getByTestId("assistant-message").first();
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
await test.step("chat starts at the default width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
});
await test.step("settings show the default without a reset", async () => {
await openAppearanceSettings(page);
await expectDefaultContentWidthSetting(page, 820);
});
await test.step("a custom width is saved and offers a reset", async () => {
await setContentWidth(page, 1600);
await expectCustomContentWidthSetting(page, 1600);
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
});
await test.step("chat and Markdown files use the custom width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
});
await test.step("reset returns to the default without storing it", async () => {
await openAppearanceSettings(page);
await resetContentWidth(page);
await expectDefaultContentWidthSetting(page, 820);
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
});
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,478 @@
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BOTS_PLUGIN_ID,
COMPACT,
LEGACY_PLUGIN_ID,
SHOWCASE_PLUGIN_ID,
WIDE,
closeScreen,
expectRowActive,
footerItem,
headerRow,
installSidebarPlugins,
popoverBox,
runCommand,
visibleTestId,
} from "../support/helpers/plugin-sidebar-items";
import {
leaveSettings,
openSidebarNavSettings,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
async function qaScreenshot(page: Page, name: string, area?: Locator) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let popover, sheet and drawer animations settle so the image shows the final state.
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (area) await area.screenshot({ path: file });
else await page.screenshot({ path: file });
}
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
async function expectFooterRow(page: Page, row: Locator) {
// Polled: on compact the drawer is still sliding in when the row first shows.
await expect
.poll(async () => {
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
const box = (await row.boundingBox())!;
return {
belowAddProject: box.y >= addProject.y + addProject.height,
aboveIconRow: box.y + box.height <= hosts.y,
alignedLeft: Math.abs(box.x - addProject.x) < 1,
fullWidth: Math.abs(box.width - addProject.width) < 1,
};
})
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
}
function botRow(page: Page, rowId: string): Locator {
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
}
async function boxOf(locator: Locator) {
const result = await locator.boundingBox();
expect(result).not.toBeNull();
return result!;
}
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
function screenTitle(page: Page): Locator {
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
}
async function expectBotScreen(page: Page, botId: string) {
await expect(page).toHaveURL(
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
);
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
// The plugin titles the screen from its params: the bot's name.
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
await expectRowActive(botRow(page, "status"), false);
}
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
const popover = await popoverBox(page, text);
const rowBox = await boxOf(row);
const otherBox = await boxOf(elsewhere);
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
}
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
function trackReleasedSubscriptions(page: Page): Set<string> {
const released = new Set<string>();
page.on("websocket", (socket) =>
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
);
return released;
}
function collectRelease(released: Set<string>, payload: string | Buffer) {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.message?.type === "subscription.release.response")
released.add(frame.message.payload.subscriptionId);
}
async function expectReleased(released: Set<string>, subscriptionId: string) {
await expect.poll(() => released.has(subscriptionId)).toBe(true);
}
function readAlertsObservation(page: Page): Promise<string | null> {
return page.evaluate(
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
);
}
/** The id of the observation the Bot alerts item opened after `previous`. */
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
return (await readAlertsObservation(page))!;
}
function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
const plugins = await installSidebarPlugins();
workspaceId = workspace.workspaceId;
cleanup = async () => {
await plugins.cleanup();
await workspace.cleanup();
};
});
test.afterEach(async () => {
await cleanup();
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(sync).toBeVisible();
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
0,
);
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
});
await test.step("the header row highlights while its screen is open", async () => {
await expectRowActive(row, false);
await row.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(screenTitle(page)).toHaveText("Deploys");
await expectRowActive(row, true);
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
await closeScreen(page);
await expectRowActive(row, false);
});
await test.step("the trailing button presses on its own", async () => {
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
await expect(page).not.toHaveURL(/\/plugin\//);
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
});
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
await expect(sync).toHaveAccessibleName("Sync");
await expectFooterRow(page, sync);
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
});
await test.step("the footer row opens a popover anchored to it", async () => {
await sync.click();
const popover = await popoverBox(page);
const button = (await sync.boundingBox())!;
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
expect(button.y - popover.y).toBeLessThan(300);
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
await qaScreenshot(page, "phase2-desktop-popover");
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
await closeScreen(page);
});
await test.step("the command center item opens the screen", async () => {
await runCommand(page, "Open deploys");
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expectRowActive(row, true);
await closeScreen(page);
});
await test.step("on a compact layout the popover is a bottom sheet", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
const sheet = await popoverBox(page);
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
await qaScreenshot(page, "phase2-compact-sheet");
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
});
});
test("a header row's shortcut hint presses the row", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
await newWorkspace.hover();
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
await expect(hint).toBeVisible();
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
await hint.click();
await expect(page).toHaveURL(/\/new(\?|$)/);
});
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
test.setTimeout(180_000);
await page.addInitScript(
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
{
key: APP_SETTINGS_KEY,
sidebarNavItems: [
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
{ key: "new-workspace", visible: true },
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
],
},
);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
await expect(entry).toBeVisible({ timeout: 30_000 });
let entryIcon = "";
await test.step("the saved order and visibility still apply", async () => {
await expect(entry).toHaveAccessibleName("Legacy entry");
await expect(entry.locator("svg")).toHaveCount(1);
entryIcon = await entry.locator("svg").innerHTML();
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
const entryBox = (await entry.boundingBox())!;
const newWorkspaceBox = (await visibleTestId(
page,
"sidebar-global-new-workspace",
).boundingBox())!;
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
});
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
await entry.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
await expect(screenTitle(page)).toHaveText("Legacy entry");
await expectRowActive(entry, true);
await closeScreen(page);
});
await test.step("remembered sidebar routes keep resolving", async () => {
await page.goto(
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
);
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
});
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
const host = encodeURIComponent(getServerId());
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(screenTitle(page)).toHaveText("Bot 2");
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
await expect(screenTitle(page)).toHaveText("Bot");
});
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
await openSidebarNavSettings(page);
const legacyIcon = page
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
.locator("svg")
.first();
const showcaseIcon = page
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
.locator("svg")
.first();
const footerIcon = page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
.locator("svg")
.first();
const markup = await showcaseIcon.innerHTML();
expect(await footerIcon.innerHTML()).toBe(markup);
expect(await legacyIcon.innerHTML()).not.toBe(markup);
// The same registered icon the sidebar row draws.
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
await qaScreenshot(
page,
"phase5-settings-sidebar-footer",
page.getByTestId("sidebar-nav-section-footer"),
);
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
await qaScreenshot(page, "phase2-compact-settings-sidebar");
});
});
test("one item renders a group of rows, and each row opens its own screen params", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const bot1 = botRow(page, "bot-1");
const bot2 = botRow(page, "bot-2");
const status = botRow(page, "status");
await expect(bot1).toBeVisible({ timeout: 30_000 });
await test.step("the rows and the separator render in order in the header", async () => {
await expect(bot1).toHaveAccessibleName("Bot 1");
await expect(bot2).toHaveAccessibleName("Bot 2");
await expect(status).toHaveAccessibleName("Bot status");
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
expect(second.y).toBeGreaterThan(first.y);
// The separator adds its line and margins between Bot 2 and the status row.
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
expect(second.y - (first.y + first.height)).toBeLessThan(4);
});
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
await bot2.click();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
});
await test.step("a reload keeps the screen and its params", async () => {
await page.reload();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-bot-screen");
});
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
await bot1.click();
await expectBotScreen(page, "bot-1");
await page.goBack();
await expectBotScreen(page, "bot-2");
});
await test.step("a popover opened from a later row anchors to that row", async () => {
await status.click();
await expectPopoverBeside(page, "Bot status details", status, bot1);
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
});
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
await qaScreenshot(page, "phase6-desktop-popover-row");
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
});
await test.step("a param named like a route segment reaches the screen", async () => {
await runCommand(page, "Open bot 3 on server x");
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
// No bot named bot-3, so the title function returns its own fallback.
await expect(screenTitle(page)).toHaveText("Bot");
await page.reload();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
});
await test.step("Settings lists the group as one item", async () => {
await openSidebarNavSettings(page);
const header = page.getByTestId("sidebar-nav-section-header");
await expect(
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
).toHaveCount(1);
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
await leaveSettings(page);
});
await test.step("the compact sidebar shows the same group", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expect(botRow(page, "bot-1")).toBeInViewport();
await expect(botRow(page, "status")).toBeInViewport();
await qaScreenshot(page, "phase6-compact-sidebar-bots");
});
});
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await expect(alerts).toHaveCount(0);
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
await expect(alerts).toHaveAccessibleName("Bot alerts");
await expectFooterRow(page, alerts);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
});
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
test.setTimeout(120_000);
const released = trackReleasedSubscriptions(page);
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
const first = await nextAlertsObservation(page, null);
expect(released.has(first)).toBe(false);
await test.step("hiding the item in Settings closes its observation", async () => {
await openSidebarNavSettings(page);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
await expectReleased(released, first);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
await leaveSettings(page);
});
await test.step("removing the item through its remover closes its observation", async () => {
await expect(alerts).toBeVisible();
const second = await nextAlertsObservation(page, first);
expect(released.has(second)).toBe(false);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
await expectReleased(released, second);
});
});
});
@@ -1,15 +1,23 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
import {
expectFooterIconRow,
expectFooterItemHidden,
expectFooterOrder,
expectFooterSettingsKeys,
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveFooterItemUp,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarFooterPreferences,
seedSidebarNavPreferences,
setFooterItemVisible,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
@@ -24,8 +32,8 @@ test.describe("Sidebar items in Appearance settings", () => {
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
"About Header",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
@@ -106,3 +114,63 @@ test.describe("Sidebar items in Appearance settings", () => {
await expectSidebarItemHidden(page, "schedules");
});
});
test.describe("Sidebar footer rows in Appearance settings", () => {
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
({ cleanup } = await installSidebarPlugins());
});
test.afterEach(async () => {
await cleanup();
});
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
test.setTimeout(120_000);
// Keys for the fixed footer icon buttons are ignored.
await seedSidebarFooterPreferences(page, [
{ key: "help", visible: false },
{ key: "hosts", visible: false },
{ key: "import", visible: false },
]);
await gotoAppShell(page);
await test.step("the Footer card lists only the footer rows", async () => {
await openSidebarNavSettings(page);
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
"About Footer",
);
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
await leaveSettings(page);
await expectFooterOrder(page, ["usage", syncKey]);
await expectFooterIconRow(page);
});
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
await openSidebarNavSettings(page);
await moveFooterItemUp(page, syncKey);
await setFooterItemVisible(page, "usage", false);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
await leaveSettings(page);
await expectFooterItemHidden(page, "usage");
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible();
await expectFooterIconRow(page);
});
await test.step("the rows keep that shape across a reload", async () => {
await page.reload();
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible({ timeout: 30_000 });
await expectFooterItemHidden(page, "usage");
await expectFooterIconRow(page);
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
});
});
});
@@ -1,219 +0,0 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
return {
id: REPORT_ID,
account: { label: "Fixture account" },
fetchedAt,
sourceId: "fixture",
sourceLabel: "Fixture plan",
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
report: {
status: "available",
planLabel: "Test plan",
windows: [
{ id: "session", label: "Session", usedPct: 3 },
{ id: "weekly", label: "Weekly", usedPct, headline: true },
],
},
};
}
async function openMockAgent(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "usage-composer-pill-",
title: "Usage composer pill e2e",
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
return session;
}
test.describe("usage composer pill", () => {
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
await expect(popover.getByText("Test plan")).toBeVisible();
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("refreshes only its report from the popover", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: [REPORT_ID] },
{ forceRefresh: true, reportIds: [REPORT_ID] },
]);
await expect(popover.getByText("64%")).toBeVisible();
await expect(pill).toContainText("64%");
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
// Pressing closes the tooltip; it reopens on the next hover.
await popover.getByText("Fixture plan", { exact: true }).hover();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
expect(usage.listRequests()).toHaveLength(2);
} finally {
await session.cleanup();
}
});
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [
[agentEntry(42, twoHoursAgo())],
{ error: "Usage source timed out" },
() => [agentEntry(64, new Date().toISOString())],
],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
const refresh = popover.getByTestId("usage-refresh");
await refresh.click();
await usage.waitForListRequests(2);
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
"Unable to refresh usage",
);
await expect(popover.getByText("42%")).toBeVisible();
await expect(pill).toContainText("42%");
await refresh.click();
await usage.waitForListRequests(3);
await expect(popover.getByText("64%")).toBeVisible();
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
} finally {
await session.cleanup();
}
});
test("prints the freshness on the card where there is no hover", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 390, height: 844 });
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
const requestsBeforeTurn = usage.agentRequests().length;
const listRequestsBeforeTurn = usage.listRequests().length;
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
expect(usage.agentRequests().at(-1)).toEqual({
agentId: session.agentId,
});
await expect(pill).toContainText("42%");
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
} finally {
await session.cleanup();
}
});
test("is hidden when the agent has no usage report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
const session = await openMockAgent(page);
try {
await usage.waitForAgentRequests(1);
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
test("is hidden on a host without usage sources", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
usageSources: false,
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.agentRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
});
+32 -7
View File
@@ -1,3 +1,5 @@
import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
@@ -9,8 +11,24 @@ function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
async function qaScreenshot(page: Page, name: string) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}
/** The host selector names the host shown, even when it is the only one. */
async function shownHostLabel(page: Page): Promise<string> {
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
return (await selector.innerText()).trim();
}
test.describe("usage screen", () => {
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
@@ -24,7 +42,7 @@ test.describe("usage screen", () => {
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
},
},
{
@@ -46,6 +64,11 @@ test.describe("usage screen", () => {
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
const hostLabel = await shownHostLabel(page);
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
`Usage host: ${hostLabel}`,
);
await qaScreenshot(page, "phase7-usage-screen-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -66,7 +89,7 @@ test.describe("usage screen", () => {
sourceLabel: "Beta plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
},
};
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
@@ -77,7 +100,7 @@ test.describe("usage screen", () => {
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
const usage = await installUsageReportsFixture(page, {
@@ -138,19 +161,21 @@ test.describe("usage screen", () => {
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
});
test("tells the user to update a host without usage sources", async ({ page }) => {
test("tells the user to update a host without usage support", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
const hostLabel = await shownHostLabel(page);
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
expect(usage.listRequests()).toHaveLength(0);
});
});
@@ -39,7 +39,7 @@ test.describe("usage settings", () => {
sourceLabel: "Alpha plan",
report: {
planLabel: "Max",
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
}),
report({
@@ -82,6 +82,10 @@ test.describe("usage settings", () => {
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
await card.getByTestId("usage-display-remaining").click();
await expect(card.getByText("30% left")).toBeVisible();
await expect(card.getByText("93% left")).toBeVisible();
});
test("refresh forces a fresh report", async ({ page }) => {
@@ -119,17 +123,18 @@ test.describe("usage settings", () => {
await expect(page.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
test("asks to update a host without usage support and never calls it", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
// Names the host: "Update Laptop to see usage".
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
@@ -0,0 +1,192 @@
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectNoPinnedUsage,
expectOnUsageScreen,
expectPinnedUsage,
leaveUsageScreen,
openCompactSidebar,
pinRow,
showUsageAs,
togglePin,
usageItem,
usageSheet,
} from "../support/helpers/usage-sidebar-item";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let sheet and drawer animations settle so the image shows the final state.
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `phase4-${name}.png`);
if (area.kind === "element") {
await area.locator.screenshot({ path: file });
return;
}
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
await page.screenshot({ path: file, clip });
}
/** The sidebar footer, from Add project down to the icon row, with some margin. */
async function footerClip(page: Page) {
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
const margin = 16;
const x = Math.max(0, top.x - margin);
const y = Math.max(0, top.y - margin);
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
}
test.describe("Usage item", () => {
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
test.setTimeout(180_000);
const serverId = getServerId();
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
const screen = page.getByTestId(`usage-host-${serverId}`);
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
await expectNoPinnedUsage(page);
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
// One host: the selector still names it.
await expect(
page.locator('[data-testid="usage-host-switcher"]:visible'),
).toHaveAccessibleName(/^Usage host: .+/);
await togglePin(screen, "Claude", "Session");
await togglePin(screen, "Codex", "Weekly");
await expectPinnedUsage(page, ["31%", "12%"]);
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
await qaScreenshot(page, "usage-screen-header", {
kind: "element",
locator: page.getByTestId("page-title").locator(".."),
});
await qaScreenshot(page, "usage-card-refresh", {
kind: "element",
locator: screen.getByTestId("usage-report-claude:default"),
});
});
await test.step("remaining flips the Usage item and the Usage screen", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% left", "88% left"]);
await expect(
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
).toBeVisible();
await expect(
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
).toBeVisible();
});
await test.step("on desktop the Usage item opens the Usage screen", async () => {
await gotoAppShell(page);
await expect(page).not.toHaveURL(/\/usage$/);
await usageItem(page).click();
await expectOnUsageScreen(page);
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
});
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
await page.setViewportSize(COMPACT);
await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% left", "88% left"]);
await qaScreenshot(page, "compact-footer");
await usageItem(page).click();
const sheet = usageSheet(page);
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
await expect(sheet.getByText("88% left")).toBeVisible();
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
// The row reads its window, percent and reset; the checkbox state says it is pinned.
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the Usage screen's controls, host selector included.
await expect(
page.locator('[data-testid="usage-host-switcher"]:visible'),
).toHaveAccessibleName(/^Usage host: .+/);
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
const sheetBox = (await sheet.boundingBox())!;
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
await qaScreenshot(page, "phase7-compact-sheet");
// Tap the backdrop above the sheet.
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
await expect(sheet).toHaveCount(0);
await page.setViewportSize(WIDE);
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% left", "88% left"]);
await expect(
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
).toHaveAttribute("aria-selected", "true");
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
const usageUrl = page.url();
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const section = page.getByTestId("usage-card");
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
await page.goto(usageUrl);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
await togglePin(screen, "Claude", "Session");
await togglePin(screen, "Codex", "Weekly");
await expectNoPinnedUsage(page);
await page.reload();
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoPinnedUsage(page);
await gotoAppShell(page);
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("Settings > Sidebar lists the Usage item", async () => {
await openSidebarNavSettings(page);
const footer = page.getByTestId("sidebar-nav-section-footer");
await expect(
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
).toBeVisible();
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
await leaveSettings(page);
});
});
});
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
.toBeNull();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `branchless-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(backingWorktree.projectKey);
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
execFileSync("git", ["switch", "--detach", "main"], {
cwd: backingWorktree.workspaceDirectory,
stdio: "pipe",
});
const created = await worktreeClient.createWorkspace({
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
title: "Sample workspace",
});
if (!created.workspace) {
throw new Error(created.error ?? "Could not create the branchless workspace");
}
const workspaceId = created.workspace.id;
const agent = await createIdleAgent(client, {
cwd: backingWorktree.workspaceDirectory,
workspaceId,
title: "Archived agent",
});
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
scope: "worktree",
});
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
await expect(
page.getByText(
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
{ exact: true },
),
).toBeVisible();
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim();
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
cwd: tempRepo.path,
encoding: "utf8",
}).trim();
expect(restoredHead).toBe(defaultHead);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim(),
).toMatch(/^restored\//);
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
});
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
function commitRestorableChange(cwd: string): void {
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
@@ -0,0 +1,88 @@
import readline from "node:readline";
import { randomUUID } from "node:crypto";
const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" };
const ultra = {
id: "ultrafast",
name: "Ultrafast",
description: "Lowest latency, increased usage",
};
const models = [
{ id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] },
{ id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] },
].map((model) =>
Object.assign(model, {
model: model.id,
defaultReasoningEffort: "medium",
supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }],
}),
);
function send(message) {
process.stdout.write(JSON.stringify(message) + "\n");
}
function respond(method, params) {
switch (method) {
case "initialize":
return {};
case "model/list":
return { data: models };
case "config/read":
return { config: { model: models[0].id, model_reasoning_effort: "medium" } };
case "collaborationMode/list":
return { data: [] };
case "skills/list":
return { data: [] };
case "thread/list":
case "thread/loaded/list":
return { data: [] };
case "thread/start":
return { thread: { id: randomUUID() }, model: params.model };
case "thread/resume":
return { thread: { id: params.threadId } };
case "thread/read":
return { thread: { id: params.threadId, turns: [] } };
case "turn/start": {
const turnId = randomUUID();
setTimeout(() => {
send({
method: "turn/started",
params: { threadId: params.threadId, turn: { id: turnId } },
});
send({
method: "item/completed",
params: {
threadId: params.threadId,
turnId,
item: {
id: randomUUID(),
type: "agentMessage",
text: "Ready to work on this project.",
},
},
});
send({
method: "turn/completed",
params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } },
});
}, 50);
return { turn: { id: turnId } };
}
default:
throw new Error(`Unsupported Codex fixture request: ${method}`);
}
}
if (process.argv.includes("--version")) {
console.log("codex-cli 0.159.0");
} else {
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
try {
send({ id: request.id, result: respond(request.method, request.params ?? {}) });
} catch (error) {
send({ id: request.id, error: { code: -32601, message: error.message } });
}
});
}
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
# Run only on a task-owned foldable emulator with a connected Paseo debug app.
# Usage: native-fold-history-route.android.sh <adb-serial> <avd-name> <agent-device-session> <workspace-name>
serial=${1:?adb serial required}
avd_name=${2:?expected AVD name required}
session=${3:?agent-device session required}
workspace_name=${4:?workspace name required}
actual_avd=$(adb -s "$serial" shell getprop ro.boot.qemu.avd_name | tr -d '\r')
if [[ "$actual_avd" != "$avd_name" ]]; then
echo "Refusing device $serial: expected $avd_name, found $actual_avd" >&2
exit 2
fi
initial_state=$(adb -s "$serial" shell cmd device_state print-state | tr -d '\r')
if [[ "$initial_state" != "0" && "$initial_state" != "2" ]]; then
echo "Expected CLOSED=0 or OPENED=2; got $initial_state" >&2
exit 2
fi
trap 'adb -s "$serial" shell cmd device_state state "$initial_state" >/dev/null' EXIT
wait_for_history() {
local snapshot
for _ in {1..20}; do
snapshot=$(agent-device snapshot --session "$session" -i)
if rg -q '\[group\] "History"' <<<"$snapshot"; then
return 0
fi
sleep 0.5
done
echo "$snapshot" >&2
return 1
}
wait_for_compact_workspace() {
local snapshot
for _ in {1..20}; do
snapshot=$(agent-device snapshot --session "$session" -i)
if rg -q 'Unable to find node on an unmounted component|RootErrorBoundary' <<<"$snapshot"; then
echo "Workspace hit a React Native error after folding" >&2
echo "$snapshot" >&2
return 1
fi
if rg -q 'Switch tabs \(' <<<"$snapshot"; then
return 0
fi
sleep 0.5
done
echo "$snapshot" >&2
return 1
}
adb -s "$serial" shell cmd device_state state 2
sleep 3
agent-device press 'text="History"' --session "$session"
if ! wait_for_history; then
echo "History route did not open on the wide foldable" >&2
exit 1
fi
adb -s "$serial" shell cmd device_state state 0
sleep 4
if ! wait_for_history; then
echo "History route was lost after folding CLOSED" >&2
exit 1
fi
adb -s "$serial" shell cmd device_state state 2
sleep 4
if ! wait_for_history; then
echo "History route was lost after unfolding OPENED" >&2
exit 1
fi
agent-device press "label=\"$workspace_name\"" --session "$session"
adb -s "$serial" shell cmd device_state state 0
sleep 4
if ! wait_for_compact_workspace; then
echo "Compact workspace failed after returning from History" >&2
exit 1
fi
echo "History retained and compact workspace mounted after OPENED -> CLOSED -> OPENED -> CLOSED"
@@ -0,0 +1,5 @@
# Android fold-open flow
Use a task-owned foldable emulator with real `cmd device_state` CLOSED (0) and OPENED (2) states, a connected development app, and an unsent draft. The recorded run used `paseo-fold-p3a` (Pixel 9 Pro Fold, API 35). Set `DRAFT_TEXT` to the exact composer value. Run the flow after a CLOSED → OPENED transition and confirm `adb -s <serial> shell cmd device_state print-state` returns `2`. `workspace-tabs-scroll` and the Settings footer prove the wide shell is usable. A regular tablet can satisfy those UI assertions, so the device-state precondition is part of the test, not inferred from the flow.
The `.ad` flow opens the app to establish an agent-device session. For the Gboard transition check, keep the app foreground, show Gboard with a real unsent draft, change device state with `cmd device_state state 0` then `state 2`, and capture the app before reopening it. An app-open step can recover a missed native layout callback and cannot prove that the immediate transition was correct. Retain before/after screenshots, a post-transition accessibility snapshot, `dumpsys activity` window bounds, and `dumpsys input_method` IME state. Repeat with the keyboard hidden, and verify CLOSED still presents the compact tab switcher.
@@ -0,0 +1,8 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 12000
wait "label=\"${DRAFT_TEXT}\"" 10000
wait "label=\"Settings\"" 10000
close
@@ -0,0 +1,24 @@
context platform=android timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 45000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-key-esc\"" 10000
is visible "id=\"terminal-virtual-keyboard-native-wide\""
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-ctrl\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' NATIVE_WIDE_KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_NATIVE_WIDE_KEY_OK__" 10000
press "id=\"terminal-key-ctrl\""
type "c" --delay-ms 0
type "printf '__AD_%s__\\n' MODIFIER_RESET_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_MODIFIER_RESET_OK__" 10000
close
@@ -0,0 +1,17 @@
context platform=ios timeout=90000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-new-tab-button\"" 30000
press "id=\"workspace-new-tab-button\""
wait "id=\"workspace-new-tab-menu-terminal\"" 10000
press "id=\"workspace-new-tab-menu-terminal\""
wait "id=\"terminal-virtual-keyboard-native-wide\"" 10000
is visible "id=\"terminal-key-esc\""
is visible "id=\"terminal-key-enter\""
is visible "id=\"terminal-paste\""
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_IOS_%s__\\n' KEY_OK" --delay-ms 0
press "id=\"terminal-key-enter\""
wait "text" "__AD_IOS_KEY_OK__" 10000
close
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: workspace-new-tab-button
timeout: 30000
- tapOn:
id: workspace-new-tab-button
- tapOn:
id: workspace-new-tab-menu-terminal
- extendedWaitUntil:
visible:
id: terminal-virtual-keyboard-native-wide
timeout: 10000
- assertVisible:
id: terminal-key-esc
- assertVisible:
id: terminal-key-enter
- assertVisible:
id: terminal-paste
- tapOn:
id: terminal-paste
- runFlow:
when:
visible: Allow Paste
commands:
- tapOn: Allow Paste
- tapOn:
id: terminal-key-enter
- extendedWaitUntil:
visible: __AD_IOS_PASTE_OK__
timeout: 10000
@@ -0,0 +1,18 @@
# Native wide terminal device flows
Run these against a connected task-owned tablet with the current development app, Metro bundle, and a workspace whose terminal opens successfully. The flows assert `terminal-virtual-keyboard-native-wide`, which is emitted only for a native noncompact window. That assertion makes a compact phone fail before any PTY success marker. A landscape phone that reaches the noncompact breakpoint is excluded by the explicit tablet `--device` argument; do not rely on the runner's default device.
Android Pixel Tablet API 36.1:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-keybar.android.ad --platform android --device paseo-tablet-p1-api36
```
iPad Pro 11-inch (M5), iOS 26.5: from the Namespace Mac checkout, prefill the simulator clipboard with the command below, then run the Maestro compatibility flow. Its `runFlow.when.visible` presses **Allow Paste** only if the iOS sheet appears; every other action and output assertion remains strict. The clipboard payload contains `PASTE_OK`, while only executed PTY output contains `__AD_IOS_PASTE_OK__`.
```sh
printf '%s' "printf '__AD_IOS_%s__\\n' PASTE_OK" | xcrun simctl pbcopy E49C88C3-9A60-4715-B63A-881498D1991A
agent-device replay packages/app/e2e/mobile/agent-device/native-terminal-wide-paste.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
```
The unsigned simulator build used for phase 1 raises a known `getRegistrationInfoAsync` entitlement overlay on launch. The flow dismisses only that exact development error and asserts it is gone before touching terminal controls; another overlay still fails the flow. `native-terminal-wide-keybar.ios.ad` separately proves that the wide iPad bar sends Enter to the PTY. It needs no clipboard permission. The Maestro flow above is the paste gate for both first-run and already-granted states.
@@ -0,0 +1,12 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
wait "id=\"workspace-tabs-scroll\"" 10000
close
@@ -0,0 +1,14 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"workspace-tab-agent_${AGENT_ID}\"" 20000
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}\"" 10000
is visible "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
press "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,17 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"context-window-meter\"" 20000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"context-window-meter\"" 10000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,17 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"context-window-meter\"" 20000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"context-window-meter\"" 10000
press "id=\"context-window-meter\""
wait "id=\"context-window-meter-tooltip\"" 10000
press "id=\"tooltip-dismiss\""
wait "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\"" 10000
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,33 @@
# Native wide menu flows
Run these on a connected task-owned tablet with the current development app and Metro bundle. `workspace-tabs-scroll` asserts the wide tab row; also pass an explicit tablet `--device` so a landscape phone above the breakpoint cannot stand in for tablet coverage. The context meter flow requires an agent tab with reported token usage selected. Its tooltip assertion uses a stable ID emitted only by the open content.
The agent tab menu flow requires an existing agent tab. Set `AGENT_ID` to the ID of that agent and `TAB_ID` to its workspace tab ID (shown in the `workspace-tab-context-*` test ID) before replay. The flow long-presses the tab and selects **Copy agent id**. It does not claim that an arrow tap works.
The sidebar Rename flow requires a selected workspace and its `WORKSPACE_KEY` (shown in the `sidebar-workspace-kebab-*` test ID). It opens the row's actions, opens Rename, then cancels without changing the workspace name. This covers the currently working row reported in #5226.
For the native-wide tooltip dismissal flows, select an agent with reported token usage so `context-window-meter` exists. A fresh load-test stream can supply usage. The Maestro flows assert that the tooltip is absent after dismissal, then reopen it to prove the control is usable. The overlay dismissal flows assert that the tab menu and Rename dialog are absent after selection/cancel. On iPad the focused Rename input can consume the first Cancel tap; the iOS Maestro flow conditionally repeats that tap only while the dialog remains visible.
For tab scrolling, first create enough tabs for actual horizontal overflow. The Android replay was validated with nine tabs and the Explorer sidebar open; the iPad replay was validated with six tabs. Set `DRAFT_ID` to a visible draft tab, `AGENT_ID` to the first visible agent tab, `OTHER_AGENT_ID` to another agent tab, and `TAB_ID` to the first agent's workspace tab ID. The short pan starts on an agent chip while a draft is selected; the draft composer must remain active. The flow then checks a stationary long press and an ordinary tap. Keep the tab row scrolled to its beginning before running the iPad flow.
Android Pixel Tablet API 36.1:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.android.ad --platform android --device paseo-tablet-p1-api36 --env OTHER_AGENT_ID=<other-agent-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.android.ad --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.android.ad --platform android --device paseo-tablet-p1-api36 --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.android.yaml --maestro --platform android --device paseo-tablet-p1-api36 --env AGENT_ID=<existing-agent-id> --env TAB_ID=<existing-tab-id> --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.android.ad --platform android --device paseo-tablet-p1-api36 --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
```
iPad Pro 11-inch (M5), iOS 26.5, from the Namespace Mac checkout:
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-context-meter.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env OTHER_AGENT_ID=<other-agent-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-agent-tab-menu.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-sidebar-rename.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tooltip-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)'
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-overlay-dismissal.ios.yaml --maestro --platform ios --device 'iPad Pro 11-inch (M5)' --env WORKSPACE_KEY=<selected-workspace-key>
agent-device replay packages/app/e2e/mobile/agent-device/native-wide-tab-scroll.ios.ad --platform ios --device 'iPad Pro 11-inch (M5)' --env DRAFT_ID=<visible-draft-id> --env AGENT_ID=<existing-agent-id> --env OTHER_AGENT_ID=<other-agent-id> --env TAB_ID=<existing-tab-id>
```
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- extendedWaitUntil:
visible:
id: workspace-tabs-scroll
timeout: 30000
- tapOn:
id: workspace-tab-agent_${AGENT_ID}
- longPressOn:
id: workspace-tab-agent_${AGENT_ID}
- assertVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- assertNotVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- longPressOn:
id: workspace-tab-agent_${AGENT_ID}
- assertVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- assertNotVisible:
id: workspace-tab-context-${TAB_ID}-copy-agent-id
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- assertNotVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- stopApp
@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: workspace-tabs-scroll
timeout: 30000
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- runFlow:
when:
visible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
commands:
- tapOn:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel
- extendedWaitUntil:
notVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
timeout: 10000
- assertNotVisible:
id: sidebar-workspace-rename-modal-${WORKSPACE_KEY}
- tapOn:
id: sidebar-workspace-kebab-${WORKSPACE_KEY}
- assertVisible:
id: sidebar-workspace-menu-rename-${WORKSPACE_KEY}
- stopApp
@@ -0,0 +1,13 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
wait "id=\"workspace-tabs-scroll\"" 10000
close
@@ -0,0 +1,16 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 20000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
press "id=\"sidebar-workspace-rename-modal-${WORKSPACE_KEY}-cancel\""
wait "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\"" 10000
press "id=\"sidebar-workspace-kebab-${WORKSPACE_KEY}\""
wait "id=\"sidebar-workspace-menu-rename-${WORKSPACE_KEY}\"" 10000
close
@@ -0,0 +1,17 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
gesture pan 900 155 550 0 280
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
wait "id=\"composer-import-agent-pill\"" 10000
gesture pan 770 155 -310 0 280
is visible "id=\"composer-import-agent-pill\""
gesture pan 900 155 550 0 280
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
press "label=\"Menu backdrop\""
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,16 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 20000
press "id=\"workspace-tab-draft_${DRAFT_ID}\""
wait "id=\"composer-import-agent-pill\"" 10000
gesture pan 450 86 -210 0 280
is visible "id=\"composer-import-agent-pill\""
gesture pan 450 86 240 0 280
longpress "id=\"workspace-tab-agent_${AGENT_ID}\"" 900
wait "id=\"workspace-tab-context-${TAB_ID}-copy-agent-id\"" 10000
press "label=\"Menu backdrop\""
press "id=\"workspace-tab-agent_${OTHER_AGENT_ID}\""
wait "id=\"agent-chat-scroll\"" 10000
close
@@ -0,0 +1,23 @@
appId: sh.paseo.debug
---
- launchApp
- extendedWaitUntil:
visible:
id: context-window-meter
timeout: 30000
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
@@ -0,0 +1,30 @@
appId: sh.paseo.debug
---
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- assertNotVisible: ".*getRegistrationInfoAsync.*"
- extendedWaitUntil:
visible:
id: context-window-meter
timeout: 30000
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
- tapOn:
id: context-window-meter
- assertVisible:
id: context-window-meter-tooltip
- tapOn:
id: tooltip-dismiss
- assertNotVisible:
id: context-window-meter-tooltip
@@ -0,0 +1,26 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
Attach only to your task's app, while no device replay is running:
```sh
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
```
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
This covers native container resizing and selection retention. It does not cover
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
menu. Those require the real chat journey: read an older multiline response,
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
retain expanded/collapsed details, and check a stationary reader's position.
Accessibility exposes full text even when glyphs are clipped, so a text wait
alone does not verify that journey.
@@ -0,0 +1,55 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
auto hostFrame = host.frame;
hostFrame.size.width = 500;
hostFrame.size.height = 200;
host.frame = hostFrame;
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
container.frame = host.bounds;
UITextView *text = nil;
for (UIView *child in host.subviews) {
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
for (UIView *nested in child.subviews) {
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
}
}
text.frame = container.bounds;
text.font = [UIFont systemFontOfSize:16];
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
auto range = text.selectedRange;
range.location = 12;
range.length = 9;
text.selectedRange = range;
auto frame = container.frame;
frame.size.width = 240;
container.frame = frame;
[container layoutIfNeeded];
CGFloat narrow = text.frame.size.width;
auto selection = text.selectedRange;
frame.size.width = 500;
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
echo "Expected a running PaseoDebug simulator app" >&2
exit 1
fi
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "command script import $probe_dir/ios.py" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -0,0 +1,63 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
function contentWidthInput(page: Page): Locator {
return page.getByLabel("Content width in pixels", { exact: true });
}
function resetContentWidthButton(page: Page): Locator {
return page.getByRole("button", { name: "Reset content width to default", exact: true });
}
export async function openAppearanceSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
}
export async function setContentWidth(page: Page, width: number): Promise<void> {
const input = contentWidthInput(page);
await input.fill(String(width));
await input.press("Tab");
}
export async function resetContentWidth(page: Page): Promise<void> {
await resetContentWidthButton(page).click();
}
export async function expectDefaultContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toHaveCount(0);
await expect.poll(() => readStoredContentWidth(page)).toBeNull();
}
export async function expectCustomContentWidthSetting(page: Page, width: number): Promise<void> {
await expect(contentWidthInput(page)).toHaveValue(String(width));
await expect(resetContentWidthButton(page)).toBeVisible();
await expect.poll(() => readStoredContentWidth(page)).toBe(width);
}
/** Asserts the rendered width of a content-column element stays within `max` and above `min`. */
export async function expectContentColumnWidth(
element: Locator,
bounds: { min?: number; max: number },
): Promise<void> {
await expect(element).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeLessThanOrEqual(bounds.max);
await expect
.poll(async () => (await element.boundingBox())?.width ?? 0)
.toBeGreaterThan(bounds.min ?? 0);
}
async function readStoredContentWidth(page: Page): Promise<unknown> {
return page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw === null
? undefined
: (JSON.parse(raw) as { contentMaxWidth?: unknown }).contentMaxWidth;
}, APP_SETTINGS_KEY);
}
@@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) {
React.useEffect(() => {
const owner = paseo.observeEvents(["project.update"]);
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
// Deliberately leave this observation to the mounted host scope.
return () => { void owner.release(); };
}, [paseo]);
const rpc = useRpc(summary);
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
@@ -0,0 +1,268 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
export const BOTS_PLUGIN_ID = "sidebar-bots";
export const WIDE = { width: 1440, height: 900 };
export const COMPACT = { width: 390, height: 844 };
/** A plugin written against the current API: a screen, header and footer items, a command. */
const SHOWCASE_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
function DeploysScreen({ theme }) {
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Deploys screen body</Text></View>;
}
function RefreshButton({ theme, onPress }) {
return (
<Pressable accessibilityRole="button" accessibilityLabel="Refresh deploys" onPress={onPress} style={{ paddingHorizontal: 6 }}>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Refresh</Text>
</Pressable>
);
}
function DeploysItem({ theme, currentScreen, openScreen }) {
const [refreshes, setRefreshes] = React.useState(0);
return (
<SidebarRow
icon="Rocket"
label={refreshes === 0 ? undefined : "Deploys (refreshed " + refreshes + ")"}
active={currentScreen?.screenId === "deploys"}
onPress={() => openScreen({ screenId: "deploys" })}
trailing={<RefreshButton theme={theme} onPress={() => setRefreshes((count) => count + 1)} />}
/>
);
}
function SyncDetails({ theme, layout, close, openScreen }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>Sync details</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Open deploys from popover" onPress={() => openScreen({ screenId: "deploys" })}><Text style={{ color: theme.colors.foreground }}>Open deploys</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Close sync details" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
}
function SyncItem({ openPopover }) {
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
}
function Broken() {
throw new Error("Sidebar item exploded");
}
export default function contribute(client) {
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
return () => {};
}
`;
/**
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
* id on `globalThis.__botAlertsObservation`.
*/
const BOTS_SOURCE = `import React from "react";
import { Pressable, Text, View } from "react-native";
import { usePaseo } from "@getpaseo/plugin/client";
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
const BOTS = [
{ id: "bot-1", name: "Bot 1" },
{ id: "bot-2", name: "Bot 2" },
];
function BotScreen({ theme, params }) {
return (
<View style={{ flex: 1, padding: 24, gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>{"Bot screen: " + params.botId}</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>{"serverId param: " + (params.serverId ?? "none")}</Text>
</View>
);
}
function botTitle(params) {
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
}
const botMenus = {};
function botMenu(bot) {
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>{bot.name + " options"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel={"Close " + bot.name + " options"} onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
};
return botMenus[bot.id];
}
function MoreButton({ theme, bot, onPress }) {
return (
<Pressable accessibilityRole="button" accessibilityLabel={"More for " + bot.name} onPress={onPress} style={{ paddingHorizontal: 6 }}>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>More</Text>
</Pressable>
);
}
function BotStatus({ theme, close }) {
return (
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.foreground }}>Bot status details</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Close bot status" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>
);
}
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
return (
<>
{BOTS.map((bot) => (
<SidebarRow
key={bot.id}
id={bot.id}
icon="Bot"
label={bot.name}
active={currentScreen?.screenId === "bot" && currentScreen.params.botId === bot.id}
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
trailing={<MoreButton theme={theme} bot={bot} onPress={() => openPopover(botMenu(bot))} />}
/>
))}
<SidebarSeparator />
<SidebarRow id="status" icon="Activity" label="Bot status" onPress={() => openPopover(BotStatus)} />
</>
);
}
function AlertsItem() {
const paseo = usePaseo();
React.useEffect(() => {
const observation = paseo.observeEvents(["project.update"]);
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
return () => { void observation.release(); };
}, [paseo]);
return <SidebarRow icon="Bell" onPress={() => {}} />;
}
export default function contribute(client) {
let removeAlerts = null;
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
} });
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
removeAlerts?.();
removeAlerts = null;
} });
return () => {};
}
`;
/** A plugin that still uses only the old descriptor calls. */
const LEGACY_SOURCE = `import React from "react";
import { Text, View } from "react-native";
function Main({ theme }) {
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Legacy screen body</Text></View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Main);
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
return () => {};
}
`;
export async function installSidebarPlugins() {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directories: string[] = [];
for (const [id, source] of [
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
[BOTS_PLUGIN_ID, BOTS_SOURCE],
] as const) {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
}
await client.patchDaemonConfig({ pluginsEnabled: true });
for (const directory of directories) await client.installDirectoryPlugin(directory);
return {
async cleanup() {
await client.removePlugin(SHOWCASE_PLUGIN_ID);
await client.removePlugin(LEGACY_PLUGIN_ID);
await client.removePlugin(BOTS_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await Promise.all(
directories.map((directory) => rm(directory, { recursive: true, force: true })),
);
},
};
}
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
export function visibleTestId(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
}
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
}
export async function expectRowActive(row: Locator, active: boolean): Promise<void> {
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
else await expect(row).not.toHaveAttribute("aria-selected", "true");
}
export async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
export async function closeScreen(page: Page): Promise<void> {
await page.getByTestId("plugin-surface-close").click();
await expect(page).not.toHaveURL(/\/plugin\//);
}
/** Box of the popover body, located from the text it renders. */
export async function popoverBox(page: Page, text = "Sync details") {
const details = page.getByText(text, { exact: true });
await expect(details).toBeInViewport();
const box = await details.boundingBox();
expect(box).not.toBeNull();
return box!;
}
@@ -55,10 +55,25 @@ export async function seedSidebarNavPreferences(
);
}
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
export async function seedSidebarFooterPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "sidebar");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
}
export async function leaveSettings(page: Page): Promise<void> {
@@ -125,9 +140,9 @@ export async function expectStoredSidebarNav(
.toEqual(expected);
}
async function expectVerticalOrder(
keys: SidebarNavKey[],
locate: (key: SidebarNavKey) => Locator,
async function expectVerticalOrder<Key extends string>(
keys: Key[],
locate: (key: Key) => Locator,
subject: string,
): Promise<void> {
await expect
@@ -136,11 +151,7 @@ async function expectVerticalOrder(
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
if (
!measured.every(
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
)
)
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
@@ -148,3 +159,83 @@ async function expectVerticalOrder(
)
.toEqual(keys);
}
/** Persisted footer row key -> the testID the app shell renders that row with. */
function shellFooterTestID(key: string): string {
// With nothing pinned the Usage item is the plain Usage row.
if (key === "usage") return "sidebar-usage";
const [, pluginId, itemId] = key.split(":");
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
}
function shellFooterRow(page: Page, key: string): Locator {
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
}
function footerSettingsRows(page: Page): Locator {
return page
.getByTestId("sidebar-nav-section-footer")
.locator('[data-testid^="sidebar-nav-item-"]');
}
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
await expect
.poll(async () =>
(
await footerSettingsRows(page).evaluateAll((rows) =>
rows.map((row) => row.getAttribute("data-testid")),
)
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
)
.toEqual(keys);
}
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
await page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-move-up-${key}`)
.click();
}
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}`);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
}
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
}
const FOOTER_ICON_TEST_IDS = [
"sidebar-hosts-trigger",
"sidebar-import-session",
"sidebar-help",
"sidebar-settings",
];
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
);
const [first, ...rest] = boxes.map((box) => box!);
let previous = first;
for (const box of rest) {
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
expect(box.x).toBeGreaterThan(previous.x);
previous = box;
}
}
@@ -4,9 +4,7 @@ import { daemonWsRoutePattern } from "./daemon-port";
export interface UsageReportsFixture {
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
agentRequests(): Array<{ agentId: string }>;
waitForListRequests(count: number): Promise<void>;
waitForAgentRequests(count: number): Promise<void>;
}
interface UsageReportsFixtureOptions {
@@ -15,10 +13,8 @@ interface UsageReportsFixtureOptions {
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
*/
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
/** Successive agent report IDs; the last one repeats. */
agentReportIds?: Array<string | null>;
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
usageSources?: boolean;
/** False simulates a host with no usage reporting capability. */
usageSupported?: boolean;
}
type UsageListResponse = UsageReportEntry[] | { error: string };
@@ -42,7 +38,7 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return envelope.message as Record<string, unknown>;
}
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
@@ -61,7 +57,10 @@ function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): s
...envelope,
message: {
...envelope.message,
payload: { ...payload, features: { ...features, usageSources: enabled } },
payload: {
...payload,
features: { ...features, usageSources: enabled, providerUsageList: enabled },
},
},
});
}
@@ -95,10 +94,8 @@ export async function installUsageReportsFixture(
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
const agentRequests: Array<{ agentId: string }> = [];
const listCounter = createCounter();
const agentCounter = createCounter();
const usageSources = options.usageSources ?? true;
const usageSupported = options.usageSupported ?? true;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -142,37 +139,18 @@ export async function installUsageReportsFixture(
listCounter.increment();
return;
}
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
agentRequests.push({
agentId: String(request.agentId),
});
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
ws.send(
JSON.stringify({
type: "session",
message: {
type: "agent.resolve_usage_report.response",
payload: { requestId, reportId },
},
}),
);
agentCounter.increment();
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo =
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
ws.send(serverInfo ?? message);
});
});
return {
listRequests: () => [...listRequests],
agentRequests: () => [...agentRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
waitForAgentRequests: (count) => agentCounter.waitFor(count),
};
}
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, type Locator, type Page } from "@playwright/test";
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
function sourceIcon(plugin: string): string {
return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8");
}
function inOneDay(): string {
return new Date(Date.now() + 24 * 60 * 60_000).toISOString();
}
/** Claude and Codex reports shaped like the built-in sources report them. */
export function claudeAndCodexReports(): UsageReportEntry[] {
const fetchedAt = new Date().toISOString();
return [
{
id: "claude:default",
account: { label: "dev@example.com" },
fetchedAt,
sourceId: "claude",
sourceLabel: "Claude",
icon: sourceIcon("claude-usage-source"),
report: {
status: "available",
planLabel: "Max",
windows: [
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
],
},
},
{
id: "codex:default",
account: { label: "dev@example.com" },
fetchedAt,
sourceId: "codex",
sourceLabel: "Codex",
icon: sourceIcon("codex-usage-source"),
report: {
status: "available",
planLabel: "Pro",
windows: [
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
],
},
},
];
}
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
function visible(page: Page, testID: string): Locator {
return page.locator(`[data-testid="${testID}"]:visible`).first();
}
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
/** The compact usage sheet the Usage item opens. */
export function usageSheet(page: Page): Locator {
return visible(page, "usage-expanded");
}
export async function expectOnUsageScreen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/usage$/);
}
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
await expect(windows).toHaveText(percents);
}
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
export async function expectNoPinnedUsage(page: Page): Promise<void> {
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
await expect(usageItem(page)).toHaveText("Usage");
}
/** A window row, which is itself the pin toggle: "Claude", "Session". */
export function pinRow(scope: Locator, source: string, window: string): Locator {
// The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h".
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
}
export async function togglePin(scope: Locator, source: string, window: string) {
const row = pinRow(scope, source, window);
const pinned = await row.isChecked();
await row.click();
await expect(row).toBeChecked({ checked: !pinned });
}
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
}
/** Opens the compact sidebar drawer. */
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
export async function leaveUsageScreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).first().click();
await expect(page).not.toHaveURL(/\/usage$/);
}
@@ -0,0 +1,15 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.screengeometry'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.screengeometry"
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,28 @@
package sh.paseo.screengeometry
import android.os.Build
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoScreenGeometryModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoScreenGeometry")
Function("getPhysicalScreenSize") {
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display
} else {
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay
}
val mode = display.mode
val density = activity.resources.displayMetrics.density
mapOf(
"width" to mode.physicalWidth / density,
"height" to mode.physicalHeight / density,
)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"]
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-screen-geometry",
"version": "0.1.0",
"private": true
}
+1
View File
@@ -95,6 +95,7 @@
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-screen-orientation": "~9.0.9",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
@@ -0,0 +1,21 @@
const { withAndroidManifest } = require("expo/config-plugins");
function withAndroidFoldConfiguration(config) {
return withAndroidManifest(config, (modConfig) => {
const activity = modConfig.modResults.manifest.application?.[0]?.activity?.find(
(entry) => entry.$?.["android:name"] === ".MainActivity",
);
if (!activity) {
throw new Error("Could not configure fold changes without MainActivity");
}
// A fold changes smallestScreenWidthDp as well as screenSize. Let React Native and
// Unistyles receive the configuration change without Android recreating MainActivity.
const changes = new Set((activity.$["android:configChanges"] ?? "").split("|").filter(Boolean));
changes.add("smallestScreenSize");
activity.$["android:configChanges"] = [...changes].join("|");
return modConfig;
});
}
module.exports = withAndroidFoldConfiguration;
@@ -62,6 +62,8 @@ function installProbe(
clientBundle,
lifetime: new AbortController(),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
};
}
@@ -1,6 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
@@ -144,6 +145,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
})}
</>,
);
@@ -187,6 +189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -233,6 +236,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -291,6 +295,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const unrevised = {
contentById: new Set<string>(),
@@ -354,6 +359,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -429,6 +435,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -473,6 +480,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
}),
);
});
@@ -553,6 +561,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -646,6 +655,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -755,6 +765,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -860,6 +871,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -959,6 +971,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1056,6 +1069,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1175,6 +1189,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
container = document.createElement("div");
document.body.appendChild(container);
@@ -1408,6 +1423,7 @@ describe("createWebStreamStrategy", () => {
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
@@ -303,6 +303,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -403,7 +404,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
const row = segments.historyVirtualized[index];
return row ? estimateStreamItemHeight(row) : 120;
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
},
measureElement: measureVirtualElement,
rangeExtractor,
@@ -92,6 +92,8 @@ export interface StreamRenderInput {
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
}
export interface ResolveStreamRenderStrategyInput {
@@ -1,7 +1,6 @@
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { SPACING, type Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
@@ -215,7 +214,7 @@ function TurnFooterRow({ children }: { children: ReactNode }) {
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
+6 -4
View File
@@ -23,7 +23,7 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
@@ -55,7 +55,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
import { resolveContentMaxWidth, useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import { returnToTimelineTail } from "./timeline-tail-navigation";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -349,6 +349,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
const contentMaxWidth = useSettings(resolveContentMaxWidth);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
@@ -1129,6 +1130,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1622,7 +1624,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
contentWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -1643,7 +1645,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
@@ -123,8 +124,8 @@ describe("splitWebVirtualizedHistory", () => {
describe("estimateStreamItemHeight", () => {
it("uses compact estimates for collapsed tool sequence rows", () => {
expect(estimateStreamItemHeight(toolCall("tool", 1))).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2))).toBe(40);
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
});
it("uses a larger estimate for user messages with image attachments", () => {
@@ -146,7 +147,7 @@ describe("estimateStreamItemHeight", () => {
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
});
it("uses cached assistant image metadata when available", () => {
@@ -165,7 +166,7 @@ describe("estimateStreamItemHeight", () => {
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
});
});
@@ -58,12 +58,12 @@ export interface WebVirtualizedHistoryWindow {
mountedEntries: IndexedStreamItem[];
}
export function estimateStreamItemHeight(item: StreamItem): number {
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
@@ -0,0 +1,19 @@
import { getConfig } from "@expo/config";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("native orientation configuration", () => {
const { exp } = getConfig(path.resolve(import.meta.dirname, ".."));
it("lets Android choose the full display window before runtime orientation policy applies", () => {
expect(exp.orientation).toBe("default");
});
it("keeps the iPhone portrait orientations explicit while iPad supports rotation", () => {
expect(exp.ios?.infoPlist?.UISupportedInterfaceOrientations).toEqual([
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
]);
expect(exp.ios?.supportsTablet).toBe(true);
});
});
+23 -11
View File
@@ -15,7 +15,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { AppState, useWindowDimensions, View } from "react-native";
import { AppState, View, type LayoutChangeEvent } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -50,6 +50,7 @@ import {
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { MeasuredWindowWidthProvider, useLayoutWindowWidth } from "@/constants/window-width";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -87,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useAdaptiveOrientation } from "@/orientation";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
@@ -457,6 +459,7 @@ interface AppContainerProps {
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const isOrientationReady = useAdaptiveOrientation();
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
@@ -466,7 +469,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const viewportWidth = useLayoutWindowWidth();
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -622,6 +625,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
if (!isOrientationReady) return null;
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
@@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) {
}
function RootAppTree() {
const [measuredWindowWidth, setMeasuredWindowWidth] = useState<number | null>(null);
const handleRootLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width));
}, []);
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
<MeasuredWindowWidthProvider width={measuredWindowWidth}>
<GestureHandlerRootView style={flexStyle} onLayout={handleRootLayout}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
</MeasuredWindowWidthProvider>
);
}
@@ -37,6 +37,7 @@ interface FakeTheme {
"4xl": number;
};
lineHeight: { diff: number };
contentMaxWidth: number;
colors: { foreground: string; syntax: Record<string, string> };
}
@@ -56,6 +57,7 @@ function makeFakeTheme(): FakeTheme {
"4xl": 26,
},
lineHeight: { diff: 22 },
contentMaxWidth: 820,
colors: { foreground: "#fff", syntax: {} },
};
}
@@ -67,6 +69,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
uiBaseFontSize: 14,
contentFontSize: 15,
codeFontSize: 12,
contentMaxWidth: 820,
syntaxTheme: "one",
...overrides,
};
@@ -102,6 +105,12 @@ describe("applyAppearance", () => {
]);
});
it("patches the content max width into the theme", () => {
applyAppearance(makeInput({ contentMaxWidth: 1600 }));
expect(runCapturedUpdater().contentMaxWidth).toBe(1600);
});
it("resolves an empty UI font family to the default stack", () => {
applyAppearance(makeInput({ uiFontFamily: "" }));
+4 -1
View File
@@ -17,6 +17,7 @@ export interface AppearanceInput {
uiBaseFontSize: number; // already clamped
contentFontSize: number; // already clamped
codeFontSize: number; // already clamped
contentMaxWidth: number; // already clamped, default resolved
syntaxTheme: SyntaxThemeId;
}
@@ -55,7 +56,7 @@ function scaleFontSize(
* always current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
*
* The updater preserves the active theme wholesale (surfaces, accents,
* terminal) and only patches the font ramp and syntax palette.
* terminal) and only patches the font ramp, content width, and syntax palette.
* `updateTheme` replaces the stored theme rather than merging, so we spread
* `...t` first.
*/
@@ -86,6 +87,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
}
@@ -94,6 +96,7 @@ export function applyAppearance(input: AppearanceInput): void {
fontFamily,
fontSize,
lineHeight,
contentMaxWidth: input.contentMaxWidth,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
});
+8 -1
View File
@@ -8,7 +8,12 @@ import {
useState,
} from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
import {
DEFAULT_THEME_PREFERENCE,
resolveContentMaxWidth,
useAppSettings,
type AppSettings,
} from "@/hooks/use-settings";
import {
rememberPluginThemeHost,
usePluginThemeCatalog,
@@ -68,6 +73,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
uiBaseFontSize: settings.uiBaseFontSize,
contentFontSize: settings.contentFontSize,
codeFontSize: settings.codeFontSize,
contentMaxWidth: resolveContentMaxWidth({ contentMaxWidth: settings.contentMaxWidth }),
syntaxTheme: settings.syntaxTheme,
});
setHasAppliedAppearance(true);
@@ -80,6 +86,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
settings.uiBaseFontSize,
settings.contentFontSize,
settings.codeFontSize,
settings.contentMaxWidth,
settings.syntaxTheme,
]);
@@ -13,7 +13,7 @@ import {
PlanModeIcon,
ThinkingIcon,
} from "@/agent-controls/icons";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcons } from "@/components/provider-icons";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
buildAgentControlContributions,
@@ -88,6 +88,7 @@ export function useAgentControlCommandCenterActions(input: {
const { t } = useTranslation();
const { controls } = input;
const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo(
() =>
buildAgentControlContributions({
@@ -96,8 +97,7 @@ export function useAgentControlCommandCenterActions(input: {
provider: controls.provider ?? null,
labels: buildAgentControlContributionLabels(t),
icons: {
provider: (provider) =>
getCommandCenterIcon(getProviderIcon(provider, controls.serverId)),
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
thinking: getCommandCenterIcon(ThinkingIcon),
planMode: getCommandCenterIcon(PlanModeIcon),
mode: (modeId) =>
@@ -129,6 +129,7 @@ export function useAgentControlCommandCenterActions(input: {
},
}),
[
getProviderIcon,
controls.ownerKey,
controls.provider,
controls.providerDefinitions,
+2 -2
View File
@@ -18,7 +18,7 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
@@ -186,7 +186,7 @@ function SessionRow({
const projectName = agent.projectPlacement?.projectName ?? "";
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider, agent.serverId);
const ProviderIcon = useProviderIcon(agent.provider, agent.serverId);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const ranges = useMemo(
() => ({
@@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { Button } from "@/components/ui/button";
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
maxWidth: theme.contentMaxWidth,
},
callout: {
flexDirection: "row",
@@ -187,7 +187,7 @@ export function ContextWindowMeter({
) : null}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<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}>
@@ -0,0 +1,83 @@
import { ChevronDown } from "lucide-react-native";
import { useCallback, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { HostPicker } from "./host-picker";
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface HostSwitcherHost {
serverId: string;
label: string;
}
/** A compact trigger showing the chosen host, opening the host picker to change it. */
export function HostSwitcher({
hosts,
value,
onSelect,
title,
accessibilityLabel,
testID,
}: {
hosts: HostSwitcherHost[];
value: string;
onSelect: (serverId: string) => void;
/** The picker's title. */
title: string;
/** Prefixes the selected host's label: "Plugin host: Laptop". */
accessibilityLabel: string;
testID: string;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value;
const openPicker = useCallback(() => setOpen(true), []);
return (
<HostPicker
hosts={hosts}
value={value}
onSelect={onSelect}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title={title}
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}: ${selectedLabel}`}
testID={testID}
onPress={openPicker}
style={styles.trigger}
>
<Text numberOfLines={1} style={styles.label}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
maxWidth: 180,
minHeight: 32,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -63,7 +63,8 @@ vi.mock("@/constants/layout", () => ({
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
useProviderIcon: () => () => null,
useProviderIcons: () => () => () => null,
}));
vi.mock("lucide-react-native", () => {
@@ -13,7 +13,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -309,7 +309,7 @@ function ImportSessionSheetRow({
const { t } = useTranslation();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
const ProviderIcon = useProviderIcon(entry.providerId, serverId);
const accessibilityState = useMemo(
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
[disabled],
@@ -559,15 +559,17 @@ export function ImportSessionSheet({
setIsFilterOpen(false);
}, []);
const getProviderIcon = useProviderIcons(serverId);
const SelectedProviderIcon = useProviderIcon(selectedProvider, serverId);
const filterOptionIcons = useMemo(() => {
const map = new Map<string, React.ReactNode>();
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
for (const provider of filterProviders) {
const ProviderIcon = getProviderIcon(provider, serverId);
const ProviderIcon = getProviderIcon(provider);
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
}
return map;
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
}, [filterProviders, getProviderIcon, theme.colors.foregroundMuted]);
const renderFilterOption = useCallback(
({
@@ -746,10 +748,7 @@ export function ImportSessionSheet({
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
(() => {
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
})()
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
+46 -36
View File
@@ -1,12 +1,11 @@
import { router } from "expo-router";
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
} from "react-native";
@@ -29,6 +28,7 @@ import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { useLayoutWindowWidth } from "@/constants/window-width";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -43,16 +43,15 @@ import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useHosts } from "@/runtime/host-runtime";
import { PluginSidebarItem } from "@/plugins/sidebar-items";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
buildSettingsAddHostRoute,
buildSettingsRoute,
buildUsageRoute,
} from "@/utils/host-routes";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -430,25 +429,6 @@ function SidebarHostPicker({
);
}
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
const { t } = useTranslation();
const isCompactLayout = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const handlePress = useCallback(() => {
if (isCompactLayout) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompactLayout, showMobileAgent]);
return (
<FooterIconButton
onPress={handlePress}
testID="sidebar-usage"
label={t("settings.hostSections.usage")}
icon={Gauge}
theme={theme}
/>
);
}
function IconTooltipContent({
label,
shortcutKeys,
@@ -472,6 +452,7 @@ function SidebarFooter({
labels,
handleAddHost,
handleOpenHostSettings,
onBeforeNavigate,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
@@ -486,6 +467,7 @@ function SidebarFooter({
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
onBeforeNavigate?: () => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
@@ -498,6 +480,7 @@ function SidebarFooter({
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
@@ -512,7 +495,6 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
<SidebarUsageButton theme={theme} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
@@ -527,6 +509,31 @@ function SidebarFooter({
);
}
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter((item) => item.visible);
if (visibleItems.length === 0) return null;
return (
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
{visibleItems.map((item) =>
item.kind === "plugin" ? (
<PluginSidebarItem
key={item.key}
group={item.group}
section="footer"
fallbackAnchorRef={rowsRef}
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
),
)}
</View>
);
}
function MobileSidebar({
active,
theme,
@@ -634,6 +641,7 @@ function MobileSidebar({
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
onBeforeNavigate={closeSidebar}
/>
</View>
</MobilePanelOverlay>
@@ -670,7 +678,7 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const viewportWidth = useLayoutWindowWidth();
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
@@ -954,29 +962,31 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
// Every footer row is 28px tall with `spacing[1]` between rows, the same as the gap above.
footerRows: {
gap: theme.spacing[1],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
// The leading glyph sits on the footer icons' rail: a 16px icon centered in a 28px button
// starts 6px in, `spacing[1.5]`.
footerAddProjectButton: {
minWidth: 0,
minHeight: 32,
flex: 1,
minHeight: 28,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
borderRadius: theme.borderRadius.lg,
},
footerAddProjectButtonHovered: {
@@ -39,7 +39,7 @@ import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -223,7 +223,7 @@ export function ModelProviderGlyph({
size: number;
tone?: ProviderGlyphTone;
}) {
const Icon = getProviderIcon(provider, serverId);
const Icon = useProviderIcon(provider, serverId);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} color={color} />;
@@ -12,6 +12,8 @@ interface PageLayoutProps {
onBack?: () => void;
testID?: string;
titleTestID?: string;
/** Controls for the whole page: right of the title on desktop, in the back header on compact. */
actions?: ReactNode;
children: ReactNode;
}
@@ -20,7 +22,14 @@ interface PageLayoutProps {
* document heading inside the page and the header only keeps the titlebar drag
* region and window controls; on compact the title moves into a back header.
*/
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
export function PageLayout({
title,
onBack,
testID,
titleTestID,
actions,
children,
}: PageLayoutProps) {
const isCompact = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
@@ -28,14 +37,19 @@ export function PageLayout({ title, onBack, testID, titleTestID, children }: Pag
return (
<View style={styles.container}>
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
{isCompact ? (
<BackHeader title={title} onBack={onBack} rightContent={actions} />
) : (
<MenuHeader borderless />
)}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<View style={styles.content}>
{showTitle ? (
<View testID={titleTestID}>
<View style={styles.titleRow} testID={titleTestID}>
<Text style={styles.title} testID="page-title">
{title}
</Text>
{actions}
</View>
) : null}
{children}
@@ -60,10 +74,16 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: 720,
alignSelf: "center",
},
titleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[4],
marginBottom: theme.spacing[6],
},
title: {
// Line up with the section titles, which sit inset from their cards.
marginLeft: theme.spacing[1],
marginBottom: theme.spacing[6],
fontSize: theme.fontSize["4xl"],
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -5,7 +5,7 @@ import {
TERMINAL_PROFILE_ICON_NAMES,
} from "@getpaseo/protocol/provider-icon-names";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name";
import { resolveProviderIconName } from "./provider-icon-name";
describe("resolveProviderIconName", () => {
it("returns the built-in identifier for known provider ids", () => {
@@ -29,43 +29,7 @@ describe("resolveProviderIconName", () => {
it("resolves a snapshot SVG for a custom provider", () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]);
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg });
});
it("replaces each host snapshot without leaking icons across hosts", () => {
const secondSvg = "<svg id='second' />";
replaceProviderSnapshotIcons("server-1", [
{ provider: "removed-provider", iconSvg: "<svg />" },
{ provider: "snapshot-provider", iconSvg: "<svg />" },
{ provider: "claude", iconSvg: "<svg />" },
]);
replaceProviderSnapshotIcons("server-2", [
{ provider: "snapshot-provider", iconSvg: secondSvg },
]);
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({
kind: "svg",
svg: "<svg />",
});
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
kind: "svg",
svg: secondSvg,
});
expect(resolveProviderIconName("claude", "server-1")).toEqual({
kind: "builtin",
id: "claude",
});
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]);
expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" });
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" });
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
kind: "svg",
svg: secondSvg,
});
expect(resolveProviderIconName("snapshot-provider", svg)).toEqual({ kind: "svg", svg });
});
});
@@ -2,7 +2,6 @@ import {
BUILTIN_PROVIDER_ICON_NAMES,
KNOWN_PROVIDER_ICON_NAMES,
} from "@getpaseo/protocol/provider-icon-names";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
export type ProviderIconName =
| { kind: "builtin"; id: string }
@@ -12,31 +11,10 @@ export type ProviderIconName =
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
const providerSnapshotIconSvgsByServer = new Map<string, ReadonlyMap<string, string>>();
export function replaceProviderSnapshotIcons(
serverId: string,
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
): void {
const icons = new Map<string, string>();
for (const entry of entries) {
if (entry.iconSvg) {
icons.set(entry.provider, entry.iconSvg);
}
}
providerSnapshotIconSvgsByServer.set(serverId, icons);
}
export function resolveProviderIconName(
provider: string,
serverId?: string | null,
): ProviderIconName {
export function resolveProviderIconName(provider: string, iconSvg?: string): ProviderIconName {
if (BUILTIN_PROVIDER_IDS.has(provider)) {
return { kind: "builtin", id: provider };
}
const iconSvg = serverId
? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider)
: undefined;
if (iconSvg) {
return { kind: "svg", svg: iconSvg };
}
@@ -1,8 +1,12 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { Bot } from "lucide-react-native";
import { SvgXml } from "react-native-svg";
import { describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "./provider-icon-name";
import { getProviderIcon, type ProviderIconComponent } from "./provider-icons";
import { afterEach, describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
import { useProviderIcon, useProviderIcons, type ProviderIconComponent } from "./provider-icons";
afterEach(cleanup);
function renderIcon(Component: ProviderIconComponent) {
if (typeof Component !== "function") throw new Error("Expected a function component");
@@ -12,12 +16,14 @@ function renderIcon(Component: ProviderIconComponent) {
});
}
describe("getProviderIcon", () => {
describe("useProviderIcon", () => {
it("renders registered snapshot SVG metadata with the requested size and color", () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1"));
const rendered = renderIcon(
renderHook(() => useProviderIcon("rendered-provider", "server-1")).result.current,
);
expect(rendered).toMatchObject({
type: SvgXml,
@@ -28,6 +34,53 @@ describe("getProviderIcon", () => {
it("uses the normal Bot fallback without snapshot SVG metadata", () => {
replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]);
expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot);
expect(renderHook(() => useProviderIcon("plain-provider", "server-1")).result.current).toBe(
Bot,
);
});
});
it("updates a mounted consumer when the plugin snapshot arrives after mount", () => {
const serverId = "cold-reload-host";
const provider = "plugin-provider";
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons(serverId, []);
const { result, unmount } = renderHook(() => useProviderIcon(provider, serverId));
expect(result.current).toBe(Bot);
act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }]));
expect(renderIcon(result.current)).toMatchObject({
type: SvgXml,
props: { xml: svg, width: 18, height: 18, color: "#123456" },
});
unmount();
});
it("keeps host SVGs isolated, updates replacements, and clears removed icons", () => {
const provider = "shared-plugin-provider";
const firstSvg = '<svg id="first" />';
const secondSvg = '<svg id="second" />';
replaceProviderSnapshotIcons("first-host", [{ provider, iconSvg: firstSvg }]);
replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: secondSvg }]);
const first = renderHook(() => useProviderIcon(provider, "first-host"));
const second = renderHook(() => useProviderIcon(provider, "second-host"));
const firstIcon = first.result.current;
expect(renderIcon(firstIcon)).toMatchObject({ props: { xml: firstSvg } });
expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: secondSvg } });
act(() => replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: firstSvg }]));
expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: firstSvg } });
expect(first.result.current).toBe(firstIcon);
act(() => replaceProviderSnapshotIcons("second-host", []));
expect(second.result.current).toBe(Bot);
expect(first.result.current).toBe(firstIcon);
});
it("updates collection consumers when their host snapshot arrives", () => {
const serverId = "collection-host";
const provider = "collection-plugin-provider";
const svg = '<svg id="collection" />';
replaceProviderSnapshotIcons(serverId, []);
const { result } = renderHook(() => useProviderIcons(serverId));
expect(result.current(provider)).toBe(Bot);
act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }]));
expect(renderIcon(result.current(provider))).toMatchObject({ props: { xml: svg } });
});
+28 -8
View File
@@ -1,5 +1,7 @@
import { useStore } from "zustand";
import { providerSnapshotIcons } from "@/data/provider-icons";
import { Bot, PackagePlus } from "lucide-react-native";
import { createElement, type ComponentType } from "react";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
@@ -34,7 +36,7 @@ const CATALOG_ICON_SVGS = new Map(
);
const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
const snapshotIconComponents = new Map<string, ProviderIconComponent>();
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
@@ -63,15 +65,15 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
}
function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent {
const cached = snapshotIconComponents.get(provider);
if (cached?.svg === svg) return cached.component;
const cached = snapshotIconComponents.get(svg);
if (cached) return cached;
const component = createSvgIcon(provider, svg);
snapshotIconComponents.set(provider, { svg, component });
snapshotIconComponents.set(svg, component);
return component;
}
export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
const name = resolveProviderIconName(provider, serverId);
function resolveProviderIcon(provider: string, svg?: string): ProviderIconComponent {
const name = resolveProviderIconName(provider, svg);
if (name.kind === "builtin") {
return BUILTIN_PROVIDER_ICONS[name.id];
}
@@ -79,7 +81,25 @@ export function getProviderIcon(provider: string, serverId?: string | null): Pro
return getCatalogProviderIcon(name.id);
}
if (name.kind === "svg") {
return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg);
return getSnapshotProviderIcon(provider, name.svg);
}
return Bot;
}
export function useProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
const svg = useStore(providerSnapshotIcons, (state) =>
serverId ? state.get(serverId)?.get(provider) : undefined,
);
return resolveProviderIcon(provider, svg);
}
/** Subscribe once for consumers that render a collection of providers. */
export function useProviderIcons(serverId?: string | null) {
const icons = useStore(providerSnapshotIcons, (state) =>
serverId ? state.get(serverId) : undefined,
);
return useCallback(
(provider: string) => resolveProviderIcon(provider, icons?.get(provider)),
[icons],
);
}
@@ -24,7 +24,7 @@ import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { Switch } from "@/components/ui/switch";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import { CadenceEditor } from "@/components/schedules/cadence-editor";
import {
SelectField,
@@ -1026,10 +1026,10 @@ function ProviderGlyph({
provider: string | null;
serverId: string | null;
}): ReactElement | null {
const Icon = useProviderIcon(provider ?? "", serverId);
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider, serverId);
return <Icon size={16} color={styles.providerIcon.color} />;
}
@@ -10,7 +10,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
@@ -144,7 +144,7 @@ function ScheduleMeta({
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
* dynamic component (useProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({
provider,
serverId,
@@ -152,10 +152,10 @@ function ProviderGlyph({
provider: string | null;
serverId: string;
}): ReactElement | null {
const Icon = useProviderIcon(provider ?? "", serverId);
if (!provider) {
return null;
}
const Icon = getProviderIcon(provider, serverId);
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
}
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { type ReactNode, useCallback } from "react";
import { Import, Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -6,6 +6,28 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
function SidebarEmptyStateCard({
testID,
title,
description,
children,
}: {
testID: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<View style={styles.card} testID={testID}>
<View style={styles.copy}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
<View style={styles.actions}>{children}</View>
</View>
);
}
/**
* The two things the sidebar's workspace list says when it has no rows.
*
@@ -27,13 +49,15 @@ export function SidebarFilterEmptyState() {
}, [clearLabelFilter, clearProjectFilters]);
return (
<View style={styles.container} testID="sidebar-filter-empty-state">
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
<Button variant="ghost" size="sm" onPress={clearFilters}>
<SidebarEmptyStateCard
testID="sidebar-filter-empty-state"
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
{t("sidebar.filterEmpty.clear")}
</Button>
</View>
</SidebarEmptyStateCard>
);
}
@@ -47,40 +71,44 @@ export function SidebarProjectEmptyState({
const { t } = useTranslation();
return (
<View style={styles.container} testID="sidebar-project-empty-state">
<Text style={styles.title}>{t("sidebar.project.empty.title")}</Text>
<Text style={styles.description}>{t("sidebar.project.empty.description")}</Text>
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
<SidebarEmptyStateCard
testID="sidebar-project-empty-state"
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
{t("importSession.title")}
</Button>
</View>
</SidebarEmptyStateCard>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
card: {
marginTop: theme.spacing[3],
padding: theme.spacing[4],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
copy: {
gap: theme.spacing[1],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
fontWeight: theme.fontWeight.normal,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
@@ -1,7 +1,13 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
@@ -11,10 +17,12 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
type SidebarHeaderRowVariant = "header" | "compact";
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
interface SidebarHeaderRowProps {
icon: LucideIcon;
icon: SidebarRowIcon | null;
label: string;
onPress: () => void;
isActive?: boolean;
@@ -26,9 +34,18 @@ interface SidebarHeaderRowProps {
* the lone header at the top of a sidebar (settings "Back to workspace").
* "compact": a row with no separator, for entries that
* sit in a header group whose wrapper owns the single divider.
* "inline": a full-width row with no inset, for a row inside a padded container such as the
* sidebar footer.
*/
variant?: SidebarHeaderRowVariant;
/** Shown in the right slot while the row is hovered, when `trailing` is not set. */
shortcutKeys?: ShortcutKey[][] | null;
/**
* The right slot. A sibling of the row's button, never inside it (web cannot nest buttons). A
* press on the slot presses the row; a button inside it presses on its own.
*/
trailing?: ReactNode;
rowRef?: Ref<View>;
}
export function SidebarHeaderRow({
@@ -41,71 +58,60 @@ export function SidebarHeaderRow({
accessibilityLabel,
variant = "header",
shortcutKeys = null,
trailing,
rowRef,
}: SidebarHeaderRowProps) {
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive;
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
const containerStyle = useMemo(
() => (variant === "compact" ? styles.containerCompact : styles.container),
[variant],
);
const buttonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.button,
(Boolean(hovered) || isActive) && styles.buttonHovered,
],
[isActive],
);
const renderChildren = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => {
const isHighlighted = Boolean(state.hovered) || isActive;
return (
<>
<ThemedIcon
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
{shortcutKeys && Boolean(state.hovered) ? (
<Shortcut chord={shortcutKeys} style={styles.shortcut} />
) : null}
</>
);
},
[ThemedIcon, isActive, label, shortcutKeys, variant],
);
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
right = <Shortcut chord={shortcutKeys} />;
}
return (
<View style={containerStyle}>
<Pressable
onPress={onPress}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
style={buttonStyle}
<View ref={rowRef} collapsable={false} style={getContainerStyle(variant)}>
<View
style={[styles.row, isHighlighted && styles.rowHighlighted]}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{renderChildren}
</Pressable>
<Pressable
onPress={onPress}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={isActive ? SELECTED_STATE : undefined}
aria-selected={isActive}
style={variant === "inline" ? styles.buttonInline : styles.button}
>
{ThemedIcon ? (
<ThemedIcon
size={iconSize}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
)}
<Text style={[styles.label, isHighlighted && styles.labelHighlighted]}>{label}</Text>
</Pressable>
{right === null ? null : (
<Pressable onPress={onPress} accessible={false} focusable={false} style={styles.trailing}>
{right}
</Pressable>
)}
</View>
</View>
);
}
function SidebarHeaderRowLabel({
label,
isHighlighted,
}: {
label: string;
isHighlighted: boolean;
}) {
const labelStyle = useMemo(
() => [styles.label, isHighlighted && styles.labelHighlighted],
[isHighlighted],
);
return <Text style={labelStyle}>{label}</Text>;
}
const SELECTED_STATE = { selected: true } as const;
const styles = StyleSheet.create((theme) => ({
container: {
@@ -124,23 +130,49 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
userSelect: "none",
},
containerInline: {
width: "100%",
justifyContent: "center",
userSelect: "none",
},
row: {
flexDirection: "row",
alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: 28,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
button: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: 28,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the list below.
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
buttonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
// Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button.
buttonInline: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
},
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
label: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
@@ -148,7 +180,21 @@ const styles = StyleSheet.create((theme) => ({
labelHighlighted: {
color: theme.colors.foreground,
},
shortcut: {
marginLeft: "auto",
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
},
}));
function getContainerStyle(variant: SidebarHeaderRowVariant) {
switch (variant) {
case "header":
return styles.container;
case "compact":
return styles.containerCompact;
case "inline":
return styles.containerInline;
}
}
@@ -1,11 +1,11 @@
import { router, usePathname } from "expo-router";
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
import { memo, useCallback, useMemo, type ComponentType } from "react";
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
import { PluginSidebarItem } from "@/plugins/sidebar-items";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
@@ -38,19 +38,22 @@ interface SidebarNavRowsProps extends SidebarNavRowProps {
* wrapper — when every item is hidden.
*/
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
const { items } = useSidebarNavItems();
const { items } = useSidebarNavItems("header");
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
const groupRef = useRef<View | null>(null);
if (visibleItems.length === 0) return null;
return (
<View style={style}>
<View ref={groupRef} collapsable={false} style={style}>
{visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItemRow
<PluginSidebarItem
key={item.key}
group={item.group}
section="header"
fallbackAnchorRef={groupRef}
onBeforeNavigate={onBeforeNavigate}
/>
);
@@ -0,0 +1,98 @@
import { useCallback, type ReactNode, type RefObject } from "react";
import type { View } from "react-native";
import { MenuRoot, useMenuContext } from "@/components/ui/menu";
import { PluginPopoverSurface } from "@/plugins/popover";
import type { SidebarSection } from "@/sidebar-nav/model";
/**
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
* compact ones. Plugin items and the built-in Usage item open it the same way:
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
* and `SidebarPopoverSurface` for the body.
*/
export function SidebarPopoverRoot({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) {
return (
<MenuRoot compactMode="sheet" open={open} onOpenChange={onOpenChange}>
{children}
</MenuRoot>
);
}
function assignRef(ref: RefObject<View | null>, node: View | null) {
Object.assign(ref, { current: node });
}
/**
* Where the popover anchors. `anchorTo` moves it to an element, such as the row just pressed;
* `offerAnchor` sets it only when nothing is anchored; `releaseAnchor` drops an element that is
* going away; `anchorToFallback` covers an item that anchored nothing.
*/
export function useSidebarPopoverAnchor(name: string): {
anchorTo: (node: View | null) => void;
offerAnchor: (node: View | null) => void;
releaseAnchor: (node: View | null) => void;
anchorToFallback: (fallback: RefObject<View | null>) => void;
} {
const { triggerRef } = useMenuContext(name);
const anchorTo = useCallback((node: View | null) => assignRef(triggerRef, node), [triggerRef]);
const offerAnchor = useCallback(
(node: View | null) => {
if (!triggerRef.current) assignRef(triggerRef, node);
},
[triggerRef],
);
const releaseAnchor = useCallback(
(node: View | null) => {
if (triggerRef.current === node) assignRef(triggerRef, null);
},
[triggerRef],
);
const anchorToFallback = useCallback(
(fallback: RefObject<View | null>) => offerAnchor(fallback.current),
[offerAnchor],
);
return { anchorTo, offerAnchor, releaseAnchor, anchorToFallback };
}
const PLACEMENT = {
header: { side: "right", align: "start" },
footer: { side: "top", align: "start" },
} as const satisfies Record<SidebarSection, { side: string; align: string }>;
export function SidebarPopoverSurface({
section,
title,
sheetTrailing,
testID,
children,
}: {
section: SidebarSection;
/** The bottom sheet's title. */
title: string;
/** Controls on the right of the bottom sheet's title. */
sheetTrailing?: ReactNode;
testID: string;
children: ReactNode;
}) {
const placement = PLACEMENT[section];
return (
<PluginPopoverSurface
sheetTitle={title}
sheetTrailing={sheetTrailing}
side={placement.side}
align={placement.align}
offset={8}
testID={testID}
>
{children}
</PluginPopoverSurface>
);
}
+51 -11
View File
@@ -4,7 +4,13 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Pressable,
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
@@ -31,9 +37,10 @@ import {
} from "@/terminal/runtime/terminal-key-dispatch";
import {
getTerminalVirtualKeyboardControlId,
resolveTerminalVirtualKeyboardRows,
shouldShowTerminalVirtualKeyBar,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
@@ -93,6 +100,7 @@ interface TerminalPaneProps {
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
const TERMINAL_KEY_BAR_MAX_WIDTH = 880;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -138,8 +146,16 @@ function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) {
() => [styles.keyButtonText, active && styles.keyButtonTextActive],
[active],
);
const accessibilityState = useMemo(() => ({ selected: active }), [active]);
return (
<Pressable testID={`terminal-key-${modifier}`} onPress={handlePress} style={pressableStyle}>
<Pressable
accessibilityLabel={MODIFIER_LABELS[modifier]}
accessibilityRole="button"
accessibilityState={accessibilityState}
testID={`terminal-key-${modifier}`}
onPress={handlePress}
style={pressableStyle}
>
<Text style={textStyle}>{MODIFIER_LABELS[modifier]}</Text>
</Pressable>
);
@@ -162,7 +178,13 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps
[],
);
return (
<Pressable testID={`terminal-key-${id}`} onPress={handlePress} style={pressableStyle}>
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
testID={`terminal-key-${id}`}
onPress={handlePress}
style={pressableStyle}
>
<Text style={styles.keyButtonText}>{label}</Text>
</Pressable>
);
@@ -222,12 +244,18 @@ export function TerminalPane({
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
const showVirtualKeyBar = shouldShowTerminalVirtualKeyBar({ isNative, isCompact: isMobile });
const [keyBarWidth, setKeyBarWidth] = useState(0);
const virtualKeyboardRows = resolveTerminalVirtualKeyboardRows({
isCompact: isMobile,
availableWidth: keyBarWidth,
});
const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile;
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
enabled: isNative || isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
@@ -292,7 +320,7 @@ export function TerminalPane({
}, [terminalId]);
const refreshClipboardAvailability = useCallback(async () => {
if (!isMobile) {
if (!showVirtualKeyBar) {
setHasClipboardText(false);
return;
}
@@ -302,7 +330,7 @@ export function TerminalPane({
} catch {
setHasClipboardText(false);
}
}, [isMobile]);
}, [showVirtualKeyBar]);
useEffect(() => {
void refreshClipboardAvailability();
@@ -436,7 +464,7 @@ export function TerminalPane({
const handleKeyboardChange = useCallback(
(nextShift: number) => {
setKeyboardInset(isMobile ? nextShift : 0);
setKeyboardInset(isNative || isMobile ? nextShift : 0);
setIsKeyboardVisible(nextShift > 0);
pulseKeyboardRefits();
},
@@ -955,6 +983,11 @@ export function TerminalPane({
[keyboardPaddingStyle],
);
const handleKeyBarLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (width > 0) setKeyBarWidth(width);
}, []);
const handleSwipeRight = useCallback(() => {
if (!swipeGesturesEnabled) return;
emulatorRef.current?.blur();
@@ -1099,10 +1132,14 @@ export function TerminalPane({
</View>
) : null}
{isMobile ? (
{showVirtualKeyBar ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<View style={styles.keyboardRows}>
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
<View
style={styles.keyboardRows}
onLayout={handleKeyBarLayout}
testID={isNative && !isMobile ? "terminal-virtual-keyboard-native-wide" : undefined}
>
{virtualKeyboardRows.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
@@ -1165,6 +1202,9 @@ const styles = StyleSheet.create((theme) => ({
},
keyboardRows: {
gap: theme.spacing[1],
width: "100%",
maxWidth: TERMINAL_KEY_BAR_MAX_WIDTH,
alignSelf: "center",
},
keyboardRow: {
flexDirection: "row",
@@ -1,7 +1,7 @@
import type { ReactElement } from "react";
import { SquareTerminal } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import type { Theme } from "@/styles/theme";
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -17,7 +17,7 @@ function ProviderProfileIcon({
size: number;
color?: string;
}) {
const Icon = getProviderIcon(iconKey);
const Icon = useProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
@@ -19,7 +19,7 @@ export interface ExternalLinkProps {
/**
* Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the
* URL via the platform's external opener. Wrap in a Tooltip when there's a
* one-line hint worth surfacing on hover/tap.
* one-line hint worth surfacing on hover. A native tap opens the URL.
*/
export function ExternalLink({
href,
@@ -53,7 +53,7 @@ export function ExternalLink({
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top" align="end" offset={6}>
<Text style={styles.tooltipText}>{tooltip}</Text>
@@ -105,6 +105,8 @@ export interface MenuSurfaceProps {
pages?: readonly MenuPageDefinition[];
/** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */
sheetTitle?: string;
/** Controls on the right of the sheet's root title. */
sheetTrailing?: ReactNode;
side?: Placement;
align?: Alignment;
offset?: number;
@@ -339,6 +341,7 @@ function MenuSheetSurface({
children,
pages = [],
sheetTitle,
sheetTrailing,
testID,
keyboardFocusScope,
}: MenuSurfaceProps): ReactElement | null {
@@ -428,7 +431,9 @@ function MenuSheetSurface({
</>
) : (
<>
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
{sheetTitle ? (
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
) : null}
<MenuPage depth={0}>{children}</MenuPage>
</>
)}
@@ -440,9 +445,11 @@ function MenuSheetSurface({
function MenuSheetHeader({
title,
onBack,
trailing = null,
}: {
title: string;
onBack: (() => void) | null;
trailing?: ReactNode;
}): ReactElement {
const { t } = useTranslation();
return (
@@ -462,6 +469,7 @@ function MenuSheetHeader({
<Text style={styles.sheetTitle} numberOfLines={1}>
{title}
</Text>
{trailing ? <View style={styles.sheetTrailing}>{trailing}</View> : null}
</View>
);
}
@@ -475,6 +483,11 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[3],
},
sheetTrailing: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
},
sheetBackButton: {
width: 24,
height: 24,
@@ -8,17 +8,20 @@ interface StatusBadgeProps {
label: string;
variant?: StatusBadgeVariant;
leading?: ReactNode;
/** `xs` fits beside a line of `sm` text: the same label on tighter padding. */
size?: "sm" | "xs";
}
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
export function StatusBadge({ label, variant = "muted", leading, size = "sm" }: StatusBadgeProps) {
const pillStyle = useMemo(
() => [
styles.pill,
size === "xs" && styles.pillXs,
variant === "success" && styles.pillSuccess,
variant === "warning" && styles.pillWarning,
variant === "error" && styles.pillError,
],
[variant],
[size, variant],
);
const textStyle = useMemo(
() => [
@@ -50,6 +53,10 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: 3,
},
pillXs: {
paddingHorizontal: theme.spacing[1.5],
paddingVertical: 1,
},
pillSuccess: {
backgroundColor: theme.colors.statusSuccessTint,
borderColor: "transparent",
+6 -5
View File
@@ -28,7 +28,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right";
@@ -248,7 +248,8 @@ export function Tooltip({
});
const isCompact = useIsCompactFormFactor();
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const opensOnPress = isNative || isCompact;
const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
@@ -256,10 +257,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isCompact,
openOnPress: opensOnPress,
delayDuration,
}),
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
[isOpen, setIsOpen, enabled, opensOnPress, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -542,7 +543,7 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -1357,6 +1357,10 @@ function DesktopFeatureItem({
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
const iconOnly = feature.desktopTrigger === "icon";
const tooltip = iconOnly
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
: getFeatureTooltip(feature);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -1367,15 +1371,16 @@ function DesktopFeatureItem({
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
accessibilityLabel={tooltip}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
@@ -2,12 +2,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Bot } from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { getProviderIcon } from "@/components/provider-icons";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
@@ -130,9 +129,8 @@ export function CompactModelSheet({
profiles,
serverId,
});
const ProviderIcon =
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null;
const ModelIcon = ProviderIcon ?? Bot;
const ModelIcon = useProviderIcon(selectedProvider, serverId);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const rootHeader = useMemo(
() => ({
...rootBrowser.header,
@@ -277,9 +275,9 @@ export function CompactModelSheet({
testID="combined-model-selector"
chevron={null}
>
{ProviderIcon ? (
{hasSelectedProvider ? (
<ComposerToolbarGlyph size={glyphSize}>
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<View style={styles.triggerLabels}>

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