Compare commits

..
Author SHA1 Message Date
Mohamed Boudra fa6df8a959 fix(app): keep the composer's remembered provider in its picker
The picker leaves out providers that aren't installed, except the one
holding the picker's current choice. The schedule form and metadata page
passed that choice; the new-agent composer didn't, so a remembered
provider not installed on this host vanished from the picker and the
trigger read "Select model" with no reason given. The builder now requires
every caller to say what its current choice is, or null.
2026-11-14 14:41:27 +01:00
Mohamed Boudra ec179aa01e fix(app): don't leave a re-added provider's sheet stuck on Removing...
The setup step and the model picker's gear keep a provider's sheet
mounted after it closes, keyed by provider. A finished removal stayed
set in that sheet, so adding the same provider back reopened it with
Remove spinning and disabled. A finished removal now ends when the host
lists the provider again.
2026-11-14 14:24:04 +01:00
Mohamed Boudra 0563654cb0 fix(app): show progress on the provider page's in-flight buttons
Remove, Add model and the diagnostic re-run now show their work on the
pressed control, like the page's Refresh and Update: a spinner, no
further presses and, on a labelled button, "Removing..." or "Adding...".

Remove kept its label and only disabled itself, and the page went blank
mid-removal: the daemon publishes the provider list without the removed
provider before it answers the config patch, so the body had nothing to
show and each frame's title fell back to the raw id. The removal now
keeps the entry the user confirmed, and one hook feeds the frame's title
and the body, so both keep showing the provider until the page closes.
The in-flight state also starts after confirmation, not while the
dialog is open.

The diagnostic sheet's header icon buttons had lost the base branch's
spinner and dimming; SheetHeaderIconButton now takes loading and
disabled, the way Button does.
2026-11-14 14:12:14 +01:00
Mohamed Boudra 6ead9a9e0b fix(app): show the not-found notice when a provider is removed elsewhere
The page hid the notice for any provider it had shown before, to cover its own Remove. A removal from another client, the CLI or a plugin then left the page blank with no way to tell why. The page now owns its Remove: while that runs and once it succeeds the body shows nothing, and any other absence says the provider isn't on this host.

The removal e2e routes only the e2e daemon's socket, so the fixture's port-6767 guard stays in force.
2026-11-14 13:14:12 +01:00
Mohamed Boudra 4d740fd4dd fix(app): don't call a just-removed provider missing while its page closes
The daemon can publish the provider list without the removed provider before it answers the config patch, so the page said "This provider isn't on this host" until the answer arrived and it navigated away; in a sheet, through the exit animation as well. A page that has shown its provider now shows nothing once it leaves the list. The not-found notice is for a page that never found its provider. The removal e2e holds the config answer so it always sees that order.
2026-11-14 13:03:21 +01:00
Mohamed Boudra ddc45fe1c1 fix(app): show a provider page's loading and not-found states
A provider page opened from its URL (a reload, a cold start, a shared link) rendered its title over nothing until the host's providers arrived, and forever when the id named a provider the host doesn't have. The page body now says Loading..., that the host is offline, that the providers couldn't be read, or that the provider isn't on this host, in the same notice card the provider list uses when it is offline. The page and both sheet frames share the body.
2026-11-14 12:51:41 +01:00
Mohamed Boudra 4ed83a0ef0 refactor(app): name the provider picker's connection and saved-choice options
Callers passed a bare true to reach the saved-choice argument.
2026-11-14 12:11:38 +01:00
Mohamed Boudra 969b2938e9 test(app): wait for an added catalog provider's probe before continuing
Adding a provider opens its page only after the daemon probes it, which can outlast the default expect timeout on CI.
2026-11-14 12:04:12 +01:00
Mohamed Boudra b759977818 fix(app): keep a saved provider choice visible when it is not installed
The provider picker drops unavailable providers. A schedule or metadata-generation setting saved against such a provider then showed Select model. The picker now keeps the saved provider, labelled Unavailable, so the user sees what is set and can change it.
2026-11-14 12:04:12 +01:00
Mohamed Boudra 35acb5a914 fix(app): say Not installed for a disabled provider that is not installed
A disabled provider's snapshot status is unavailable, so the row said Disabled even when the binary was missing. The installation inspection is checked first, so Not installed wins over Disabled.
2026-11-14 12:04:11 +01:00
Mohamed Boudra 0869f6df33 fix(app): don't open a provider's page after its Add sheet was closed
Adding a provider waits for the daemon to probe it. If the user closed the sheet during that wait, the completed add still navigated to the provider's page. The add now remembers which sheet session started it and only opens the page if that session is still current.
2026-11-14 12:04:11 +01:00
Mohamed Boudra 18b19f1e78 fix(app): let screen readers read a settings row's state line
The row's button carries the row's name as its label, so only the title is hidden from assistive tech now; the hint below it (a provider's version, error or Not installed) is read again.

The Enter-on-switch test now dispatches its key events synchronously inside one act. An awaited keyboard call let the Switch's animation run a frame after the test, which Vitest reported as an unhandled error and failed the run.
2026-11-14 12:04:11 +01:00
Mohamed Boudra 37c99c12b1 test(app): find the provider page's buttons in the compact sheet
On compact layouts AdaptiveModalSheet puts its testID on the header only,
so buttons in the page body cannot be found inside it. The spec looks them
up on the page, as it did before the page moved into the sheet.
2026-11-14 11:43:25 +01:00
Mohamed Boudra bf3d6b5fe0 test(app): give the Unistyles stub the light theme's full colours
Since the stub started applying withUnistyles theme mappings, a mapping
reading a colour the stub's theme lacked crashed: the markdown styles read
colors.terminal and failed the plan card disclosure tests. The stub's
colours now start from the light theme, with its own values pinned on top.
2026-11-14 11:43:25 +01:00
Mohamed Boudra 4c072573fe fix(app): reserve a control's height only on rows that open something
The previous change gave every card row a control's minimum height and
the design doc said so, but rows built directly on settingsStyles.row
(66 uses) never got it: a single-line row was 48px or 64px depending on
which code path built it.

The minimum now belongs to SettingsRowHeader, the row with an optional
control slot, so a list where only some rows carry a switch keeps one
height. SettingsRow and hand-built rows take their content's height again,
as on the base branch, and the doc states the narrower rule.
2026-11-14 11:43:25 +01:00
Mohamed Boudra ee4c26f48e fix(app): keep a settings row's switch out of the row's button
The provider row's switch rendered inside the row's <button>. That is
invalid markup, and keys reached both: Enter on the switch toggled it and
also opened the provider's page, and Space on it opened the page without
toggling.

SettingsRowHeader now lays its press target under the row's content as a
sibling of the control. The content lets presses through to the button and
stays out of the accessibility tree, where the button's label already names
the row. Hover is tracked on the row, so it holds over the switch.
2026-11-14 11:43:24 +01:00
Mohamed Boudra 19d791777e fix(app): title a provider's Settings page like a project's
The page title and the detail header both named the provider. The screen
now titles it "Providers" and the page names the provider once, the way a
project's page does.
2026-11-14 10:56:38 +01:00
Mohamed Boudra b2a83d335f fix(app): keep a provider row's error to one line
A long or multi-line error wrapped the hint to two lines with the dot
centred between them. The row cuts it to one line; the provider's page
shows it in full.
2026-11-14 10:56:38 +01:00
Mohamed Boudra c523b3ad9b fix(app): keep settings rows one height and on one icon rail on phones
On touch layouts a switch reserves a 44px control, so in the provider list
rows with a switch stood 10px taller than "Not installed" rows without one.
Card rows now reserve a default control's height, the rule nested rows
already follow.

A compact SettingsRow wrapped with a full `gap`, which added 12px on top of
the leading slot's margin: catalog icons sat 24px from their labels instead
of 12px. The wrap only needs space between lines, so it uses `rowGap`.
2026-11-14 10:56:37 +01:00
Mohamed Boudra 4858e62270 docs: describe the provider list and pages as they are now 2026-11-14 10:26:19 +01:00
Mohamed Boudra d6efcc453b feat(app): open setup's provider pages over the setup card
The detail and Add provider sheets take the setup card's frame and a back
arrow, so they read as pages of setup instead of a sheet over it. The step
is renamed "Your providers" to match the list it shows.
2026-11-14 10:24:00 +01:00
Mohamed Boudra 2e90865134 feat(app): show each provider's version, updates and faults in the list
Each row now says how its provider is: version and model count, Update
available, a spinner while an update runs, a red dot with the error, or
Not installed / Disabled. Only a broken provider gets a dot, and a
provider that is not installed has no switch, since the model picker
already leaves it out. The kebab is gone; Remove lives on the detail.

Rows read the same installation and update queries as the detail, so
opening a provider costs no new requests. Hosts without
providerInstallation still get a correct list from the snapshot alone.

Add provider is a "+ Provider" button in the header and a muted last row;
both open one sheet with search and catalog rows, replacing the tile grid.
After Add, the new provider's detail opens.

The row is the settings kit's detail row, which gains a leading icon, a
hint status (spinner, error dot, or a held line) and a control before the
chevron. The Unistyles test stub now applies withUnistyles theme mappings,
so browser tests render components that read colours through the theme.
2026-11-14 10:17:29 +01:00
Mohamed Boudra 603c52b294 feat(app): open a provider's detail as a Settings page or a sheet
Settings → Providers now pushes a provider page, like Projects and Plugins;
the model picker gear and setup keep a sheet. Both frame one body, ordered
error, Enabled, Installation, Models, Troubleshooting, Danger zone, so the
old sheet's sticky footer and Retry button are gone. Refresh lives once, in
the Troubleshooting status row.

The page header (back link, icon, title) moves into the shared settings kit
so projects, plugins and providers line up on the same rails.
2026-11-14 10:17:29 +01:00
Mohamed Boudra 737792ad7c fix(app): leave providers that are not installed out of the model picker
An agent's own provider stays in its selector, labelled Unavailable.
2026-11-14 10:17:29 +01:00
Mohamed Boudra 3edd502aac test(server): name the provider PATH test for what it shows
The availability check that gates a Copilot spawn still looks on the daemon's
PATH, so the copy on the provider's PATH is not always the one agents launch.
2026-11-14 10:16:17 +01:00
Mohamed Boudra bd3d86749c fix(server): show and update the provider CLI its agents launch
The installation contract found the CLI on the daemon's PATH, while agents
spawn the bare command with the provider's environment. A provider whose env
sets PATH could launch one copy and show and update another, so an update
succeeded while new agents kept the old version. The CLI is now resolved with
the provider's environment.

The section test's check-state table now always names the text it expects, so
no case can skip its assertion.
2026-11-14 10:04:21 +01:00
Mohamed Boudra b152308340 fix(app): keep a running update's progress visible whatever the inspection reads
Update carries the update's progress, but its row rendered only while the inspection read
"installed". An inspection that ran during the update (on reopen or Refresh) could read loading,
not installed or failed while the binary was replaced, and the sheet then showed nothing about the
running update. The Updates row now stays while an update runs.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 2bd55c5ff7 fix(app): show a provider update's progress on its Update button
The pressed button carries an action's progress everywhere else in Settings: Button's own
loading state with a present-participle label, as Browser data's Clear and Project settings'
Save do. Update now does the same, reading "Updating..." from the tap until the update settles,
and the update's row holds only the output while it runs, then the result. This replaces the
row-owned spinner from the previous commit, which no other surface uses.

The installation's in-flight labels use three dots like their siblings.
2026-11-14 09:39:41 +01:00
Mohamed Boudra f7a442f4d4 fix(app): show one progress indicator per provider update and refresh
While an update ran, the Update button and the update's row each showed a spinner. The button's
spinner also widened it, and the row's leading spinner shifted the title when the update settled.
The update's row now owns progress from the tap until the update settles: it shows the update
starting before the host answers, so the indicator never moves, and the Update button only
disables.

Refresh blanked the Installation rows while the provider was asked again, then moved "Checking…"
from the Version row to the Updates row. The rows now keep their last answers until the new ones
arrive, and the Refresh button shows the refresh until both the models and the installation are
read again.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 1f79e07feb fix(app): let update output scroll inside the provider sheet on Android
The output box is a ScrollView nested in the sheet's scrolling body. Android hands the drag to the outer ScrollView unless the inner one sets nestedScrollEnabled, so only the last 240px of a long update's output was readable. The tool-call detail views set the same flag.
2026-11-14 09:39:41 +01:00
Mohamed Boudra c7f55fd13f feat(app): offer provider updates whether or not an update check answers
Update and Update instructions lived inside the row that appeared only after
checkForUpdates answered, so Copilot and OpenCode, which declare update but no
check, never offered either. A check is a convenience: the Updates row now
shows for an installed provider that offers an update, keeps Update or Update
instructions in place in every check state (none, checking, up to date,
available, couldn't check), and shows what the check knows as a hint beside
it. A provider that offers no update still shows the check's answer once it
has one. The daemon already ran update without a check; a test now says so.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 7349b26b56 test(app): keep the custom model check sensitive to a Remove button pushed off the sheet
scrollIntoViewIfNeeded on the Remove button scrolled the overflow-hidden model
card sideways too, so the check passed with the long-id layout fix undone.
Scroll only the Custom models heading into view, which moves the sheet
vertically and leaves the card at scrollLeft 0. With the layout fix undone the
spec now fails at 1200px and 390px again.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 0d9cf2b982 test(app): scroll custom model rows into view before checking them
The provider sheet now shows an Installation section above the model
lists. At 390x1000 the custom model row sits under the sheet footer
until scrolled. The spec checks that a long model id leaves its Remove
button reachable. It had assumed the row needed no vertical scroll.
2026-11-14 09:39:41 +01:00
Mohamed Boudra dc4379ee01 fix(muse): report the build the update check compares
Paseo reuses a cached update check while inspect reports the same
version string. Muse reported 1.4.18 but compared its build
(1.4.18-R8600.1), so a new build of the same release kept the stale
"Update available" for up to an hour. The contract docs now say to
include everything the check compares in the reported version.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 8e0a18f595 fix(providers): share one update check when two clients reopen after a version change 2026-11-14 09:39:41 +01:00
Mohamed Boudra b927be1bc7 test(app): assert provider installation states are visible 2026-11-14 09:39:41 +01:00
Mohamed Boudra e755886c4f fix(providers): keep update checks within their own deadline
Tying a cached check to its version made every check wait for a fresh
inspection, which could add the 20 s inspect deadline to the 25 s check
deadline. A check now never starts an inspection. It waits on one only
when the inspection is cached or already running and the check would
reuse a cached answer. When it asks the provider, it records the version
afterwards.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 857808f7c9 fix(app): refresh provider version after an update settles while disconnected
The installation and update queries were invalidated only by a live
succeeded progress event. A connection dropped mid-update delivered the
settled operation as a snapshot on reconnect, which left the old version
and Update on screen. A success the view saw running now rereads them
whether it arrives as progress or as a snapshot.
2026-11-14 09:39:41 +01:00
Mohamed Boudra ad5e76b133 fix(providers): reuse a cached update check only for the version it answered
The previous fix dropped the cached check after an inspection settled
with a new version. On reopen the app sends inspect and check together,
so the check answered from the stale entry before the inspection
settled. A cached check now records the installed version it was
computed against and is reused only when the current inspection, shared
if in flight, reports the same version.
2026-11-14 09:39:41 +01:00
Mohamed Boudra d7e8099288 docs(providers): say when Update instructions shows 2026-11-14 09:39:41 +01:00
Mohamed Boudra 27bbf5355b fix(providers): keep update checks in step with the installed version
A conclusive check is cached for an hour and was not tied to the version
it answered for. After a CLI updated outside Paseo, settings showed the
new version and still offered Update to it. When an inspection reports a
different version, the cached check is dropped. Versions are compared
for equality, so they stay opaque.

The release comparison dropped prerelease suffixes, so Codex
0.200.0-alpha.1 looked current against 0.200.0. A prerelease now comes
before the release with the same numbers. Two different prereleases of
the same release are reported as unknown.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 3536689ffb fix(app): bound update output and show refresh failures in provider settings
The screen appended every progress chunk with no limit while open, so a
noisy updater grew memory and never showed the truncation note that a
reopened screen shows. Progress now folds with the daemon's 64 KiB limit.

A manual refresh stored its result and failure outside the queries the
screen reads, so a failed refresh brought back the old answer with no
warning. Refresh now runs through those queries. It passes staleTime 0
because the app's default staleTime of Infinity would otherwise answer
from the cache.

The not-connected error uses the translated string the i18n check
requires.
2026-11-14 09:39:41 +01:00
Mohamed Boudra 322c542eaf chore(protocol): mark provider installation as shipping in v0.17.0
main is on the 0.16 beta series, and a promotion ships the tested beta's
content without pulling from main, so this lands in the next minor.
2026-11-14 09:39:40 +01:00
Mohamed Boudra fa8dcb372a docs(providers): document the provider installation contract
Plugin authors get the contract in the provider guide, with a worked
example that reports a version, checks for an update and runs the CLI's
own updater, and every field, deadline and cache in the reference.
The internal provider and plugin docs record why the three callbacks are
separate, that versions are opaque, that Paseo adds no verification to
an update, and how the plugin bridge carries and cancels each request.
2026-11-14 09:39:40 +01:00
Mohamed Boudra 59ab8b8c1f feat(providers): report installation for the remaining providers
Copilot, OpenCode, Pi, Oh My Pi, Antigravity and Hoplite now show an
install link and the installed version in provider settings. Each one
declares only what its CLI offers:

- Update checks ask the source the CLI's own updater reads: Pi's
  latest-version endpoint, `omp update --check`, and Hoplite's
  latest.json. Copilot and OpenCode resolve their release inside the
  binary, so they declare no check.
- Updates run the CLI's own command: `copilot update`, `opencode
  upgrade`, `pi update --self` (leaves Pi packages alone), `omp update`.
  Antigravity and Hoplite document no self-update.

OpenCode's success message asks for a daemon restart, because all
OpenCode agents share one server that keeps running the old version.
2026-11-14 09:39:40 +01:00
Mohamed Boudra 75a1ccdcee feat(providers): run provider updates from provider settings
Providers can declare `update(context)`, and the Installation section shows an Update
button when an update is available. The daemon owns the operation: one per provider,
joined by later requests, kept in memory past client disconnects, streamed to subscribers
through an owned subscription, and aborted only when the daemon stops. The provider's
answer is the outcome; the daemon adds no verification. After success it drops the cached
inspection and update check and refreshes discovery, without restarting agents.

Codex and Claude run their own `update` subcommand and pass the output through. Muse has
no update subcommand, so it does not declare one.

Updater output is capped at 64 KiB per update so a chatty updater cannot grow daemon memory.
Starting an update requires daemon.manage because it runs commands on the host.
2026-11-14 09:39:40 +01:00
Mohamed Boudra 4b67d035ce feat(providers): show available updates in provider settings
Providers can now declare `installation.checkForUpdates`, returning
`current`, `available` with opaque `versions` and `message`, or `unknown`.
The provider decides what counts as newer; Paseo shows its answer as
written and never parses or compares versions.

The check is its own RPC, `provider.installation.check_updates`, so the
installed version never waits on a network lookup. The daemon caches a
conclusive answer for an hour and an inconclusive one for 30s, shares
in-flight calls, and deadlines the call at 25s, below the plugin RPC
timeout. A throw or timeout comes back as `error`, which the app shows as
inconclusive, never as up to date. The sheet's Refresh bypasses the cache.

Codex and Claude Code read the release source their own updaters use:
the Homebrew cask for cask installs, otherwise the GitHub latest release
for Codex and the configured `autoUpdatesChannel` for Claude Code.
`codex doctor --json` reports the same answer but scans local state and
took close to a minute, so it is not used. Muse compares its build with
the launcher's channel manifest, the same rule the launcher updates by.

The install link now shows whenever the provider declares `installUrl`
and is not reported installed: the Install button for `not_installed`, a
quieter "Install instructions" link when the check failed or the
provider only links to a guide.
2026-11-14 09:39:40 +01:00
Mohamed Boudra 5afbd0d7d3 feat(providers): show installed version and install link in provider settings
Providers can now declare `installation`: install and update links plus an
`inspect` callback that reports installed state and an opaque version string.
Built-in clients and plugin providers share one contract; plugins receive the
daemon-resolved launch with `resolved: false` when the executable is missing,
so they can report not installed instead of failing.

The daemon calls `inspect` only on the new
`provider.installation.inspect.request` RPC, never from availability probing,
discovery or session creation, so a slow version probe cannot delay agents.
Results are cached per client for 30s, deadlined at 20s, and links survive a
failed probe. The app gates the provider sheet's Installation row on
`server_info.features.providerInstallation`, so older hosts show nothing new.
2026-11-14 09:39:40 +01:00
187 changed files with 9984 additions and 4996 deletions
+7 -7
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -132,7 +132,7 @@ A section or group explains itself through the `info` prop on `<SettingsSection>
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. A row that opens something (`<SettingsDetailRow>`, a collapsible row's header) is at least as tall as one holding a default control, so a list where only some of those rows carry a switch keeps one row height. Other rows take their content's height. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
@@ -205,11 +205,11 @@ Terminology:
## 11. States
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.
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` leads the line it relates to, never trails a label: a settings row's hint takes `hintStatus="busy"` while its work runs, and `hintStatus="pending"` holds the line with a skeleton until its value is read (`packages/app/src/components/settings/row-header.tsx`). 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.
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 `sm` `<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`).
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/components/ui/form-field.tsx`). A settings row that is broken says so in its hint with `hintStatus="error"`: a red dot and the error in `statusDanger`.
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
@@ -231,13 +231,13 @@ Changing state must not move the layout. A row that grows when its badge arrives
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
A trailing chevron opens a detail, as a page or a sheet (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the pressable; use `<SettingsDetailRow>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx`), provider row (`packages/app/src/provider-settings/internal/provider-row.tsx`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles passes its `<Switch>` as the `control` of `<SettingsDetailRow>`, before the chevron; the switch stops the press from reaching the row (`packages/app/src/provider-settings/internal/provider-row.tsx`). A list the user adds to ends with a muted `<SettingsAddRow>`. Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
@@ -286,7 +286,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/provider-settings/internal/provider-list.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
+1 -1
View File
@@ -49,7 +49,7 @@ than downloading a published desktop release.
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
+3 -9
View File
@@ -65,10 +65,6 @@ Two escape hatches in the codebase:
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
modal's first frames at the screen size minus the status and navigation bars, then grows it to
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
down. The wrapper sizes the content to the window from the first frame.
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
@@ -426,11 +422,9 @@ gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so wrap its content in
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
+12
View File
@@ -510,6 +510,18 @@ before calling status; it exposes your diagnostic through the normal provider di
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
Implement optional `installation` to fill the **Installation** section of provider settings. The
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
it, so a slow check reports that deadline. `update` has no request timeout and streams output
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
sends `provider.installation.cancel`, which aborts the callback's `signal`.
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
author contract is in the
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
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,
+44
View File
@@ -263,6 +263,50 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Installation and updates
The provider list and the **Installation** section of a provider's page read `AgentClient.installation`. Built-ins set
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
owns deadlines, caching and the one running update per provider, so a provider only answers.
Declare only what the vendor offers:
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
and never parses or compares them.
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
build a release lookup of your own.
- `update` runs the CLI's own update command and passes its output through, using
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
exactly what the same command does in a terminal.
The three calls are separate because they cost differently. `inspect` runs for every provider
whenever the provider list shows, because each row states its version; a provider's page shares the
row's request. It has a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
holds for an hour while `inspect` reports the same version string. Report every part of the version
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
stops.
The update check only informs. Once `inspect` reports the CLI installed, the app offers **Update**
for a provider that declares `update`, or **Update instructions** for one that declares only
`updateUrl`, whatever the check answers and whether the provider has one. The check's answer shows
beside the action when it has one. Copilot and OpenCode have no check, so they show **Update** alone.
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
so its message asks the user to restart the daemon.
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
that profile launches. Clients for `extends: "acp"` providers declare no installation.
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
updater during development replaces the CLI on your machine.
## Usage sources
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
@@ -15,7 +15,8 @@ import {
expectProviderInstalledInSettings,
findAcpCatalogProvider,
installAcpCatalogProvider,
openAddProviderArea,
openAddProvider,
openProviderDetail,
openSettingsHost,
openSettingsHostSection,
seedSavedSettingsHosts,
@@ -47,7 +48,7 @@ test.describe("ACP provider catalog", () => {
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await openAddProvider(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
@@ -69,7 +70,7 @@ test("adds fx from the catalog with only a config entry", async ({ page }, testI
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
await openAddProvider(page);
await findAcpCatalogProvider(page, "fx");
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
@@ -104,7 +105,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
await openAddProviderArea(page);
await openAddProvider(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
@@ -127,7 +128,7 @@ for (const theme of ["light", "dark"] as const) {
testInfo.outputPath(`hoplite-removed-${theme}.json`),
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
);
await openAddProviderArea(page);
await openAddProvider(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
@@ -140,11 +141,11 @@ for (const theme of ["light", "dark"] as const) {
}
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
await page.getByTestId("provider-actions-hoplite").click();
await openProviderDetail(page, "Hoplite");
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByTestId("provider-remove-hoplite").click();
await page.getByTestId("provider-remove").click();
}
test("unsupported host asks for an update without writing Hoplite config", async ({
@@ -164,7 +165,7 @@ test("unsupported host asks for an update without writing Hoplite config", async
await openSettings(page);
await openSettingsHost(page, outdatedDaemon.serverId);
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
await openAddProviderArea(page);
await openAddProvider(page);
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
const errorToast = page.getByTestId("app-toast-message");
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
+54 -4
View File
@@ -6,6 +6,7 @@ import {
addProjectFromSetup,
continueSetup,
expectSetupStep,
expectSheetCoversSetupCard,
finishSetup,
goToSetupStep,
hostGivesAgentsPaseoTools,
@@ -23,6 +24,7 @@ import {
recordFirstLaunch,
setupModal,
setupPrimaryButton,
setupSheet,
showSetupAgainFromSettings,
skipSetup,
startSetupHost,
@@ -35,7 +37,11 @@ import {
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
installAcpCatalogProvider,
openAddProvider,
openSettingsHostSection,
} from "../support/helpers/settings";
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
@@ -59,11 +65,28 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
await test.step("Providers shows the same provider list as Settings", async () => {
await continueSetup(page);
await expectSetupStep(page, "Your agents");
await expectSetupStep(page, "Your providers");
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
});
await test.step("a provider's detail and Add provider cover the setup card and lead back to it", async () => {
await setupModal(page)
.getByRole("button", { name: "Claude provider details", exact: true })
.click();
const detail = setupSheet(page, "provider-settings-sheet");
await expectSheetCoversSetupCard(page, detail);
await detail.getByRole("button", { name: "Back to providers", exact: true }).click();
await expect(detail).toHaveCount(0);
await setupModal(page).getByTestId("provider-list-add").click();
const add = setupSheet(page, "add-provider-sheet");
await expectSheetCoversSetupCard(page, add);
await page.keyboard.press("Escape");
await expect(add).toHaveCount(0);
await expectSetupStep(page, "Your providers");
});
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
await continueSetup(page);
await expectSetupStep(page, "Add your projects");
@@ -103,6 +126,33 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
}
});
test("a provider removed in setup can be added back and removed again", async ({ page }) => {
test.setTimeout(180_000);
const host = await startSetupHost("re-add");
const detail = setupSheet(page, "provider-settings-sheet");
const remove = detail.getByTestId("provider-remove");
page.on("dialog", (dialog) => void dialog.accept());
try {
await openAppAsNewUser(page, host);
await continueSetup(page);
await expectSetupStep(page, "Your providers");
await openAddProvider(page);
await installAcpCatalogProvider(page, "Junie");
await remove.click();
await expect(detail).toHaveCount(0);
// Adding it back opens the same provider's sheet, which must not still be removing it.
await openAddProvider(page);
await installAcpCatalogProvider(page, "Junie");
await expect(remove).toHaveText("Remove");
await expect(remove).toBeEnabled();
} finally {
await host.close();
}
});
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
const host = await startSetupHost("first-launch");
@@ -145,7 +195,7 @@ test("changing the theme keeps the setup window in place on every step", async (
for (const title of [
"Welcome to Paseo",
"Your agents",
"Your providers",
"Add your projects",
"Let agents use Paseo",
"Take your agents with you",
@@ -418,7 +468,7 @@ test("in the compact layout, Finish on Orchestration applies its switches", asyn
await openAppAsNewUser(page, host);
await step("Welcome to Paseo");
await primary("Continue").click();
await step("Your agents");
await step("Your providers");
await primary("Continue").click();
await step("Add your projects");
await primary("Continue").click();
@@ -1,21 +1,22 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import type { TestInfo } from "@playwright/test";
import type { Locator, TestInfo } from "@playwright/test";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import {
findAcpCatalogProvider,
openAddProviderArea,
openAddProvider,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
async function openProviderCatalog(page: Page) {
async function openProviderCatalog(page: Page, viewport?: { width: number; height: number }) {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
if (viewport) await page.setViewportSize(viewport);
await openAddProvider(page);
}
async function expectProviderArtwork(
@@ -25,11 +26,11 @@ async function expectProviderArtwork(
name: string,
) {
await findAcpCatalogProvider(page, name);
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
// Catalog rows have no semantic role of their own; scope artwork to the named provider's row.
const icon = page.getByTestId(`provider-catalog-row-${id}`).locator("svg").first();
await expect(icon).toBeVisible();
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
await expect.soft(icon).toHaveAttribute("width", "24");
// Artwork draws at the provider list's icon size, so the catalog and the list share a rail.
await expect.soft(icon).toHaveAttribute("width", "16");
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
@@ -49,3 +50,20 @@ test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
});
/** The space between a row's icon and its label, as drawn. */
async function gapAfterIcon(row: Locator, label: string): Promise<number> {
const icon = await row.locator("svg").first().boundingBox();
const text = await row.getByText(label, { exact: true }).boundingBox();
if (!icon || !text) throw new Error(`${label} has no icon or label on screen`);
return text.x - (icon.x + icon.width);
}
test("catalog rows keep the provider list's icon rail on a phone", async ({ page }) => {
await openProviderCatalog(page, { width: 390, height: 844 });
await findAcpCatalogProvider(page, "Kiro CLI");
const listGap = await gapAfterIcon(page.getByTestId("provider-row-claude"), "Claude");
const catalogGap = await gapAfterIcon(page.getByTestId("provider-catalog-row-kiro"), "Kiro CLI");
expect(catalogGap).toBe(listGap);
});
@@ -0,0 +1,54 @@
import { expect, test } from "../support/fixtures";
import { openCustomModels, removeCustomModel } from "../support/helpers/custom-models";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
// A pressed control shows its own progress while the daemon answers, the way Button's `loading`
// does: a spinner in place of its icon, no further presses and, on a labelled button, a
// present-participle label.
const MODEL_ID = "in-flight-model";
test.describe("provider page work in flight", () => {
test("says Adding... on the Add button while a custom model is saved", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
await openCustomModels(page);
await page.getByRole("button", { name: "Add model", exact: true }).click();
const sheet = page.getByTestId("add-custom-model-sheet");
await page.getByPlaceholder("e.g. openai/gpt-5").fill(MODEL_ID);
gate.holdNextServerMessage("set_daemon_config_response");
await sheet.getByRole("button", { name: "Add", exact: true }).click();
await gate.waitForHeldServerMessage("set_daemon_config_response");
const adding = sheet.getByRole("button", { name: "Adding...", exact: true });
await expect(adding.getByRole("progressbar")).toBeVisible();
await expect(adding).toBeDisabled();
gate.releaseHeldServerMessage("set_daemon_config_response");
await expect(sheet).toBeHidden({ timeout: 30_000 });
await removeCustomModel(page, MODEL_ID);
});
test("shows the diagnostic report running again on its re-run button", async ({ page }) => {
const gate = await installDaemonWebSocketGate(page);
await openCustomModels(page);
await page.getByRole("button", { name: "Diagnostic report", exact: true }).click();
const sheet = page.getByTestId("provider-diagnostic-sheet");
const rerun = sheet.getByRole("button", { name: "Refresh diagnostic", exact: true });
await expect(rerun).toBeEnabled({ timeout: 30_000 });
// Copy has a report to copy once the first run is in.
await expect(sheet.getByRole("button", { name: "Copy diagnostic" })).toBeEnabled();
gate.holdNextServerMessage("provider_diagnostic_response");
await rerun.click();
await gate.waitForHeldServerMessage("provider_diagnostic_response");
const running = sheet.getByRole("button", { name: "Refreshing diagnostic", exact: true });
await expect(running.getByRole("progressbar")).toBeVisible();
await expect(running).toBeDisabled();
gate.releaseHeldServerMessage("provider_diagnostic_response");
await expect(rerun).toBeEnabled({ timeout: 30_000 });
});
});
@@ -2,11 +2,15 @@ import type { Dialog } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
expectProviderNotOnHost,
installAcpCatalogProvider,
openAddProviderArea,
leaveProviderDetail,
openAddProvider,
openProviderDetail,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
@@ -62,13 +66,39 @@ async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog>
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
void dialog.accept();
});
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
await page.getByTestId("provider-remove").click();
if (!warning) {
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
}
return warning;
}
const MISSING_NOTICE = '[data-testid="provider-detail-missing"]';
/** Records whether the page ever says its provider isn't on the host, however briefly. */
async function watchForMissingNotice(page: Page): Promise<void> {
await page.evaluate((selector) => {
const flags = window as unknown as { sawMissingNotice?: boolean };
flags.sawMissingNotice = false;
new MutationObserver(() => {
if (document.querySelector(selector)) flags.sawMissingNotice = true;
}).observe(document.body, { childList: true, subtree: true });
}, MISSING_NOTICE);
}
async function expectMissingNoticeNeverShown(page: Page): Promise<void> {
const saw = await page.evaluate(
() => (window as unknown as { sawMissingNotice?: boolean }).sawMissingNotice,
);
expect(saw).toBe(false);
}
/**
* The daemon publishes the provider list without the removed provider before it answers the config
* patch. Holding the answer keeps the page in that window.
*/
const CONFIG_RESPONSE = "set_daemon_config_response";
test.describe("provider removal", () => {
test("removes a custom provider from Settings", async ({ page }) => {
test.setTimeout(120_000);
@@ -79,20 +109,39 @@ test.describe("provider removal", () => {
try {
await removeCustomProvider(client);
const gate = await installDaemonWebSocketGate(page);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
await openAddProviderArea(page);
// A built-in provider has no Danger zone.
await openProviderDetail(page, "Claude");
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
await leaveProviderDetail(page);
await openAddProvider(page);
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
await expectProviderSource(client, "custom");
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
await openProviderDetail(page, CUSTOM_PROVIDER.name);
await expect(page.getByTestId("provider-remove")).toBeVisible();
await watchForMissingNotice(page);
gate.holdNextServerMessage(CONFIG_RESPONSE);
await clickRemoveProviderAndAcceptWarning(page);
await gate.waitForHeldServerMessage(CONFIG_RESPONSE);
await expectProviderSource(client, undefined);
// Until the removal is answered, the page keeps the provider and the pressed button says so.
const removeButton = page.getByTestId("provider-remove");
await expect(removeButton).toHaveText("Removing...");
await expect(removeButton.getByRole("progressbar")).toBeVisible();
await expect(removeButton).toBeDisabled();
await expect(page.getByTestId("provider-detail-title")).toHaveText(CUSTOM_PROVIDER.name);
gate.releaseHeldServerMessage(CONFIG_RESPONSE);
// Removing leaves the provider's page for the list, without first calling it missing.
await expect(page.getByTestId("provider-list")).toBeVisible();
await expectMissingNoticeNeverShown(page);
await expect(
page.getByRole("button", {
@@ -100,7 +149,33 @@ test.describe("provider removal", () => {
exact: true,
}),
).toHaveCount(0);
await expectProviderSource(client, undefined);
} finally {
await removeCustomProvider(client).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
test("says a provider removed elsewhere while its page is open isn't on this host", async ({
page,
}) => {
test.setTimeout(120_000);
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
clientIdPrefix: "provider-removal-e2e",
});
try {
await removeCustomProvider(client);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProvider(page);
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
await openProviderDetail(page, CUSTOM_PROVIDER.name);
await removeCustomProvider(client);
await expectProviderNotOnHost(page);
} finally {
await removeCustomProvider(client).catch(() => undefined);
await client.close().catch(() => undefined);
@@ -130,7 +205,9 @@ test.describe("provider removal", () => {
exact: true,
});
await expect(pluginProviderRow).toBeVisible();
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
await openProviderDetail(page, PLUGIN_PROVIDER.name);
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
await leaveProviderDetail(page);
// The provider survives a removeProviders patch pointed at it: the
// plugin, not config.json, owns the definition.
@@ -97,7 +97,7 @@ async function hasFocusWithin(locator: Locator): Promise<boolean> {
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Diagnostic report" })).toBeVisible();
}
async function exerciseProviderSettingsStack(page: Page) {
@@ -109,7 +109,7 @@ async function exerciseProviderSettingsStack(page: Page) {
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await page.getByRole("button", { name: "Diagnostic report" }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
@@ -118,7 +118,7 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
await expect(page.getByText("Checked just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
@@ -15,6 +15,9 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
import { getServerId } from "../support/helpers/server-id";
import {
expectSettingsHeader,
expectProviderNotOnHost,
expectProviderPageLoaded,
gotoProviderPage,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
@@ -262,6 +265,20 @@ test.describe("Settings host page", () => {
});
});
test.describe("provider page from its URL", () => {
test("loads a provider's page on a cold visit", async ({ page }) => {
await gotoAppShell(page);
await gotoProviderPage(page, getServerId(), "claude");
await expectProviderPageLoaded(page, "Claude");
});
test("says a provider the host doesn't have isn't on this host", async ({ page }) => {
await gotoAppShell(page);
await gotoProviderPage(page, getServerId(), "not-a-provider");
await expectProviderNotOnHost(page);
});
});
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
@@ -14,7 +14,6 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archivedToast,
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
@@ -26,7 +25,6 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -121,41 +119,6 @@ test.describe("Workspace archive undo", () => {
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("a long list ends just below its last row, and the toast over it moves no row", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
const fillerProjectIds: string[] = [];
try {
for (let index = 0; index < 10; index += 1) {
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
fillers.push(repo);
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
}
const worktree = await seedWorktree(page, "archive-toast-long-list");
const resting = await scrollSidebarListToEnd(page);
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
await archiveFromRowButton(page, worktree.workspaceId);
await page.mouse.move(0, 0);
const withToast = await scrollSidebarListToEnd(page);
const toastBox = await archivedToast(page).boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
await outlastUndoWindow(page);
await expect(archivedToast(page)).toHaveCount(0);
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
} finally {
for (const projectId of fillerProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
}
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
@@ -21,6 +21,7 @@ export interface OlderTimelinePagesGate {
getRepeatedEntryCount(): number;
getOwnedEntryCountContaining(text: string): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
@@ -370,6 +371,15 @@ export async function holdAgentOlderTimelinePages(
const ownedEntries = new Map<string, string>();
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
const routePattern =
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
@@ -383,6 +393,7 @@ export async function holdAgentOlderTimelinePages(
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
@@ -428,6 +439,14 @@ export async function holdAgentOlderTimelinePages(
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await searchModels(page, id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
@@ -33,8 +33,20 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await searchModels(page, id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
/**
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
*/
async function searchModels(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await page
.getByText("Custom models", { exact: true })
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
}
@@ -86,6 +86,17 @@ export function setupModal(page: Page): Locator {
return page.getByTestId("onboarding-modal").getByRole("dialog");
}
/** A sheet opened from a setup step, which should sit exactly on the setup card. */
export function setupSheet(page: Page, testID: "provider-settings-sheet" | "add-provider-sheet") {
return page.getByTestId(testID).getByRole("dialog");
}
export async function expectSheetCoversSetupCard(page: Page, sheet: Locator): Promise<void> {
await expect(sheet).toBeVisible();
const card = await setupModal(page).boundingBox();
await expect.poll(() => sheet.boundingBox()).toEqual(card);
}
export async function expectSetupStep(page: Page, title: string): Promise<void> {
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
timeout: 60_000,
@@ -106,10 +106,12 @@ export async function verifyProviderSettings({
await test.step("provider settings render the registered icon", async () => {
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expectProviderIcon(
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
iconPaths,
);
// The row's button lies under its content, so the icon is in the row, beside the button.
const opener = page.getByRole("button", {
name: "Direct provider example provider details",
exact: true,
});
await expectProviderIcon(page.getByTestId(/^provider-row-/).filter({ has: opener }), iconPaths);
await capture(page, testInfo, "provider-settings");
await page.getByTestId("settings-back-to-workspace").click();
});
+44 -2
View File
@@ -507,8 +507,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
});
}
export async function openAddProviderArea(page: Page): Promise<void> {
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
export async function openAddProvider(page: Page): Promise<void> {
await page.getByTestId("provider-list-add").click();
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
}
@@ -520,17 +520,59 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
await findAcpCatalogProvider(page, providerName);
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
// The sheet closes once the add is done. Adding probes the new provider, which can outlast an
// assertion's default wait.
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0, {
timeout: 60_000,
});
}
/** Adding opens the new provider's page; its row waits in the list behind it. */
export async function expectProviderInstalledInSettings(
page: Page,
providerName: string,
): Promise<void> {
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
await page.getByTestId("provider-detail-back").click();
await expect(
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
).toBeVisible();
}
export async function openProviderDetail(page: Page, providerName: string): Promise<void> {
await page.getByRole("button", { name: `${providerName} provider details`, exact: true }).click();
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
}
/** Load a provider's Settings page from its URL, as a reload or a shared link does. */
export async function gotoProviderPage(
page: Page,
serverId: string,
providerId: string,
): Promise<void> {
await page.goto(
`/settings/hosts/${encodeURIComponent(serverId)}/providers/${encodeURIComponent(providerId)}`,
);
}
export async function expectProviderPageLoaded(page: Page, providerName: string): Promise<void> {
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName, {
timeout: 30_000,
});
await expect(page.getByText("Provider status")).toBeVisible({ timeout: 30_000 });
}
export async function expectProviderNotOnHost(page: Page): Promise<void> {
await expect(page.getByText("This provider isn't on this host")).toBeVisible({
timeout: 30_000,
});
}
export async function leaveProviderDetail(page: Page): Promise<void> {
await page.getByTestId("provider-detail-back").click();
await expect(page.getByTestId("provider-list")).toBeVisible();
}
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
}
@@ -427,11 +427,7 @@ export async function holdOlderHistoryPages(
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await expect
.poll(() => gate.getRequestCount(), {
message: `older history requests ${count} page(s)`,
})
.toBeGreaterThanOrEqual(count);
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
@@ -162,38 +162,3 @@ export function createGatedTeardown(): GatedTeardown {
release: () => writeFileSync(releasePath, "release\n"),
};
}
export function archivedToast(page: Page) {
return page.getByTestId("workspace-archived-toast");
}
export interface SidebarListLayout {
/** Bottom edge of the list's visible area. */
listBottom: number;
/** Bottom edges of the visible project and workspace rows, top to bottom. */
rowBottoms: number[];
rowHeight: number;
}
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
return list.evaluate((element) => {
const visible = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
),
)
.map((row) => row.getBoundingClientRect())
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
return {
listBottom: visible.bottom,
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
rowHeight: Math.min(...rows.map((row) => row.height)),
};
});
}
@@ -49,21 +49,8 @@ afterEach(() => {
host.remove();
});
type OlderHistoryInput = Pick<
StreamRenderInput,
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
>;
interface MountTimelineOptions {
olderHistory?: OlderHistoryInput;
isMobileBreakpoint?: boolean;
}
function mountTimeline(
historyMounted: StreamItem[],
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
function mountTimeline(historyMounted: StreamItem[]) {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const input: StreamRenderInput = {
agentId: "agent",
@@ -89,7 +76,6 @@ function mountTimeline(
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
...olderHistory,
};
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
@@ -114,20 +100,8 @@ function mountTimeline(
scrollTo: (top: number) => {
scrollContainer.scrollTop = top;
},
// A wheel listener runs before the browser applies the scroll the wheel causes.
wheelUpTo: (top: number) => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
scrollContainer.scrollTop = top;
},
distanceFromEnd: () =>
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
scrollTop: () => scrollContainer.scrollTop,
scrollUp: (distance: number) =>
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
scrollContainer.scrollTop -= distance;
scrollContainer.dispatchEvent(new Event("scroll"));
}),
// Resize observers and the hold's settle run on animation frames.
settle: () =>
act(async () => {
@@ -139,53 +113,6 @@ function mountTimeline(
}
describe("web disclosure hold", () => {
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.scrollUp(600);
await timeline.settle();
const offset = timeline.scrollTop();
const readingTop = timeline.rowTop("message-64");
expect(offset).toBeGreaterThan(0);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.scrollTop()).toBe(offset);
expect(timeline.rowTop("message-64")).toBe(readingTop);
},
);
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
async (options) => {
const history = Array.from({ length: 80 }, (_, index) => row(index));
const timeline = mountTimeline(history, options);
await timeline.settle();
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
timeline.render({
historyVirtualized: history.slice(0, 60),
historyMounted: history.slice(60),
liveHead: [row(80)],
});
await timeline.settle();
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
},
);
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history);
@@ -281,32 +208,3 @@ describe("web disclosure hold", () => {
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
});
});
describe("web history start", () => {
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
let olderHistoryLoads = 0;
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
const timeline = mountTimeline(history, {
olderHistory: {
hasOlderHistory: true,
olderHistoryProgressKey: "epoch:1",
onNearHistoryStart: async () => {
olderHistoryLoads += 1;
return true;
},
},
});
await timeline.settle();
// Opening a row at the bottom stops following output without any scroll.
timeline.hold("message-5", () =>
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
);
await timeline.settle();
expect(olderHistoryLoads).toBe(0);
timeline.wheelUpTo(0);
await timeline.settle();
expect(olderHistoryLoads).toBe(1);
});
});
+6 -19
View File
@@ -457,8 +457,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
// History can become virtualized after the reader has already scrolled the mounted rows.
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
initialRect: {
width: scrollContainerRef.current?.clientWidth ?? 0,
height: scrollContainerRef.current?.clientHeight ?? 0,
@@ -607,15 +605,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
}, []);
const hasUpwardInputEvidence = useCallback(() => {
const mouseGesture = mouseScrollGestureRef.current;
return (
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current
);
}, []);
useLayoutEffect(() => {
if (isActive) {
return;
@@ -802,16 +791,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
const mouseGesture = mouseScrollGestureRef.current;
const hasUpwardInputEvidence =
mouseGesture?.kind === "scrollbar" ||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
window.performance.now() < upwardInputEvidenceUntilRef.current;
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
setFollowOutput(true);
} else if (followOutputRef.current && userScrolledUp) {
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
stopFollowingOutputFromUserIntent();
} else if (userScrolledUp) {
// Wheel listeners can run before or after the browser applies the scroll, so the
// upward scroll itself re-arms loading when it reaches the start.
rearmHistoryStartFromUserIntent();
}
lastKnownScrollTopRef.current = currentScrollTop;
@@ -822,10 +811,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasUpwardInputEvidence,
isJumpSettling,
readingAnchor,
rearmHistoryStartFromUserIntent,
stopFollowingOutputFromUserIntent,
updateScrollMetrics,
]);
+1
View File
@@ -934,6 +934,7 @@ function RootStack() {
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
<Stack.Screen name="settings/hosts/[serverId]/providers/[providerId]" />
</ThemedStack>
);
}
@@ -0,0 +1,24 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsHostProviderDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
providerId?: string | string[];
}>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const providerId = normalizeProjectSettingsRouteId(params.providerId);
const view = useMemo(
() => ({ kind: "provider" as const, serverId, providerId }),
[providerId, serverId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -39,21 +39,6 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
/**
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
* timer, which would otherwise fire after the test, when jsdom is gone.
*/
async function resolveLookupAndDeliver(
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
result: DirectorySuggestionResult,
): Promise<void> {
await act(async () => {
lookup.resolve(result);
await lookup.promise;
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
@@ -337,12 +322,11 @@ describe("useFileLink", () => {
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
await resolveLookupAndDeliver(
deferred,
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
@@ -1,71 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import type { AttachmentMetadata } from "@/attachments/types";
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
let root: Root;
let container: HTMLDivElement;
let latest: AttachmentPreviewUrl | null = null;
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
latest = useAttachmentPreviewUrl(attachment);
return null;
}
async function render(attachment: AttachmentMetadata | null) {
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
}
async function waitForReady(): Promise<string> {
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
if (latest?.status !== "ready") throw new Error("preview never resolved");
return latest.url;
}
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
latest = null;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("never hands back a URL it already released when the same attachment returns", async () => {
const attachment = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
await render(attachment);
const released = await waitForReady();
await render(null);
await render(attachment);
expect(latest).not.toEqual({ status: "ready", url: released });
expect(await waitForReady()).not.toBe(released);
});
it("retries a failed attachment from loading when it returns", async () => {
const stored = await persistAttachmentFromBlob({
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
mimeType: "image/png",
fileName: "shot.png",
});
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
await render(missing);
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
await render(null);
await render(missing);
expect(latest).toEqual({ status: "loading" });
});
@@ -2,75 +2,62 @@ import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export type AttachmentPreviewUrl =
| { status: "loading" }
| { status: "ready"; url: string }
| { status: "failed" };
const LOADING: AttachmentPreviewUrl = { status: "loading" };
const FAILED: AttachmentPreviewUrl = { status: "failed" };
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
if (!attachment) return null;
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
"\u0000",
);
}
/**
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
* URL, while the next one is still resolving. A missing attachment stays `loading`.
*/
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined,
): AttachmentPreviewUrl {
const [resolved, setResolved] = useState<{
key: string;
preview: AttachmentPreviewUrl;
} | null>(null);
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const key = attachmentKey(attachment);
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
const current = attachmentRef.current;
if (!current || key === null) {
return;
}
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
return;
}
void (async () => {
try {
const url = await resolveAttachmentPreviewUrl(current);
const resolved = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment: current, url });
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
return;
}
currentUrl = url;
setResolved({ key, preview: { status: "ready", url } });
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: current.id,
error,
});
if (!disposed) {
setResolved({ key, preview: FAILED });
setUrl(null);
}
}
})();
return () => {
disposed = true;
if (currentUrl) {
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
const activeAttachment = activeAttachmentRef.current;
if (!currentUrl || !activeAttachment) {
return;
}
// A result lives only as long as its lookup. Kept, the same attachment coming back would
// read a released URL, or an old failure, until the next lookup lands.
setResolved((previous) => (previous?.key === key ? null : previous));
void releaseAttachmentPreviewUrl({
attachment: activeAttachment,
url: currentUrl,
});
};
}, [key]);
}, [id, storageType, storageKey, mimeType]);
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
return url;
}
@@ -23,6 +23,7 @@ import {
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -73,6 +74,7 @@ export interface SheetHeader {
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
const HEADER_ICON_SIZE = 16;
const styles = StyleSheet.create((theme) => ({
nativeDialogSurface: {
@@ -137,6 +139,15 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
},
headerIconButtonInactive: {
opacity: theme.opacity[50],
},
headerIconBox: {
width: HEADER_ICON_SIZE,
height: HEADER_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
// Without a header the close button floats over the card's top-right corner.
floatingClose: {
position: "absolute",
@@ -345,31 +356,55 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
* actions line up with it at one size. Pass it through `SheetHeader.actions`. Like `Button`, a
* loading action shows a spinner in place of its icon and, like a disabled one, is dimmed and
* cannot be pressed.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
onPress,
loading = false,
disabled = false,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
onPress: () => void;
loading?: boolean;
disabled?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const inactive = disabled || loading;
const style = useMemo(
() => [styles.closeButton, inactive && styles.headerIconButtonInactive],
[inactive],
);
const accessibilityState = useMemo(
() => ({ disabled: inactive, busy: loading }),
[inactive, loading],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
accessibilityState={accessibilityState}
disabled={inactive}
style={style}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
{({ pressed }) => {
const color = pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
if (!loading) return <Icon size={HEADER_ICON_SIZE} color={color} />;
// The spinner sits in the icon's box, so the header does not move while it runs.
return (
<View style={styles.headerIconBox}>
<LoadingSpinner size={HEADER_ICON_SIZE} color={color} />
</View>
);
}}
</Pressable>
);
}
@@ -539,6 +574,11 @@ export interface AdaptiveModalSheetProps {
* corner, and the compact sheet closes from its handle.
*/
header?: SheetHeader;
/**
* Leave out the header's close button when `header.back` is the only way out, as for a sheet
* that stands in for a page of the surface beneath it. Escape still closes the sheet.
*/
showCloseButton?: boolean;
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
@@ -569,6 +609,7 @@ export interface AdaptiveModalSheetProps {
*/
function CompactSheetBody({
header,
showCloseButton,
onClose,
testID,
footer,
@@ -579,6 +620,7 @@ function CompactSheetBody({
children,
}: {
header?: SheetHeader;
showCloseButton: boolean;
onClose: () => void;
testID?: string;
footer: ReactNode;
@@ -608,7 +650,14 @@ function CompactSheetBody({
);
return (
<SheetVisibleFrame footer={footerView}>
{header ? <SheetHeaderView header={header} onClose={onClose} testID={testID} /> : null}
{header ? (
<SheetHeaderView
header={header}
onClose={onClose}
showCloseButton={showCloseButton}
testID={testID}
/>
) : null}
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -635,6 +684,7 @@ function CompactSheetBody({
export function AdaptiveModalSheet({
header,
showCloseButton = true,
visible,
onClose,
onDismiss,
@@ -762,6 +812,7 @@ export function AdaptiveModalSheet({
<OverlayLayerProvider layer={modalLayer}>
<CompactSheetBody
header={header}
showCloseButton={showCloseButton}
onClose={onClose}
testID={testID}
footer={footerView}
@@ -781,7 +832,9 @@ export function AdaptiveModalSheet({
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
{header ? <SheetHeaderView header={header} onClose={onClose} /> : null}
{header ? (
<SheetHeaderView header={header} onClose={onClose} showCloseButton={showCloseButton} />
) : null}
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
{scrollable ? (
<ScrollView
@@ -1,149 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { i18n } from "@/i18n/i18next";
import { WindowLayoutProvider } from "@/window-layout";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
interface FrameSample {
image: { x: number; y: number; width: number; height: number } | null;
error: boolean;
}
function nextFrame(): Promise<number> {
return new Promise((resolve) => requestAnimationFrame(resolve));
}
function createPng(width: number, height: number): Promise<Blob> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable");
context.fillStyle = "#3a7";
context.fillRect(0, 0, width, height);
return canvas.convertToBlob({ type: "image/png" });
}
function noop() {}
function isPainted(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (Number(getComputedStyle(node).opacity) === 0) return false;
}
return true;
}
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
function sampleFrame(): FrameSample {
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
const rect = image?.getBoundingClientRect();
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
return {
image: visible
? {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
: null,
error: (document.body.textContent ?? "").includes("Couldn't load image"),
};
}
/** Samples every frame from the open until the image has held one box for 20 frames. */
async function recordOpening(open: () => void): Promise<FrameSample[]> {
const samples: FrameSample[] = [];
open();
let stableFrames = 0;
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
await nextFrame();
const sample = sampleFrame();
const previous = samples.at(-1);
stableFrames =
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
? stableFrames + 1
: 0;
samples.push(sample);
}
return samples;
}
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
vi.unstubAllGlobals();
});
function renderLightbox(source: ImageLightboxSource | null) {
root.render(
<SafeAreaProvider>
<WindowLayoutProvider>
<AttachmentLightbox source={source} onClose={noop} />
</WindowLayoutProvider>
</SafeAreaProvider>,
);
}
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
const samples = await recordOpening(() => renderLightbox(source));
const fitted = samples.at(-1)?.image;
expect(fitted, "the image never settled").toBeTruthy();
expect(samples.filter((sample) => sample.error)).toEqual([]);
expect(
samples.filter(
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
),
).toEqual([]);
}
describe("AttachmentLightbox opening in the browser", () => {
it("paints the fitted attachment image as its first image frame", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
});
it("paints only the fitted image when the same attachment opens again", async () => {
const metadata = await persistAttachmentFromBlob({
blob: await createPng(2400, 1600),
mimeType: "image/png",
fileName: "screenshot.png",
});
const source: ImageLightboxSource = { type: "attachment", metadata };
await expectOpeningPaintsOnlyTheFittedImage(source);
await act(async () => renderLightbox(null));
await expectOpeningPaintsOnlyTheFittedImage(source);
});
it("shows an image the browser reports no natural size for", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
it("paints the fitted timeline image as its first image frame", async () => {
const uri = URL.createObjectURL(await createPng(2400, 1600));
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
});
});
@@ -4,7 +4,6 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
@@ -39,9 +38,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
return {
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
>(() => ({ status: "ready", url: "blob:preview" })),
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
};
});
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
@@ -74,13 +73,10 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("react-native-safe-area-context", () => ({
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/window-layout", () => ({
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
}));
vi.mock("react-native-gesture-handler", () => ({
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
@@ -155,7 +151,7 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
});
afterEach(() => {
@@ -233,22 +229,14 @@ describe("AttachmentLightbox", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL fails to resolve", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("shows neither the image nor an error while the preview URL resolves", () => {
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
@@ -4,21 +4,17 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { AttachmentMetadata } from "@/attachments/types";
import {
type AttachmentPreviewUrl,
useAttachmentPreviewUrl,
} from "@/attachments/use-attachment-preview-url";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isNative } from "@/constants/platform";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
import { WindowSafeArea } from "@/window-layout";
export type ImageLightboxSource =
| { type: "attachment"; metadata: AttachmentMetadata }
@@ -35,13 +31,10 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
const { t } = useTranslation();
const metadata = source?.type === "attachment" ? source.metadata : null;
const attachmentPreview = useAttachmentPreviewUrl(metadata);
const image: AttachmentPreviewUrl =
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
const url = image.status === "ready" ? image.url : null;
const attachmentUrl = useAttachmentPreviewUrl(metadata);
const url = source?.type === "uri" ? source.uri : attachmentUrl;
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
const [errored, setErrored] = useState(false);
const failed = errored || image.status === "failed";
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
useEffect(() => {
@@ -83,12 +76,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<ModalRoot style={styles.root}>
{/* The modal draws over the whole window: its insets are the window's, not the ones left
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
<WindowSafeArea>
{/* The modal is its own window: its insets are the window's, not the ones left where the
image was opened (inside a sheet tab, the bottom one is already spent). */}
<SafeAreaProvider>
<WindowChromeRootRegion corners="both">
<View ref={setOverlayScope} style={styles.root}>
<Pressable
@@ -99,10 +94,9 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
style={styles.backdrop}
/>
<LightboxImageArea>
{failed ? (
{hasError ? (
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
) : null}
{!failed && url ? (
) : (
<ZoomableImage
accessibilityLabel={t("composer.attachments.openImage")}
actions={actions}
@@ -114,11 +108,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
testID="attachment-lightbox"
uri={url}
/>
) : null}
)}
</LightboxImageArea>
</View>
</WindowChromeRootRegion>
</WindowSafeArea>
</SafeAreaProvider>
</ModalRoot>
</Modal>
);
@@ -133,10 +133,9 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const preview = useAttachmentPreviewUrl(metadata);
const uri = preview.status === "ready" ? preview.url : null;
const source = useMemo(() => (uri ? { uri } : null), [uri]);
if (!source) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
@@ -11,10 +11,6 @@ export type { DraggableListProps, DraggableRenderItemInfo };
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
// A drop remounts every row between the drag's start and end slots (see the draggable-flatlist
// patch), so state held inside a rendered row resets when a drop moves it. Keep state that must
// survive a reorder in the list's owner, keyed by row.
export function DraggableList<T>({
data,
keyExtractor,
@@ -121,10 +117,6 @@ export function DraggableList<T>({
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
// A list that doesn't scroll shows every row, so it renders them all in its first commit.
// FlatList's default first render is 10 rows; a nested list remounted by a drop would paint
// 10 rows, then grow on a later commit.
initialNumToRender={scrollEnabled ? undefined : data.length}
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
dragGestureHostPresented={gestureHostPresented}
@@ -1,234 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type LayoutChangeEvent } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { PackagePlus } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "@/components/ui/external-link";
import { SearchField } from "@/components/ui/search-field";
import { StatusBadge } from "@/components/ui/status-badge";
import { rankProviderCatalog } from "@/components/provider-catalog-search";
import {
useAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { SPACING, type Theme } from "@/styles/theme";
import { settingsStyles } from "@/styles/settings";
interface ProviderCatalogListProps {
serverId: string;
installingProviderId: string | null;
onInstall: (entry: AcpProviderCatalogItem) => Promise<void> | void;
}
const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24;
const TILE_GAP = SPACING[3];
/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */
const MIN_TILE_WIDTH = 300;
const MAX_COLUMNS = 4;
const ThemedPackagePlus = withUnistyles(PackagePlus);
const ThemedSvgXml = withUnistyles(SvgXml);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
function columnCountForWidth(width: number): number {
const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP));
return Math.min(MAX_COLUMNS, Math.max(1, fits));
}
interface CatalogTileProps {
entry: AcpProviderCatalogItem;
/** `undefined` until the grid is measured, or in a single column; the tile fills the row. */
width: number | undefined;
installing: boolean;
onInstall: (entry: AcpProviderCatalogItem) => void;
}
function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) {
const { t } = useTranslation();
const handleInstall = useCallback(() => {
onInstall(entry);
}, [entry, onInstall]);
const tileStyle = useMemo(
() => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }],
[width],
);
return (
<View style={tileStyle} testID={`provider-catalog-tile-${entry.id}`}>
<View style={styles.iconFrame}>
{entry.iconSvg ? (
<ThemedSvgXml
xml={entry.iconSvg}
width={PROVIDER_REMOTE_ICON_SIZE}
height={PROVIDER_REMOTE_ICON_SIZE}
uniProps={foregroundColorMapping}
/>
) : (
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
)}
</View>
<View style={styles.details}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<View style={styles.metaRow}>
{/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */}
<StatusBadge label={t("providerCatalog.types.acp")} size="xs" />
<ExternalLink
href={entry.installLink}
label={t("providerCatalog.actions.installInstructions")}
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
/>
</View>
</View>
<Button
size="sm"
variant="outline"
disabled={installing}
loading={installing}
onPress={handleInstall}
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
style={styles.addButton}
testID={`install-provider-${entry.id}`}
>
{installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
</Button>
</View>
);
}
export function ProviderCatalogList({
serverId,
installingProviderId,
onInstall,
}: ProviderCatalogListProps) {
const { t } = useTranslation();
const { entries: catalogEntries } = useAcpProviderCatalog();
const { entries: providerEntries } = useProvidersSnapshot(serverId);
const [search, setSearch] = useState("");
const [gridWidth, setGridWidth] = useState<number | null>(null);
const installedIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries],
);
const availableEntries = useMemo(
() =>
rankProviderCatalog(
catalogEntries.filter((entry) => !installedIds.has(entry.id)),
search,
),
[catalogEntries, installedIds, search],
);
const handleGridLayout = useCallback((event: LayoutChangeEvent) => {
setGridWidth(event.nativeEvent.layout.width);
}, []);
const tileWidth = useMemo(() => {
if (gridWidth === null) return undefined;
const columns = columnCountForWidth(gridWidth);
if (columns === 1) return undefined;
return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns);
}, [gridWidth]);
return (
<View>
<View style={styles.searchRail}>
<SearchField
value={search}
onChangeText={setSearch}
placeholder={t("providerCatalog.search")}
clearAccessibilityLabel={t("providerCatalog.clearSearch")}
testID="provider-catalog-search"
clearTestID="provider-catalog-search-clear"
/>
</View>
{availableEntries.length === 0 ? (
<View style={[settingsStyles.card, styles.stateBox]}>
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
<View style={styles.grid} onLayout={handleGridLayout}>
{availableEntries.map((entry) => (
<CatalogTile
key={entry.id}
entry={entry}
width={tileWidth}
installing={installingProviderId === entry.id}
onInstall={onInstall}
/>
))}
</View>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
searchRail: {
flexDirection: "row",
marginBottom: theme.spacing[3],
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: TILE_GAP,
},
tile: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
padding: theme.spacing[3],
},
tileFull: {
width: "100%",
},
details: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
iconFrame: {
flexShrink: 0,
width: 36,
height: 36,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
addButton: {
flexShrink: 0,
},
name: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
metaRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: theme.spacing[2],
},
stateBox: {
minHeight: 96,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
},
stateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -1,869 +0,0 @@
import * as Clipboard from "expo-clipboard";
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
import {
resolveProviderDiscoveredModels,
type ProviderDiscoveredModelsCache,
} from "./provider-diagnostic-models";
interface ProviderDiagnosticSheetProps {
provider: string;
visible: boolean;
onClose: () => void;
serverId: string;
}
function rankModels<T>(items: T[], query: string, fields: (item: T) => string[]): T[] {
if (!query.trim()) return items;
const scored = items
.map((item) => ({ item, score: scoreTextFields(query, fields(item)) }))
.filter(
(entry): entry is { item: T; score: NonNullable<typeof entry.score> } => entry.score !== null,
);
scored.sort((a, b) => compareMatchScores(a.score, b.score));
return scored.map((entry) => entry.item);
}
function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
return (
<View style={sheetStyles.modelRow}>
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
{model.description ? (
<Text style={sheetStyles.descriptionInline} numberOfLines={1}>
{model.description}
</Text>
) : null}
</View>
);
}
function CustomModelRow({
model,
deleting,
onDelete,
}: {
model: ProviderProfileModel;
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
deleting ? sheetStyles.disabled : null,
],
[deleting],
);
return (
<View style={sheetStyles.modelRow}>
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
{model.label}
</Text>
<Text
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
<View style={sheetStyles.modelRowFiller} />
<Pressable
onPress={handleDelete}
disabled={deleting}
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
</View>
);
}
function SectionHeader({ title, count, hint }: { title: string; count?: number; hint?: string }) {
return (
<View style={sheetStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
<View style={sheetStyles.sectionHeaderMeta}>
{count !== undefined ? (
<Text style={settingsStyles.sectionHeaderTitle}>{count}</Text>
) : null}
{count !== undefined && hint ? (
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
) : null}
{hint ? <Text style={settingsStyles.sectionHeaderTitle}>{hint}</Text> : null}
</View>
</View>
);
}
function AddCustomModelSubSheet({
provider,
serverId,
visible,
onClose,
refresh,
}: {
provider: string;
serverId: string;
visible: boolean;
onClose: () => void;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const additionalModels = useMemo(
() => config?.providers?.[provider]?.additionalModels ?? [],
[config?.providers, provider],
);
const trimmed = input.trim();
const canAdd = trimmed.length > 0 && !additionalModels.some((model) => model.id === trimmed);
useEffect(() => {
if (!visible) {
setInput("");
setError(null);
}
}, [visible]);
const handleAdd = useCallback(() => {
if (!canAdd) return;
setError(null);
setSaving(true);
void patchConfig({
providers: {
[provider]: {
additionalModels: [...additionalModels, { id: trimmed, label: trimmed }],
},
},
})
.then(() => refresh([provider]))
.then(() => onClose())
.catch((err) => {
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
})
.finally(() => setSaving(false));
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.providers.models.addCustomTitle") }),
[t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
desktopMaxWidth={420}
snapPoints={ADD_SNAP_POINTS}
testID="add-custom-model-sheet"
>
<View style={sheetStyles.formGroup}>
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
<AdaptiveTextInput
initialValue={input}
resetKey={`add-custom-${visible}`}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder={t("settings.providers.models.modelIdPlaceholder")}
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.formInput, isWeb && { outlineStyle: "none" }]}
/>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
<View style={sheetStyles.formActions}>
<Button variant="secondary" onPress={onClose} disabled={saving}>
{t("common.actions.cancel")}
</Button>
<Button variant="default" onPress={handleAdd} disabled={!canAdd || saving}>
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
function DiagnosticSubSheet({
provider,
serverId,
visible,
onClose,
}: {
provider: string;
serverId: string;
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchDiagnostic = useCallback(async () => {
if (!client) return;
setLoading(true);
try {
const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
);
} finally {
setLoading(false);
}
}, [client, provider, t]);
useEffect(() => {
if (visible) {
void fetchDiagnostic();
} else {
setDiagnostic(null);
}
}, [visible, fetchDiagnostic]);
const refreshButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
loading ? sheetStyles.disabled : null,
],
[loading],
);
const handleRefreshPress = useCallback(() => {
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
}, [diagnostic, t, toast]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<View style={sheetStyles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
);
let body: React.ReactNode;
if (loading && !diagnostic) {
body = (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
);
} else if (diagnostic) {
body = (
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
{diagnostic}
</ScrollableCodeSurface>
);
} else {
body = (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
</SurfaceCard>
);
}
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={DIAGNOSTIC_SNAP_POINTS}
scrollable={false}
testID="provider-diagnostic-sheet"
>
{body}
</AdaptiveModalSheet>
);
}
interface ProviderModalBodyProps {
discoveredCount: number;
additionalCount: number;
providerSnapshotRefreshing: boolean;
providerErrorMessage: string | null;
modelsRefreshing: boolean;
searchActive: boolean;
filteredDiscovered: AgentModelDefinition[];
filteredCustom: ProviderProfileModel[];
deletingModelId: string | null;
onRefresh: () => void;
onDeleteCustom: (modelId: string) => void;
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
}
interface ProviderSheetFooterInput {
fetchedAt: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
onOpenAddSheet: () => void;
onOpenDiagSheet: () => void;
onRefreshModels: () => void;
}
function renderProviderSheetFooter({
fetchedAt,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet,
onOpenDiagSheet,
onRefreshModels,
}: ProviderSheetFooterInput) {
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
return (
<View style={contentStyle}>
{fetchedAt || !isCompact ? (
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
) : null}
<View style={actionsStyle}>
<Button variant="secondary" leftIcon={Plus} onPress={onOpenAddSheet} style={buttonStyle}>
{t("settings.providers.models.addModel")}
</Button>
<Button
variant="secondary"
leftIcon={FileText}
onPress={onOpenDiagSheet}
style={buttonStyle}
>
{t("settings.providers.diagnostic.button")}
</Button>
<Button
variant="default"
leftIcon={modelsRefreshing ? undefined : RotateCw}
onPress={onRefreshModels}
disabled={modelsRefreshing}
style={buttonStyle}
>
{modelsRefreshing
? t("settings.providers.diagnostic.refreshing")
: t("settings.providers.diagnostic.refresh")}
</Button>
</View>
</View>
);
}
function ProviderUpdatedLabel({
fetchedAt,
isCompact,
}: {
fetchedAt: string | null;
isCompact: boolean;
}) {
const { t } = useTranslation();
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
const style = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<Text style={style} numberOfLines={1}>
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
</Text>
);
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const {
discoveredCount,
additionalCount,
providerSnapshotRefreshing,
providerErrorMessage,
modelsRefreshing,
searchActive,
filteredDiscovered,
filteredCustom,
deletingModelId,
onRefresh,
onDeleteCustom,
theme,
} = props;
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View>
);
}
if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
<Button variant="default" onPress={onRefresh} disabled={modelsRefreshing}>
{modelsRefreshing
? t("settings.providers.models.retrying")
: t("settings.providers.models.retry")}
</Button>
</View>
);
}
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
</View>
);
}
if (discoveredCount === 0 && additionalCount === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
</View>
);
}
return (
<>
{filteredDiscovered.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader
title={t("settings.providers.models.discovered")}
count={filteredDiscovered.length}
/>
<View style={settingsStyles.card}>
{filteredDiscovered.map((model) => (
<DiscoveredModelRow key={model.id} model={model} />
))}
</View>
</View>
) : null}
{filteredCustom.length > 0 ? (
<View style={sheetStyles.section}>
<SectionHeader
title={t("settings.providers.models.custom")}
count={filteredCustom.length}
/>
<View style={settingsStyles.card}>
{filteredCustom.map((model) => (
<CustomModelRow
key={model.id}
model={model}
deleting={deletingModelId === model.id}
onDelete={onDeleteCustom}
/>
))}
</View>
</View>
) : null}
</>
);
}
export function ProviderDiagnosticSheet({
provider,
visible,
onClose,
serverId,
}: ProviderDiagnosticSheetProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const [query, setQuery] = useState("");
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [diagSheetOpen, setDiagSheetOpen] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo(
() => snapshotEntries?.find((entry) => entry.provider === provider),
[snapshotEntries, provider],
);
const additionalModels = useMemo(
() => config?.providers?.[provider]?.additionalModels ?? [],
[config?.providers, provider],
);
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error"
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
: null;
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<ProviderDiscoveredModelsCache | null>(null);
const currentModels = providerEntry?.models;
const { models: discoveredModels, cache: nextDiscoveredCache } = resolveProviderDiscoveredModels({
serverId,
provider,
currentModels,
providerSnapshotRefreshing,
previousCache: stableDiscoveredRef.current,
});
stableDiscoveredRef.current = nextDiscoveredCache;
useEffect(() => {
if (!visible) {
setQuery("");
setAddSheetOpen(false);
setDiagSheetOpen(false);
}
}, [visible]);
const q = query.trim();
const filteredDiscovered = useMemo(
() => rankModels(discoveredModels, q, (m) => [m.label, m.id, m.description ?? ""]),
[discoveredModels, q],
);
const filteredCustom = useMemo(
() => rankModels(additionalModels, q, (m) => [m.label, m.id]),
[additionalModels, q],
);
const handleRefreshModels = useCallback(() => {
void refresh([provider]);
}, [provider, refresh]);
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
const handleOpenDiagSheet = useCallback(() => setDiagSheetOpen(true), []);
const handleCloseDiagSheet = useCallback(() => setDiagSheetOpen(false), []);
const handleDeleteCustom = useCallback(
(modelId: string) => {
setDeletingModelId(modelId);
void patchConfig({
providers: {
[provider]: {
additionalModels: additionalModels.filter((model) => model.id !== modelId),
},
},
})
.then(() => refresh([provider]))
.finally(() => {
setDeletingModelId((current) => (current === modelId ? null : current));
});
},
[additionalModels, patchConfig, provider, refresh],
);
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: providerLabel,
search: {
onChange: setQuery,
placeholder: t("settings.providers.models.searchPlaceholder"),
testID: "provider-settings-search",
},
}),
[providerLabel, t],
);
return (
<>
<AdaptiveModalSheet
header={sheetHeader}
visible={visible}
onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAt: providerEntry?.fetchedAt ?? null,
isCompact,
modelsRefreshing,
t,
onOpenAddSheet: handleOpenAddSheet,
onOpenDiagSheet: handleOpenDiagSheet,
onRefreshModels: handleRefreshModels,
})}
snapPoints={MAIN_SNAP_POINTS}
>
<ProviderModalBody
discoveredCount={discoveredModels.length}
additionalCount={additionalModels.length}
providerSnapshotRefreshing={providerSnapshotRefreshing}
providerErrorMessage={providerErrorMessage}
modelsRefreshing={modelsRefreshing}
searchActive={Boolean(q)}
filteredDiscovered={filteredDiscovered}
filteredCustom={filteredCustom}
deletingModelId={deletingModelId}
onRefresh={handleRefreshModels}
onDeleteCustom={handleDeleteCustom}
theme={theme}
/>
</AdaptiveModalSheet>
<AddCustomModelSubSheet
provider={provider}
serverId={serverId}
visible={addSheetOpen}
onClose={handleCloseAddSheet}
refresh={refresh}
/>
<DiagnosticSubSheet
provider={provider}
serverId={serverId}
visible={diagSheetOpen}
onClose={handleCloseDiagSheet}
/>
</>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
monoHint: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
descriptionInline: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
},
formInput: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
iconButton: {
width: 28,
height: 28,
flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},
section: {
marginBottom: theme.spacing[4],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
marginLeft: theme.spacing[1],
},
sectionHeaderMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
modelRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
flexShrink: 0,
},
customModelText: {
flexShrink: 1,
minWidth: 0,
},
modelRowFiller: {
flex: 1,
},
emptyState: {
paddingVertical: theme.spacing[8],
alignItems: "center",
gap: theme.spacing[3],
},
footerContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
compactFooterContent: {
flex: 1,
gap: theme.spacing[2],
},
footerMeta: {
flex: 1,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
compactFooterMeta: {
flex: 0,
},
footerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
compactFooterActions: {
gap: theme.spacing[2],
},
compactFooterButton: {
alignSelf: "stretch",
},
formGroup: {
gap: theme.spacing[3],
},
formLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
formActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));
const MAIN_SNAP_POINTS = ["65%", "92%"];
const ADD_SNAP_POINTS = ["40%"];
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
@@ -0,0 +1,480 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import {
ProviderInstallationSection,
type ProviderInstallationState,
type ProviderUpdateAction,
type ProviderUpdatesState,
} from "./provider-installation-section";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const mounted: { root: Root; container: HTMLDivElement }[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const INSTALL_URL = "https://example.com/install";
const UPDATE_URL = "https://example.com/update";
function mount(
state: ProviderInstallationState,
updates: ProviderUpdatesState = { status: "hidden" },
action?: ProviderUpdateAction,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(<ProviderInstallationSection state={state} updates={updates} action={action} />),
);
mounted.push({ root, container });
const view = within(container);
return {
view,
rerender: (nextUpdates: ProviderUpdatesState, nextAction?: ProviderUpdateAction) =>
act(() =>
root.render(
<ProviderInstallationSection state={state} updates={nextUpdates} action={nextAction} />,
),
),
installButton: () => view.queryByRole("button", { name: "Install" }),
installLink: () => view.queryByRole("link", { name: "Install instructions" }),
};
}
function ready(installation: ProviderInstallationState & { status: "ready" }) {
return installation;
}
describe("install link visibility", () => {
it("offers the Install button when the provider reports it is not installed", () => {
const section = mount(
ready({
status: "ready",
installation: { installUrl: INSTALL_URL, inspection: { status: "not_installed" } },
}),
);
expect(section.installButton()).not.toBeNull();
expect(section.installLink()).toBeNull();
});
it("offers a quiet install link when the installation check failed", async () => {
const section = mount(
ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
inspection: { status: "error", message: "version probe exploded" },
},
}),
);
await expect.element(section.view.getByText("version probe exploded")).toBeVisible();
expect(section.installLink()).not.toBeNull();
expect(section.installButton()).toBeNull();
});
it("offers a quiet install link for a provider that only declares where to install it", () => {
const section = mount(ready({ status: "ready", installation: { installUrl: INSTALL_URL } }));
expect(section.installLink()).not.toBeNull();
expect(section.installButton()).toBeNull();
});
it("offers no install link once the provider reports it is installed", () => {
const section = mount(
ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
inspection: { status: "installed", version: "1" },
},
}),
);
expect(section.installLink()).toBeNull();
expect(section.installButton()).toBeNull();
});
});
describe("update check", () => {
const installed = ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
updateUrl: UPDATE_URL,
inspection: { status: "installed", version: "0.165.0" },
},
});
it("shows the reported versions and message as written, with Update instructions", async () => {
const section = mount(installed, {
status: "ready",
updateCheck: {
status: "available",
versions: ["0.168.0", "nightly (unstable)"],
message: "Restart to finish",
},
});
await expect.element(section.view.getByText("Update available")).toBeVisible();
expect(
section.view.getAllByTestId("provider-updates-version").map((node) => node.textContent),
).toEqual(["0.168.0", "nightly (unstable)"]);
await expect.element(section.view.getByText("Restart to finish")).toBeVisible();
await expect
.element(section.view.getByRole("button", { name: "Update instructions" }))
.toBeVisible();
});
const inconclusive = [
{
name: "unknown",
updates: { status: "ready", updateCheck: { status: "unknown", message: "Offline" } },
},
{
name: "a thrown or timed-out check",
updates: { status: "ready", updateCheck: { status: "error", message: "Offline" } },
},
{ name: "a failed request", updates: { status: "error", message: "Offline" } },
] as const;
it.each(inconclusive)("shows $name as inconclusive, never up to date", async ({ updates }) => {
const section = mount(installed, updates);
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
await expect.element(section.view.getByText("Offline")).toBeVisible();
expect(section.view.queryByText("Up to date")).toBeNull();
await expect
.element(section.view.getByRole("button", { name: "Update instructions" }))
.toBeVisible();
});
it.each(inconclusive)(
"still says $name when the provider offers no update",
async ({ updates }) => {
const section = mount(
ready({
status: "ready",
installation: { inspection: { status: "installed", version: "0.165.0" } },
}),
updates,
);
await expect.element(section.view.getByText("Couldn't check for updates")).toBeVisible();
expect(section.view.queryByRole("button")).toBeNull();
},
);
it("shows up to date only when the provider says so", async () => {
const section = mount(installed, { status: "ready", updateCheck: { status: "current" } });
await expect.element(section.view.getByText("Up to date")).toBeVisible();
});
it("shows the check's answer for a provider that offers no update", async () => {
const section = mount(
ready({
status: "ready",
installation: { inspection: { status: "installed", version: "1.4.18" } },
}),
{ status: "ready", updateCheck: { status: "current" } },
);
await expect.element(section.view.getByText("Up to date")).toBeVisible();
expect(section.view.queryByRole("button")).toBeNull();
});
it.each([
{ name: "cannot check", updates: { status: "ready", updateCheck: null } },
{ name: "is still checking", updates: { status: "loading" } },
] as const)(
"shows no Updates row while a provider that offers no update $name",
({ updates }) => {
const section = mount(
ready({
status: "ready",
installation: {
installUrl: INSTALL_URL,
inspection: { status: "installed", version: "1.0.0" },
},
}),
updates,
);
expect(section.view.queryByTestId("provider-updates")).toBeNull();
expect(section.view.getByTestId("provider-installation-version").textContent).toBe("1.0.0");
},
);
});
describe("update action", () => {
const installed = (canUpdate: boolean) =>
ready({
status: "ready",
installation: {
updateUrl: UPDATE_URL,
inspection: { status: "installed", version: "1.0.0" },
...(canUpdate ? { canUpdate: true } : {}),
},
});
const available: ProviderUpdatesState = {
status: "ready",
updateCheck: { status: "available", versions: ["2.0.0"] },
};
function action(overrides: Partial<ProviderUpdateAction> = {}) {
let starts = 0;
const value: ProviderUpdateAction = {
current: null,
starting: false,
startError: null,
start: () => {
starts += 1;
},
...overrides,
};
return { value, starts: () => starts };
}
const operation = { id: "op", startedAt: "2026-11-13T00:00:00.000Z" };
const finishedAt = "2026-11-13T00:01:00.000Z";
// The check only informs: Update and the update instructions never wait on it.
const checkStates = [
{ name: "an update is available", updates: available, says: "Update available" },
// No check answer: the row shows only its title (asserted whole below).
{
name: "the provider cannot check",
updates: { status: "ready", updateCheck: null },
says: "Updates",
},
{ name: "the check is running", updates: { status: "loading" }, says: "Checking..." },
{
name: "the check could not tell",
updates: { status: "ready", updateCheck: { status: "unknown" } },
says: "Couldn't check for updates",
},
{
name: "the check failed",
updates: { status: "error", message: "Offline" },
says: "Couldn't check for updates",
},
{
name: "the provider is up to date",
updates: { status: "ready", updateCheck: { status: "current" } },
says: "Up to date",
},
] satisfies { name: string; updates: ProviderUpdatesState; says: string }[];
it.each(checkStates)(
"offers Update when $name and the provider runs its own",
async ({ updates, says }) => {
const control = action();
const section = mount(installed(true), updates, control.value);
const button = section.view.getByRole("button", { name: "Update" });
act(() => button.click());
expect(control.starts()).toBe(1);
expect(section.view.queryByRole("button", { name: "Update instructions" })).toBeNull();
await expect.element(section.view.getByText(says, { exact: true })).toBeVisible();
},
);
it.each(checkStates)(
"offers Update instructions when $name and the provider only links its guide",
async ({ updates }) => {
const section = mount(installed(false), updates, action().value);
expect(section.view.queryByRole("button", { name: "Update" })).toBeNull();
await expect
.element(section.view.getByRole("button", { name: "Update instructions" }))
.toBeVisible();
},
);
it("shows no update status beside Update when the provider cannot check", () => {
const section = mount(installed(true), { status: "ready", updateCheck: null }, action().value);
expect(section.view.getByTestId("provider-updates").textContent).toBe("UpdatesUpdate");
});
it("shows a running update with its output so far", () => {
const section = mount(
installed(true),
available,
action({
current: {
operation: { ...operation, status: "running" },
output: [
{ type: "message", text: "Running codex update\n" },
{ type: "stdout", text: "added 1 package\n" },
],
},
}).value,
);
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
"Running codex update\nadded 1 package\n",
);
});
it("shows the provider's success message as written", () => {
const section = mount(
installed(true),
{ status: "ready", updateCheck: { status: "current" } },
action({
current: {
operation: {
...operation,
status: "succeeded",
finishedAt,
message: " Restart agents \n",
},
output: [],
},
}).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
expect(section.view.getByTestId("provider-update-message").textContent).toBe(
" Restart agents \n",
);
expect(section.view.queryByTestId("provider-update-output")).toBeNull();
});
it("shows a failure with its message, its output and the update instructions", async () => {
const section = mount(
installed(true),
available,
action({
current: {
operation: { ...operation, status: "failed", finishedAt, message: "exited with code 1" },
output: [{ type: "stderr", text: "npm error EACCES\n" }],
truncated: true,
},
}).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
await expect.element(section.view.getByText("exited with code 1")).toBeVisible();
expect(section.view.getByTestId("provider-update-output").textContent).toBe(
"npm error EACCES\n",
);
await expect.element(section.view.getByText("Earlier output was dropped.")).toBeVisible();
await expect
.element(section.view.getByRole("link", { name: "Update instructions" }))
.toBeVisible();
await expect.element(section.view.getByRole("button", { name: "Update" })).toBeVisible();
});
// Update carries the update's progress from the tap until it settles, like every in-flight
// button in Settings. The update's row shows only its output meanwhile.
describe("progress", () => {
const runningUpdate = (output: string[] = []) => ({
operation: { ...operation, status: "running" as const },
output: output.map((text) => ({ type: "stdout" as const, text })),
});
function progress(section: ReturnType<typeof mount>) {
const indicators = section.view.queryAllByRole("progressbar");
const button = section.view.queryByTestId("provider-update-start");
return {
count: indicators.length,
inButton: indicators.every((indicator) => button?.contains(indicator) ?? false),
label: button?.textContent,
pressable: button?.getAttribute("aria-disabled") !== "true",
};
}
const updating = { count: 1, inButton: true, label: "Updating...", pressable: false };
const idle = { count: 0, inButton: true, label: "Update", pressable: true };
it("shows progress on Update alone from tap to settle", () => {
const previous = {
operation: { ...operation, status: "failed" as const, finishedAt, message: "exit 1" },
output: [{ type: "stderr" as const, text: "old failure\n" }],
};
const section = mount(installed(true), available, action({ current: previous }).value);
expect(progress(section)).toEqual(idle);
// Tapped, the host has not answered yet: the previous result gives way to the new update.
section.rerender(available, action({ current: previous, starting: true }).value);
expect(progress(section)).toEqual(updating);
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
// Running before any output arrives.
section.rerender(available, action({ current: runningUpdate() }).value);
expect(progress(section)).toEqual(updating);
expect(section.view.queryByTestId("provider-update-run")).toBeNull();
section.rerender(available, action({ current: runningUpdate(["downloading\n"]) }).value);
expect(progress(section)).toEqual(updating);
expect(section.view.getByTestId("provider-update-run").textContent).toBe("downloading\n");
section.rerender(
{ status: "ready", updateCheck: { status: "current" } },
action({
current: {
...runningUpdate(["downloading\n"]),
operation: { ...operation, status: "succeeded", finishedAt },
},
}).value,
);
expect(progress(section)).toEqual(idle);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Updated");
});
it("shows progress on Update when the sheet reopens mid-update", () => {
const section = mount(
installed(true),
available,
action({ current: runningUpdate(["downloading\n"]) }).value,
);
expect(progress(section)).toEqual(updating);
expect(section.view.queryByTestId("provider-update-status")).toBeNull();
});
it.each([
{ name: "still loading", state: { status: "loading" } },
{
name: "not installed",
state: { status: "ready", installation: { inspection: { status: "not_installed" } } },
},
{
name: "failing",
state: {
status: "ready",
installation: { inspection: { status: "error", message: "--version timed out" } },
},
},
{ name: "unreachable", state: { status: "error", message: "Host disconnected" } },
] satisfies { name: string; state: ProviderInstallationState }[])(
"shows progress on Update while the installation reads as $name",
({ state }) => {
const section = mount(
state,
{ status: "hidden" },
action({ current: runningUpdate() }).value,
);
// The inspection may show its own progress beside the Version row.
const button = section.view.getByRole("button", { name: "Updating..." });
expect(button.getAttribute("aria-disabled")).toBe("true");
expect(within(button).queryAllByRole("progressbar")).toHaveLength(1);
},
);
it("returns Update to rest when the host refuses the start", () => {
const section = mount(installed(true), available, action({ starting: true }).value);
expect(progress(section)).toEqual(updating);
section.rerender(available, action({ startError: "Busy" }).value);
expect(progress(section)).toEqual(idle);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
});
});
it("shows why the host refused to start the update", async () => {
const section = mount(
installed(true),
available,
action({ startError: "Provider codex cannot update itself" }).value,
);
expect(section.view.getByTestId("provider-update-status").textContent).toBe("Update failed");
await expect
.element(section.view.getByText("Provider codex cannot update itself"))
.toBeVisible();
});
});
@@ -0,0 +1,448 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type {
ProviderInstallation,
ProviderInstallationUpdate,
ProviderInstallationUpdateCheck,
} from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export type ProviderInstallationState =
| { status: "unsupported" }
| { status: "loading" }
| { status: "ready"; installation: ProviderInstallation | null }
| { status: "error"; message: string };
/**
* What the provider's update check knows. `hidden` until the provider is installed. A null
* `updateCheck` means the provider cannot check; the check only informs, and never gates Update.
*/
export type ProviderUpdatesState =
| { status: "hidden" }
| { status: "loading" }
| { status: "ready"; updateCheck: ProviderInstallationUpdateCheck | null }
| { status: "error"; message: string };
/** Running the provider's own update. Absent when the host cannot report updates. */
export interface ProviderUpdateAction {
/** The provider's latest update since the host started, or null. */
current: ProviderInstallationUpdate | null;
/** The request to start an update is in flight. */
starting: boolean;
/** Why the host did not start the update. */
startError: string | null;
start(): void;
}
export function ProviderInstallationSection({
state,
updates,
action,
}: {
state: ProviderInstallationState;
updates: ProviderUpdatesState;
action?: ProviderUpdateAction;
}) {
const { t } = useTranslation();
if (state.status === "unsupported") return null;
if (
state.status === "ready" &&
!state.installation?.inspection &&
!state.installation?.installUrl
)
return null;
const installation = state.status === "ready" ? state.installation : null;
const run = action ? readUpdateRun(action) : null;
// A running update keeps its row, so Update shows its progress whatever the inspection reads
// meanwhile; the binary may be mid-replacement.
const updating = action ? isUpdating(action) : false;
return (
<View style={settingsStyles.section} testID="provider-installation">
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>
{t("settings.providers.installation.title")}
</Text>
</View>
<View style={settingsStyles.card}>
<InstallationRow state={state} />
{installation?.inspection?.status === "installed" || updating ? (
<UpdatesRow
updates={updates}
updateUrl={installation?.updateUrl}
action={updating || installation?.canUpdate ? action : undefined}
/>
) : null}
{run ? <UpdateRunRow run={run} updateUrl={installation?.updateUrl} /> : null}
</View>
</View>
);
}
function InstallInstructionsLink({ installUrl }: { installUrl: string }) {
const { t } = useTranslation();
return (
<ExternalLink
href={installUrl}
label={t("settings.providers.installation.installInstructions")}
testID="provider-installation-install-instructions"
/>
);
}
function InstallationRow({ state }: { state: ProviderInstallationState }) {
const { t } = useTranslation();
const installation = state.status === "ready" ? state.installation : null;
const installUrl = installation?.installUrl;
const handleInstall = useCallback(() => {
if (installUrl) void openExternalUrl(installUrl);
}, [installUrl]);
if (state.status === "loading") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
<View style={styles.trailing}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.value}>{t("settings.providers.installation.checking")}</Text>
</View>
</View>
);
}
const inspection = installation?.inspection;
if (inspection?.status === "not_installed") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>
{t("settings.providers.installation.notInstalled")}
</Text>
{installUrl ? (
<Button variant="outline" onPress={handleInstall}>
{t("settings.providers.installation.install")}
</Button>
) : null}
</View>
);
}
if (inspection?.status === "installed") {
return (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.version")}</Text>
{inspection.version ? (
<Text
style={styles.version}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-installation-version"
>
{inspection.version}
</Text>
) : (
<Text style={styles.value}>{t("settings.providers.installation.installed")}</Text>
)}
</View>
);
}
// The installed state is unknown: the check failed, or the provider only links to a guide.
let message: string | null = null;
if (state.status === "error") message = state.message;
else if (inspection?.status === "error") message = inspection.message;
if (!message) {
return installUrl ? (
<View style={settingsStyles.row}>
<InstallInstructionsLink installUrl={installUrl} />
</View>
) : null;
}
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.providers.installation.failed")}</Text>
<Text style={settingsStyles.rowHint}>{message}</Text>
{installUrl ? (
<View style={styles.link}>
<InstallInstructionsLink installUrl={installUrl} />
</View>
) : null}
</View>
</View>
);
}
/**
* The update the provider offers, with what its check knows beside it. Update, or the update
* instructions, stay in place whatever the check says: an answer can be an hour old, and the
* provider's own updater reports when there is nothing newer.
*/
function UpdatesRow({
updates,
updateUrl,
action,
}: {
updates: ProviderUpdatesState;
updateUrl: string | undefined;
/** Present when the provider runs its own update. */
action: ProviderUpdateAction | undefined;
}) {
const { t } = useTranslation();
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
const offersUpdate = Boolean(action || updateUrl);
// With nothing to offer, the row waits until the check has something to say.
if (!offersUpdate && !updateCheck && updates.status !== "error") return null;
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-updates">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{updateCheck?.status === "available"
? t("settings.providers.installation.updateAvailable")
: t("settings.providers.installation.updates")}
</Text>
<UpdateCheckSummary updates={updates} />
</View>
<UpdateButton action={action} updateUrl={updateUrl} />
</View>
);
}
function UpdateCheckSummary({ updates }: { updates: ProviderUpdatesState }) {
const { t } = useTranslation();
if (updates.status === "loading") {
return (
<View style={styles.trailing}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={settingsStyles.rowHint}>{t("settings.providers.installation.checking")}</Text>
</View>
);
}
if (updates.status === "hidden") return null;
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
if (updates.status === "ready" && !updateCheck) return null;
if (updateCheck?.status === "current") {
return (
<Text style={settingsStyles.rowHint} testID="provider-updates-current">
{t("settings.providers.installation.upToDate")}
</Text>
);
}
if (updateCheck?.status === "available") {
// Versions are opaque; drop repeats so each one keys its own line.
const versions = [...new Set(updateCheck.versions ?? [])];
return (
<>
{versions.map((version) => (
<Text
key={version}
style={styles.availableVersion}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-updates-version"
>
{version}
</Text>
))}
{updateCheck.message ? (
<Text style={settingsStyles.rowHint}>{updateCheck.message}</Text>
) : null}
</>
);
}
// A check that threw, timed out or could not tell is never shown as up to date.
const message = updates.status === "error" ? updates.message : updateCheck?.message;
return (
<>
<Text style={settingsStyles.rowHint} testID="provider-updates-unchecked">
{t("settings.providers.installation.updatesFailed")}
</Text>
{message ? <Text style={settingsStyles.rowHint}>{message}</Text> : null}
</>
);
}
function isUpdating(action: ProviderUpdateAction): boolean {
return action.starting || action.current?.operation.status === "running";
}
/**
* Update runs the provider's updater; without one, the provider's update guide opens. The button
* carries the update's progress from the tap until it settles.
*/
function UpdateButton({
action,
updateUrl,
}: {
action: ProviderUpdateAction | undefined;
updateUrl: string | undefined;
}) {
const { t } = useTranslation();
const handleOpenInstructions = useCallback(() => {
if (updateUrl) void openExternalUrl(updateUrl);
}, [updateUrl]);
if (action)
return (
<Button
variant="outline"
onPress={action.start}
loading={isUpdating(action)}
testID="provider-update-start"
>
{isUpdating(action)
? t("settings.providers.installation.updating")
: t("settings.providers.installation.update")}
</Button>
);
if (!updateUrl) return null;
return (
<Button variant="outline" onPress={handleOpenInstructions}>
{t("settings.providers.installation.updateInstructions")}
</Button>
);
}
type UpdateRun =
| { status: "running"; update: ProviderInstallationUpdate }
| { status: "succeeded" | "failed"; update: ProviderInstallationUpdate }
| { status: "refused"; message: string };
/**
* What the update's row shows, or null for nothing. While the update starts or runs, Update shows
* its progress and the row holds only the output so far. A new start clears the previous result.
*/
function readUpdateRun(action: ProviderUpdateAction): UpdateRun | null {
const update = action.current;
if (isUpdating(action)) {
return update?.operation.status === "running" && update.output.length > 0
? { status: "running", update }
: null;
}
if (action.startError) return { status: "refused", message: action.startError };
if (!update || update.operation.status === "running") return null;
return { status: update.operation.status, update };
}
function UpdateRunRow({ run, updateUrl }: { run: UpdateRun; updateUrl: string | undefined }) {
const { t } = useTranslation();
const scroll = useRef<ScrollView>(null);
const followOutput = useCallback(() => scroll.current?.scrollToEnd({ animated: false }), []);
const update = run.status === "refused" ? null : run.update;
const output = update?.output.map((entry) => entry.text).join("") ?? "";
let message: string | undefined;
if (run.status === "refused") message = run.message;
else if (run.update.operation.status !== "running") message = run.update.operation.message;
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="provider-update-run">
<View style={settingsStyles.rowContent}>
{run.status === "running" ? null : (
<Text style={settingsStyles.rowTitle} testID="provider-update-status">
{run.status === "succeeded"
? t("settings.providers.installation.updated")
: t("settings.providers.installation.updateFailed")}
</Text>
)}
{message ? (
<Text style={settingsStyles.rowHint} selectable testID="provider-update-message">
{message}
</Text>
) : null}
{output ? (
<ScrollView
ref={scroll}
style={
run.status === "running" ? styles.output : [styles.output, styles.outputAfterResult]
}
contentContainerStyle={styles.outputContent}
onContentSizeChange={followOutput}
// Android scrolls a ScrollView nested in the provider sheet only with this flag.
nestedScrollEnabled
>
{update?.truncated ? (
<Text style={styles.outputNote}>
{t("settings.providers.installation.outputTruncated")}
</Text>
) : null}
<Text
style={styles.outputText}
selectable
dataSet={CODE_SURFACE_DATASET}
testID="provider-update-output"
>
{output}
</Text>
</ScrollView>
) : null}
{(run.status === "failed" || run.status === "refused") && updateUrl ? (
<View style={styles.link}>
<ExternalLink
href={updateUrl}
label={t("settings.providers.installation.updateInstructions")}
/>
</View>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
output: {
maxHeight: 240,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
},
outputAfterResult: {
marginTop: theme.spacing[2],
},
outputContent: {
padding: theme.spacing[3],
},
outputText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: 20,
color: theme.colors.foreground,
},
outputNote: {
marginBottom: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
value: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
version: {
flexShrink: 1,
marginLeft: theme.spacing[3],
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
},
availableVersion: {
marginTop: theme.spacing[1],
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
},
link: {
flexDirection: "row",
marginTop: theme.spacing[1],
},
}));
@@ -0,0 +1,190 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import type {
ProviderInstallationState,
ProviderUpdateAction,
ProviderUpdatesState,
} from "./provider-installation-section";
import { applyProviderUpdateProgress } from "./provider-update-progress";
export interface ProviderInstallationRead {
state: ProviderInstallationState;
updates: ProviderUpdatesState;
/** Absent when the host cannot report provider installations. */
action: ProviderUpdateAction | undefined;
/** Ask the provider again, skipping the host's caches. */
refresh: () => void;
/** A refresh is in flight. The last answers stay on screen until the new ones arrive. */
refreshing: boolean;
}
/**
* Reads a provider's installation from hosts that advertise `providerInstallation`. The update
* check is a separate request that starts once the provider reports itself installed, so the
* installed version never waits on it.
*/
export function useProviderInstallation(input: {
serverId: string;
provider: string;
visible: boolean;
}): ProviderInstallationRead {
const { serverId, provider, visible } = input;
const supported = useHostFeature(serverId, "providerInstallation");
const client = useHostRuntimeClient(serverId);
const queryClient = useQueryClient();
const { t } = useTranslation();
const enabled = supported && visible && Boolean(client);
const installationKey = ["provider-installation", serverId, provider] as const;
const updatesKey = ["provider-updates", serverId, provider] as const;
const inspect = useCallback(
async (refresh: boolean) => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.inspectProviderInstallation(provider, { refresh });
if (result.error) throw new Error(result.error);
return result.installation;
},
[client, provider, t],
);
const checkForUpdates = useCallback(
async (refresh: boolean) => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.checkProviderUpdates(provider, { refresh });
if (result.error) throw new Error(result.error);
return result.updateCheck;
},
[client, provider, t],
);
// The host caches both answers, so reading on every open is cheap.
const installationQuery = useFetchQuery({
queryKey: installationKey,
queryFn: () => inspect(false),
enabled,
dataShape: "value",
staleTimeMs: 0,
});
const installed = installationQuery.data?.inspection?.status === "installed";
const updatesQuery = useFetchQuery({
queryKey: updatesKey,
queryFn: () => checkForUpdates(false),
enabled: enabled && installed,
dataShape: "value",
staleTimeMs: 0,
});
// A refresh runs through the queries so its answer or its failure is what the screen shows.
// `staleTime: 0` overrides the app default so the request always goes out.
const refreshInstallation = useMutation({
mutationFn: () =>
queryClient.fetchQuery({
queryKey: installationKey,
queryFn: () => inspect(true),
staleTime: 0,
}),
});
const refreshUpdates = useMutation({
mutationFn: () =>
queryClient.fetchQuery({
queryKey: updatesKey,
queryFn: () => checkForUpdates(true),
staleTime: 0,
}),
});
// The host owns the update; this view follows it while open and catches up on reopen.
const [currentUpdate, setCurrentUpdate] = useState<ProviderInstallationUpdate | null>(null);
useEffect(() => {
if (!enabled || !client) return;
const subscription = client.observeProviderUpdates(provider);
// The update this view saw running. Its success arrives as live progress, or as the snapshot
// a reconnect delivers when the progress was missed; either way the host dropped its cached
// answers, so read the new version and update state.
let runningId: string | null = null;
const follow = (operation: ProviderInstallationUpdate["operation"] | undefined) => {
if (!operation) return;
if (operation.status === "running") {
runningId = operation.id;
return;
}
if (operation.id !== runningId) return;
runningId = null;
if (operation.status !== "succeeded") return;
void queryClient.invalidateQueries({
queryKey: ["provider-installation", serverId, provider],
});
void queryClient.invalidateQueries({ queryKey: ["provider-updates", serverId, provider] });
};
const detach = subscription.subscribe({
snapshot: (response) => {
setCurrentUpdate(response.update);
follow(response.update?.operation);
},
update: (message) => {
if (message.type !== "provider.installation.update.progress") return;
setCurrentUpdate((current) => applyProviderUpdateProgress(current, message.payload));
follow(message.payload.operation);
},
error: (failure) => console.warn("Failed to observe provider updates", failure),
});
return () => {
detach();
void subscription
.release()
.catch((failure) => console.warn("Failed to release provider update observation", failure));
};
}, [client, enabled, provider, queryClient, serverId]);
const startUpdate = useMutation({
mutationFn: async () => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const result = await client.updateProvider(provider);
if (result.error) throw new Error(result.error);
return result.update;
},
onSuccess: (update) => {
if (!update) return;
// Progress for this operation may already have arrived; keep the further-along copy.
setCurrentUpdate((current) =>
current?.operation.id === update.operation.id ? current : update,
);
},
});
const { mutate: mutateStartUpdate } = startUpdate;
const start = useCallback(() => mutateStartUpdate(), [mutateStartUpdate]);
const action: ProviderUpdateAction | undefined = supported
? {
current: currentUpdate,
starting: startUpdate.isPending,
startError: startUpdate.error?.message ?? null,
start,
}
: undefined;
const { mutate: mutateInstallation } = refreshInstallation;
const { mutate: mutateUpdates } = refreshUpdates;
const refresh = useCallback(() => {
if (!supported || !client) return;
mutateInstallation();
if (installed) mutateUpdates();
}, [client, installed, mutateInstallation, mutateUpdates, supported]);
let state: ProviderInstallationState;
if (!supported) state = { status: "unsupported" };
else if (installationQuery.isPending) state = { status: "loading" };
else if (installationQuery.isError)
state = { status: "error", message: installationQuery.error.message };
else state = { status: "ready", installation: installationQuery.data ?? null };
let updates: ProviderUpdatesState;
if (!supported || !installed) updates = { status: "hidden" };
else if (updatesQuery.isPending) updates = { status: "loading" };
else if (updatesQuery.isError) updates = { status: "error", message: updatesQuery.error.message };
else updates = { status: "ready", updateCheck: updatesQuery.data ?? null };
const refreshing = refreshInstallation.isPending || refreshUpdates.isPending;
return { state, updates, action, refresh, refreshing };
}
@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { ProviderDetailSheet } from "@/provider-settings";
import { OverlayLayerProvider } from "@/lib/overlay-root";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
@@ -20,7 +20,7 @@ export function ProviderSettingsHost() {
return (
<OverlayLayerProvider layer={overlayParentLayer}>
<ProviderDiagnosticSheet
<ProviderDetailSheet
key={`${serverId}:${provider}`}
provider={provider}
serverId={serverId}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { ProviderInstallationUpdateProgress } from "@getpaseo/protocol/messages";
import { applyProviderUpdateProgress } from "./provider-update-progress";
const running = {
status: "running",
id: "update-1",
startedAt: "2026-11-13T00:00:00.000Z",
} as const;
function progress(...texts: string[]): ProviderInstallationUpdateProgress {
return {
provider: "codex",
operation: running,
output: texts.map((text) => ({ type: "stdout", text })),
};
}
describe("applyProviderUpdateProgress", () => {
it("appends output for the same update", () => {
const first = applyProviderUpdateProgress(null, progress("a"), { outputLimit: 10 });
const second = applyProviderUpdateProgress(first, progress("b"), { outputLimit: 10 });
expect(second.output.map((entry) => entry.text)).toEqual(["a", "b"]);
expect(second.truncated).toBeUndefined();
});
it("drops the oldest output beyond the limit and marks the update truncated", () => {
let update = applyProviderUpdateProgress(null, progress("12345"), { outputLimit: 8 });
update = applyProviderUpdateProgress(update, progress("6789"), { outputLimit: 8 });
expect(update.output.map((entry) => entry.text)).toEqual(["6789"]);
expect(update.truncated).toBe(true);
update = applyProviderUpdateProgress(update, progress("abcdefghij"), { outputLimit: 8 });
expect(update.output.map((entry) => entry.text)).toEqual(["cdefghij"]);
expect(update.truncated).toBe(true);
});
it("starts a new update empty", () => {
const first = applyProviderUpdateProgress(null, progress("123456789"), { outputLimit: 4 });
const next = applyProviderUpdateProgress(
first,
{ ...progress("x"), operation: { ...running, id: "update-2" } },
{ outputLimit: 4 },
);
expect(next.output.map((entry) => entry.text)).toEqual(["x"]);
expect(next.truncated).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type {
ProviderInstallationUpdate,
ProviderInstallationUpdateOutput,
ProviderInstallationUpdateProgress,
} from "@getpaseo/protocol/messages";
/** Characters of update output the app keeps, matching what the daemon retains. */
export const PROVIDER_UPDATE_OUTPUT_LIMIT = 64 * 1024;
/**
* Folds a progress message into the update it belongs to; a new operation starts empty. Output
* beyond `outputLimit` characters drops oldest first and marks the update truncated, so a noisy
* updater cannot grow the screen's memory while it stays open.
*/
export function applyProviderUpdateProgress(
current: ProviderInstallationUpdate | null,
progress: ProviderInstallationUpdateProgress,
options: { outputLimit: number } = { outputLimit: PROVIDER_UPDATE_OUTPUT_LIMIT },
): ProviderInstallationUpdate {
const previous = current?.operation.id === progress.operation.id ? current : null;
const limit = options.outputLimit;
const output: ProviderInstallationUpdateOutput[] = [
...(previous?.output ?? []),
...progress.output,
];
let length = output.reduce((total, entry) => total + entry.text.length, 0);
let truncated = previous?.truncated ?? false;
while (length > limit && output.length > 1) {
length -= output.shift()!.text.length;
truncated = true;
}
const last = output.at(-1);
if (last && last.text.length > limit) {
output[output.length - 1] = { type: last.type, text: last.text.slice(-limit) };
truncated = true;
}
return { operation: progress.operation, output, ...(truncated ? { truncated: true } : {}) };
}
@@ -0,0 +1,60 @@
import { Plus } from "lucide-react-native";
import { useCallback } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ICON_SIZE } from "@/styles/theme";
import { useSettingsRowLayout } from "./row-density";
const ThemedPlus = withUnistyles(Plus);
type HoverState = PressableStateCallbackType & { hovered?: boolean };
/**
* The last row of a card that lists things, adding one more. Quiet, so the list reads first: a
* muted plus in the rows' leading slot and a muted label on the labels' rail.
*/
export function SettingsAddRow({
label,
onPress,
testID,
}: {
label: string;
onPress: () => void;
testID?: string;
}) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
layout.row,
styles.row,
hovered && styles.hovered,
pressed && styles.pressed,
],
[layout],
);
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.pressed}
testID={testID}
>
<View style={styles.leading}>
<ThemedPlus size={ICON_SIZE.md} uniProps={mutedIconColorMapping} />
</View>
<Text style={[layout.title, styles.label]}>{label}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
row: { justifyContent: "flex-start" },
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
label: { color: theme.colors.foregroundMuted },
hovered: { backgroundColor: theme.colors.surface2 },
pressed: { backgroundColor: theme.colors.surface3 },
}));
@@ -0,0 +1,86 @@
import { ArrowLeft } from "lucide-react-native";
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
* "Back to …" above a settings detail page on wide layouts, where the page has no header of its
* own. The ghost button drops its padding so the arrow sits on the content's leading rail.
*/
export function SettingsBackLink({
label,
onPress,
testID,
}: {
label: string;
onPress: () => void;
testID?: string;
}) {
return (
<Button
testID={testID}
accessibilityLabel={label}
onPress={onPress}
variant="ghost"
leftIcon={ArrowLeft}
style={styles.backLink}
>
{label}
</Button>
);
}
/**
* The top of a settings detail page: "Back to …" when the page has no header of its own, then the
* icon and name the page is about, with an optional action after the name.
*/
export function SettingsDetailHeader({
back,
icon,
title,
trailing,
titleTestID,
}: {
back?: { label: string; onPress: () => void; testID?: string };
icon: ReactNode;
title: string;
trailing?: ReactNode;
titleTestID?: string;
}) {
return (
<View style={styles.header}>
{back ? <SettingsBackLink {...back} /> : null}
<View style={styles.titleRow}>
{icon}
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
{title}
</Text>
{trailing}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
backLink: {
alignSelf: "flex-start",
paddingHorizontal: 0,
},
header: {
gap: theme.spacing[2],
marginBottom: theme.spacing[4],
},
titleRow: {
marginTop: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
}));
@@ -1,12 +1,16 @@
import type { ReactNode } from "react";
import { SettingsRowHeader } from "./row-header";
import { SettingsRowHeader, type SettingsRowHintStatus } from "./row-header";
interface SettingsDetailRowProps {
label: string;
/** An icon before the label, such as the thing the row opens. */
leading?: ReactNode;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
/** Marks the hint as work under way, a fault, or a value still loading. */
hintStatus?: SettingsRowHintStatus;
/** What the detail is set to, so the row says it without being opened. */
summary?: string;
onPress: () => void;
@@ -14,6 +18,10 @@ interface SettingsDetailRowProps {
disabled?: boolean;
/** Opens a page outside the app; an outward arrow replaces the chevron. */
external?: boolean;
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
control?: ReactNode;
/** Names the row for assistive tech when the label alone does not say where it leads. */
accessibilityLabel?: string;
testID?: string;
}
+12 -1
View File
@@ -30,11 +30,16 @@ export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
export { SettingsDetailRow } from "./detail-row";
export type { SettingsRowHintStatus } from "./row-header";
export { SettingsAddRow } from "./add-row";
export { SettingsBackLink, SettingsDetailHeader } from "./detail-header";
export { SettingsInfoTip } from "./headings/settings-info-tip";
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
interface AppRowExtras {
labelAccessory?: ReactNode;
/** An icon before the label, such as the thing the row is about. */
leading?: ReactNode;
}
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
@@ -58,6 +63,7 @@ export function SettingsCard({ children, testID }: { children: ReactNode; testID
export function SettingsRow({
label,
leading,
labelAccessory,
hint,
error,
@@ -70,6 +76,7 @@ export function SettingsRow({
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
return (
<View style={rowStyle} testID={testID}>
{leading ? <View style={styles.leading}>{leading}</View> : null}
<View style={styles.label}>
{labelAccessory ? (
<View style={styles.labelRow}>
@@ -207,7 +214,11 @@ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: Setti
}
const styles = StyleSheet.create((theme) => ({
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
// A control that wraps under the label gets space above it. Across the row, the leading slot and
// the label already carry their own margins, so a column gap would double them.
compactRow: { flexWrap: "wrap", rowGap: theme.spacing[3] },
// The same leading slot as `SettingsRowHeader`, so icons and labels share rails across row kinds.
leading: { marginRight: theme.spacing[3], alignItems: "center", justifyContent: "center" },
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
labelRow: {
flexDirection: "row",
@@ -2,7 +2,12 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Button } from "@/components/ui/button";
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
import {
SettingsCard,
SettingsCollapsibleRow,
SettingsDetailRow,
SettingsRow,
} from "@/components/settings";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -14,6 +19,8 @@ afterEach(() => {
container = null;
});
function openNothing() {}
function mountRows() {
container = document.createElement("div");
document.body.appendChild(container);
@@ -26,6 +33,7 @@ function mountRows() {
Download
</Button>
</SettingsRow>
<SettingsDetailRow label="Opens a page" onPress={openNothing} testID="detail-row" />
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
<SettingsRow label="Nested with a button" testID="nested-with-button">
<Button variant="outline" testID="nested-button">
@@ -66,6 +74,13 @@ describe("settings row density", () => {
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
});
it("keeps a row that opens something as tall as a row holding a control", () => {
mountRows();
expect(heightOf("detail-row")).toBe(heightOf("card-row"));
expect(heightOf("group-toggle")).toBe(heightOf("card-row"));
});
it("sizes nested text and controls one step below card rows", () => {
mountRows();
@@ -16,6 +16,8 @@ export function NestedSettingsRows({ children }: { children: ReactNode }) {
export interface SettingsRowLayout {
row: StyleProp<ViewStyle>;
/** A row as tall as one holding a control of `controlSize`, whether or not it holds one. */
controlRow: StyleProp<ViewStyle>;
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
@@ -30,12 +32,14 @@ export function useSettingsRowLayout(): SettingsRowLayout {
nested
? {
row: [settingsStyles.row, styles.nestedRow],
controlRow: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "sm",
}
: {
row: settingsStyles.row,
controlRow: [settingsStyles.row, styles.cardControlRow],
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "md",
@@ -45,6 +49,9 @@ export function useSettingsRowLayout(): SettingsRowLayout {
}
const styles = StyleSheet.create((theme) => ({
cardControlRow: {
minHeight: mapLayout(CONTROL_HEIGHTS.md, (height) => height + theme.spacing[4] * 2),
},
// Every nested row is as tall as one holding an `sm` control, so rows with and without a
// trailing button line up.
nestedRow: {
@@ -1,25 +1,36 @@
import { ChevronRight } from "lucide-react-native";
import { useCallback, useMemo, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { ExternalLinkLabel } from "@/components/ui/external-link";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { PressHighlight } from "@/components/ui/press-highlight";
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useSettingsRowLayout } from "./row-density";
import { useSettingsRowLayout, type SettingsRowLayout } from "./row-density";
const ThemedChevron = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
type HoverState = PressableStateCallbackType & { hovered?: boolean };
/**
* What the hint reports beyond its words: work under way (a leading spinner), a fault (a leading
* red dot and red text), or a value still loading (a placeholder the height of the line).
*/
export type SettingsRowHintStatus = "busy" | "error" | "pending";
export interface SettingsRowHeaderProps {
label: string;
/** An icon before the label, such as the thing the row opens. */
leading?: ReactNode;
/** Shown beside the label, such as a badge. */
labelAccessory?: ReactNode;
/** A line under the label saying what the row leads to. */
hint?: string;
hintStatus?: SettingsRowHintStatus;
/** A short status shown before the chevron. */
summary?: string;
onPress: () => void;
@@ -28,6 +39,10 @@ export interface SettingsRowHeaderProps {
expanded?: boolean;
/** The row leaves the app, such as to a docs page: the summary and an outward arrow are drawn as an external link in place of the chevron. */
external?: boolean;
/** A control acting on the row's subject in place, such as a switch, before the chevron. */
control?: ReactNode;
/** Names the row for assistive tech when the label alone does not say where it leads. */
accessibilityLabel?: string;
accessibilityState?: AccessibilityState;
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
webAccessibilityProps?: Record<string, unknown> | null;
@@ -38,81 +53,216 @@ export interface SettingsRowHeaderProps {
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
* pointing right.
*
* The press target is a button laid under the row's content rather than wrapped around it, so a
* control in the row is the button's sibling: a switch inside a button is invalid markup, and keys
* pressed on the switch would also press the row. Everything drawn over the button lets presses
* through to it. The label, icon and summary stay out of the accessibility tree, where the button's
* label already names the row; the hint stays readable, since it carries the row's state.
* Hover is tracked on the row itself, so it holds while the pointer is over the control.
*
* The row is as tall as one holding a control whether or not it holds one, so a list where only
* some rows carry a switch keeps one row height.
*/
export function SettingsRowHeader({
label,
leading,
labelAccessory,
hint,
hintStatus,
summary,
onPress,
disabled,
expanded = false,
external = false,
control,
accessibilityLabel,
accessibilityState,
webAccessibilityProps,
testID,
}: SettingsRowHeaderProps) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
[disabled, layout],
const [pointerOver, setPointerOver] = useState(false);
const handlePointerEnter = useCallback(() => setPointerOver(true), []);
const handlePointerLeave = useCallback(() => setPointerOver(false), []);
const hovered = pointerOver && !disabled;
const rowStyle = useMemo(() => [layout.controlRow, hovered && styles.hovered], [hovered, layout]);
const pressTargetStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.pressTarget,
!disabled && pressed && styles.pressed,
],
[disabled],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<PressHighlight
{...webAccessibilityProps}
accessibilityRole={external ? "link" : "button"}
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={onPress}
<View
style={rowStyle}
highlightStyle={styles.pressed}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
testID={testID}
>
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={layout.title}>{label}</Text>
{labelAccessory}
<PressHighlight
{...webAccessibilityProps}
accessibilityRole={external ? "link" : "button"}
accessibilityLabel={accessibilityLabel ?? (summary ? `${label}, ${summary}` : label)}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={onPress}
style={pressTargetStyle}
highlightStyle={styles.pressed}
/>
{leading ? (
<View style={[styles.leading, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
{leading}
</View>
) : null}
<View style={[settingsStyles.rowContent, styles.passThrough]}>
<View style={labelAccessory ? styles.labelRow : undefined} {...HIDDEN_FROM_ASSISTIVE_TECH}>
<Text style={layout.title}>{label}</Text>
{labelAccessory}
</View>
{hintStatus ? (
<RowHintLine hint={hint} status={hintStatus} hintStyle={layout.hint} />
) : null}
{hint && !hintStatus ? <Text style={layout.hint}>{hint}</Text> : null}
</View>
{control ? (
<View style={styles.control}>
<ControlSizeProvider size={layout.controlSize}>{control}</ControlSizeProvider>
</View>
) : null}
<View style={[styles.trailing, styles.passThrough]} {...HIDDEN_FROM_ASSISTIVE_TECH}>
{external ? (
// Drawn the same as a section header's docs link, so the two read as one thing.
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
) : (
<>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
) : (
<Text style={layout.title}>{label}</Text>
)}
{hint ? <Text style={layout.hint}>{hint}</Text> : null}
</View>
{external ? (
// Drawn the same as a section header's docs link, so the two read as one thing.
<ExternalLinkLabel hovered={hovered}>{summary}</ExternalLinkLabel>
) : (
<>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
)}
</>
)}
</>
)}
</PressHighlight>
</>
)}
</View>
</View>
);
}
const HIDDEN_FROM_ASSISTIVE_TECH = {
accessibilityElementsHidden: true,
importantForAccessibility: "no-hide-descendants",
"aria-hidden": true,
} as const;
/**
* The hint with its status mark. The line keeps the hint's own height in every status, so a row
* does not move when its value arrives or its work starts. A long error is cut to the line; the
* row's detail says it in full.
*/
function RowHintLine({
hint,
status,
hintStyle,
}: {
hint: string | undefined;
status: SettingsRowHintStatus;
hintStyle: SettingsRowLayout["hint"];
}) {
const textStyle = useMemo(
() => [hintStyle, styles.hintText, status === "error" && styles.hintError],
[hintStyle, status],
);
if (status === "pending") {
return (
<View style={styles.hintLine}>
{/* A blank line of hint text holds the height; the placeholder sits over it. */}
<Text style={textStyle}> </Text>
<HintPlaceholder />
</View>
);
}
return (
<View style={styles.hintLine}>
{status === "busy" ? (
<ThemedLoadingSpinner size={14} uniProps={mutedIconColorMapping} />
) : (
<View style={styles.errorDot} />
)}
<Text style={textStyle} numberOfLines={1}>
{hint}
</Text>
</View>
);
}
function HintPlaceholder() {
const pulse = useSkeletonPulse();
return <SkeletonPulse pulse={pulse} style={styles.hintPlaceholder} />;
}
const styles = StyleSheet.create((theme) => ({
leading: {
marginRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
control: {
marginRight: theme.spacing[3],
},
// The button under the row's content; it fills the row, padding included.
pressTarget: {
...StyleSheet.absoluteFillObject,
},
passThrough: {
pointerEvents: "none",
},
trailing: {
flexDirection: "row",
alignItems: "center",
flexShrink: 1,
},
hintLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
marginTop: theme.spacing[1],
},
// The line carries the hint's top margin, so the mark and the words share one baseline box.
hintText: {
marginTop: 0,
flexShrink: 1,
},
hintError: {
color: theme.colors.statusDanger,
},
errorDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.statusDanger,
},
hintPlaceholder: {
position: "absolute",
left: 0,
top: theme.spacing[0.5],
bottom: theme.spacing[0.5],
width: theme.spacing[32],
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
labelRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
summary: {
flexShrink: 1,
@@ -462,9 +462,7 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
@@ -474,7 +472,7 @@ describe("sidebar group visibility", () => {
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
useLimitedSidebarGroup({ items, selection: null, policy }),
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -499,7 +497,6 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items,
selection: null,
expanded: false,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
);
@@ -521,9 +518,7 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -537,10 +532,9 @@ describe("sidebar group visibility", () => {
"done",
"attention",
]);
const { result, rerender } = renderHook(
({ rows, expanded }) =>
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
{ initialProps: { rows: items, expanded: false } },
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
"row-1",
@@ -554,15 +548,16 @@ describe("sidebar group visibility", () => {
"row-10",
"row-12",
]);
rerender({ rows: items, expanded: true });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.slice(0, 3), expanded: true });
expect(result.current.expanded).toBe(true);
rerender({ rows: items.slice(0, 3) });
expect(result.current.canToggle).toBe(false);
rerender({ rows: items.toReversed(), expanded: true });
rerender({ rows: items.toReversed() });
expect(result.current.visibleItems).toEqual(items.toReversed());
rerender({ rows: items.toReversed(), expanded: false });
act(() => result.current.toggleExpanded());
expect(names(result.current.visibleItems)).toEqual([
"row-12",
"row-11",
@@ -573,25 +568,15 @@ describe("sidebar group visibility", () => {
"row-6",
]);
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
rerender({ rows: [newest, ...items], expanded: false });
rerender({ rows: [newest, ...items] });
expect(result.current.visibleItems[0]).toBe(newest);
expect(result.current.visibleItems).toHaveLength(10);
});
it("shows the reveal its owner passes in on every mount", () => {
// A native drop remounts every project block it moves, so the group holds no reveal of its
// own. Only the native list remounts rows; the browser list never does, so no test here can
// drive that remount.
const items = entries([...Array(7).fill("done"), "running", "done", "failed", "done"]);
const first = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
);
expect(first.result.current.visibleItems).toEqual(items);
first.unmount();
const remounted = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
);
expect(remounted.result.current.visibleItems).toEqual(items);
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
@@ -617,7 +602,6 @@ describe("sidebar group visibility", () => {
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
expanded: false,
policy,
}),
{ initialProps: { last: items[11] } },
@@ -657,15 +641,12 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result, rerender } = renderHook(
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
{ initialProps: { expanded: false } },
);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
rerender({ expanded: true });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toEqual(items);
rerender({ expanded: false });
act(() => result.current.toggleExpanded());
expect(result.current.visibleItems).toHaveLength(20);
});
});
@@ -368,10 +368,12 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
@@ -380,6 +382,7 @@ function getProjectWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -817,6 +820,7 @@ function NewWorkspaceGhostRow({
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
pressed && styles.newWorkspaceGhostRowPressed,
],
[],
);
@@ -829,7 +833,6 @@ function NewWorkspaceGhostRow({
})}
onPress={handlePress}
style={rowStyle}
highlightStyle={styles.newWorkspaceGhostRowPressed}
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
>
{({ hovered, pressed }) => (
@@ -880,7 +883,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -927,13 +930,25 @@ function ProjectHeaderRow({
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const handleProjectPressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleProjectPressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const projectRowStyle = useMemo(
() => [
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
],
[isDragging, selected, isHovered],
);
@@ -946,7 +961,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -992,10 +1007,9 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1019,10 +1033,9 @@ function ProjectHeaderRow({
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={interaction.handlePressIn}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1078,7 +1091,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const isCompact = useIsCompactFormFactor();
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isTouchPlatform = platformIsNative || isCompact;
const interaction = useLongPressDragInteraction({
drag,
@@ -1098,6 +1111,17 @@ function WorkspaceRowInner({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
@@ -1111,8 +1135,13 @@ function WorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1147,11 +1176,10 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1529,8 +1557,6 @@ function ProjectBlock({
showShortcutBadges,
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
workspacesExpanded,
onToggleWorkspacesExpanded,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
@@ -1557,9 +1583,6 @@ function ProjectBlock({
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
workspacesExpanded: boolean;
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
@@ -1584,11 +1607,12 @@ function ProjectBlock({
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: workspacesExpanded,
policy: visibilityPolicy,
});
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
@@ -1746,10 +1770,6 @@ function ProjectBlock({
onToggleCollapsed(project.viewKey);
}, [onToggleCollapsed, project.viewKey]);
const handleToggleWorkspacesExpanded = useCallback(() => {
onToggleWorkspacesExpanded(project.viewKey);
}, [onToggleWorkspacesExpanded, project.viewKey]);
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
@@ -1786,7 +1806,7 @@ function ProjectBlock({
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
onPress={handleToggleWorkspacesExpanded}
onPress={toggleWorkspacesExpanded}
testID={`sidebar-project-show-more-${project.viewKey}`}
/>
) : null}
@@ -1856,8 +1876,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.workspacesExpanded === next.workspacesExpanded &&
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
@@ -2186,29 +2204,15 @@ function ProjectModeList({
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
const togglePinnedChatsExpanded = useCallback(
() => setPinnedChatsExpanded((current) => !current),
[],
);
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
setExpandedProjectKeys((current) => {
const next = new Set(current);
if (!next.delete(projectViewKey)) next.add(projectViewKey);
return next;
});
}, []);
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
expanded: pinnedChatsExpanded,
});
const nativeScrollGestureProps = useMemo(
() =>
@@ -2364,8 +2368,6 @@ function ProjectModeList({
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
@@ -2387,8 +2389,6 @@ function ProjectModeList({
},
[
collapsedProjectKeys,
expandedProjectKeys,
toggleProjectWorkspacesExpanded,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
@@ -2592,9 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
// Schedules icon across the divider; their layout boxes have different insets.
paddingTop: 2,
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
// This space stays present across toast changes so the scroll range never jumps.
paddingBottom: theme.spacing[24],
},
projectListContainer: {
width: "100%",
@@ -39,6 +39,7 @@ export function SidebarGroupToggleRow({
styles.row,
indented && sidebarWorkspaceRowStyles.rowIndented,
hovered && !pressed && styles.rowHovered,
pressed && styles.rowPressed,
],
[indented],
);
@@ -49,7 +50,6 @@ export function SidebarGroupToggleRow({
accessibilityLabel={label}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.rowPressed}
testID={testID}
>
{({ hovered, pressed }) => (
@@ -12,17 +12,16 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
*/
export function getSidebarRowBackdrop({
isDragging = false,
isHighlighted = false,
isPressed = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
/** The press highlight is painting the row's pressed fill. */
isHighlighted?: boolean;
isPressed?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SidebarSurfaceBackdrop {
if (isDragging || isHighlighted) return "surface2";
if (isDragging || isPressed) return "surface2";
if (selected) return "surfaceSidebarSelected";
if (isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
@@ -9,7 +9,13 @@ import {
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { View, Text, ScrollView } from "react-native";
import {
View,
Text,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -149,20 +155,13 @@ export function SidebarStatusWorkspaceList({
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.togglePinnedCollapsed,
);
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
const togglePinnedWorkspacesExpanded = useCallback(
() => setPinnedWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
canToggle: canTogglePinnedWorkspaces,
} = useLimitedSidebarGroup({
items: pinnedWorkspaces,
selection: activeWorkspaceSelection,
expanded: pinnedWorkspacesExpanded,
});
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
@@ -367,19 +366,15 @@ function StatusGroupRows({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
const toggleWorkspacesExpanded = useCallback(
() => setWorkspacesExpanded((current) => !current),
[],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
expanded: workspacesExpanded,
policy: STATUS_GROUP_VISIBILITY,
});
@@ -465,8 +460,12 @@ function StatusGroupHeader({
}, [group.key, toggleWorkspaceGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useMemo(
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
@@ -478,7 +477,6 @@ function StatusGroupHeader({
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
accessibilityState={accessibilityState}
style={rowStyle}
highlightStyle={styles.statusGroupRowPressed}
onPress={handlePress}
testID={`sidebar-status-group-${group.key}`}
>
@@ -828,7 +826,7 @@ function StatusWorkspaceRowInnerContent({
}) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
const [isHighlighted, setIsHighlighted] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
@@ -853,6 +851,17 @@ function StatusWorkspaceRowInnerContent({
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame
@@ -878,12 +887,13 @@ function StatusWorkspaceRowInnerContent({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -918,11 +928,10 @@ function StatusWorkspaceRowInnerContent({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
onHighlightChange={setIsHighlighted}
onPressIn={isEditingTitle ? undefined : startDragPress}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : endDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1059,11 +1068,13 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -1075,6 +1086,7 @@ function getStatusWorkspaceRowStyle({
isHovered && styles.workspaceRowHovered,
selected && styles.sidebarRowSelected,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -1089,9 +1101,8 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
// a toast shows: changing it moves the rows when the toast leaves.
paddingBottom: theme.spacing[4],
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
paddingBottom: theme.spacing[24],
},
pinnedSection: {
marginBottom: theme.spacing[1],
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
@@ -12,22 +12,23 @@ const PINNED_POLICY = { kind: "pinned" } as const;
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
selection: ActiveWorkspaceSelection | null;
/**
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
* rows it moved, and a reveal held inside a row would reset with them.
*/
expanded: boolean;
policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
expanded,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
return useMemo(
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
return { ...visibility, expanded, toggleExpanded };
}
@@ -8,6 +8,7 @@ import {
import {
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewProps,
@@ -26,7 +27,6 @@ import {
type MenuTriggerState,
} from "@/components/ui/menu";
import { PressHighlight } from "@/components/ui/press-highlight";
import type { PressHighlightProps } from "@/components/ui/press-highlight.types";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
@@ -109,7 +109,8 @@ export function ContextMenuTrigger({
triggerRef,
...props
}: PropsWithChildren<
Omit<PressHighlightProps, "style"> & {
Omit<PressableProps, "style"> & {
highlightStyle?: StyleProp<ViewStyle>;
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
+3 -25
View File
@@ -1,10 +1,4 @@
import { useMemo } from "react";
import {
Modal as NativeModal,
Platform,
useWindowDimensions,
type ModalProps as NativeModalProps,
} from "react-native";
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
@@ -23,23 +17,7 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
function noClose() {}
export function Modal(props: ModalProps) {
const { visible = true, onRequestClose, style } = props;
const { visible = true, onRequestClose } = props;
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
const windowFrame = useAndroidWindowFrame();
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
}
/**
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
*/
function useAndroidWindowFrame() {
const { width, height } = useWindowDimensions();
return useMemo(
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
[width, height],
);
return <NativeModal supportedOrientations={orientations} {...props} />;
}
@@ -1,87 +0,0 @@
import { forwardRef, useCallback, useMemo } from "react";
import {
Pressable,
type PressableProps,
type PressableStateCallbackType,
type View,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { PressHighlightProps } from "./press-highlight.types";
interface RipplePressableProps extends Omit<PressableProps, "style"> {
rippleColor?: string;
// `withUnistyles` flattens a `style` prop, which would drop the caller's state callback.
pressableStyle: PressableProps["style"];
}
// A foreground ripple tints the content with the surface, so nothing inside needs to know the
// row is pressed.
const RipplePressable = withUnistyles(
forwardRef<View, RipplePressableProps>(function RipplePressable(
{ rippleColor, pressableStyle, ...props },
ref,
) {
const ripple = useMemo(
() => (rippleColor ? { color: rippleColor, foreground: true } : undefined),
[rippleColor],
);
return <Pressable {...props} ref={ref} style={pressableStyle} android_ripple={ripple} />;
}),
);
// Android's own `ViewConfiguration.getTapTimeout()`: a native list in a scroll view waits this long
// before showing pressed, so a scroll that claims the touch first cancels it.
const TAP_TIMEOUT_MS = 100;
const rippleColorMapping = (theme: Theme) => ({ rippleColor: theme.colors.interactionHighlight });
/**
* The platform ripple replaces `highlightStyle`. Pressable starts it from JS after the press
* delay, then the animation runs on the UI thread without a React commit.
*/
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{
highlightStyle,
onHighlightChange: _onHighlightChange,
style,
unstable_pressDelay = TAP_TIMEOUT_MS,
...props
},
ref,
) {
const hasRipple = highlightStyle != null;
const rippleStyle = useCallback(
(state: PressableStateCallbackType) => [
hasRipple && styles.rippleBorderReset,
typeof style === "function" ? style(state) : style,
hasRipple && styles.rippleClip,
],
[style, hasRipple],
);
return (
<RipplePressable
{...props}
ref={ref}
pressableStyle={rippleStyle}
uniProps={hasRipple ? rippleColorMapping : undefined}
unstable_pressDelay={unstable_pressDelay}
/>
);
});
const styles = StyleSheet.create({
// React Native 0.86 sends NaN when a style drops `borderWidth`, and an overflow-hidden view then
// clips to a NaN padding box and stops painting. A sidebar row loses its drag border on drop.
// An explicit 0 under the caller's style sends 0 instead. Fixed upstream in
// facebook/react-native#57789; remove once the app ships a React Native release with it.
rippleBorderReset: {
borderWidth: 0,
},
// React Native draws ripples in the view's rectangle. With overflow hidden, the view clips its
// children to its rounded padding box, and that clip is still on the canvas when Android draws
// the foreground, so the ripple follows the corners.
rippleClip: {
overflow: "hidden",
},
});
@@ -4,8 +4,6 @@ import { ArrowUp } from "lucide-react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { afterEach, expect, it } from "vitest";
import { PressHighlight } from "./press-highlight.native";
import { PressHighlight as AndroidPressHighlight } from "./press-highlight.android";
import { PressHighlight as WebPressHighlight } from "./press-highlight";
import { FloatingActionButton } from "./floating-action-button";
const rowStyle = { backgroundColor: "rgb(255, 255, 255)", borderRadius: 8 };
@@ -15,22 +13,6 @@ afterEach(cleanup);
function onPress() {}
function recordPresses() {
const calls: string[] = [];
return {
calls,
onPressIn: () => calls.push("in"),
onPressOut: () => calls.push("out"),
};
}
const borderedRowStyle = { ...rowStyle, borderWidth: 1, borderColor: "rgb(0, 0, 0)" };
function recordHighlights() {
const calls: boolean[] = [];
return { calls, record: (highlighted: boolean) => calls.push(highlighted) };
}
it("keeps a floating action opaque while showing its held-press feedback", async () => {
const { getByRole } = render(
<FloatingActionButton icon={ArrowUp} accessibilityLabel="Jump to file" onPress={onPress} />,
@@ -49,147 +31,30 @@ it("keeps a floating action opaque while showing its held-press feedback", async
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
});
it("paints the native row fill while pressed and keeps its existing corners", async () => {
const { getByTestId } = render(
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
const { getByTestId, rerender } = render(
<GestureHandlerRootView>
<PressHighlight testID="row" style={rowStyle} highlightStyle={highlightStyle} />
<PressHighlight
testID="row"
testOnly_pressed
style={rowStyle}
highlightStyle={highlightStyle}
/>
</GestureHandlerRootView>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
expect(getComputedStyle(row).borderRadius).toBe("8px");
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
});
it("paints the pressed fill on web and reports it, so a caller does not paint its own", async () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<WebPressHighlight
testID="row"
style={rowStyle}
highlightStyle={highlightStyle}
onHighlightChange={highlights.record}
onPress={onPress}
/>,
rerender(
<GestureHandlerRootView>
<PressHighlight
testID="row"
testOnly_pressed={false}
style={rowStyle}
highlightStyle={highlightStyle}
/>
</GestureHandlerRootView>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
expect(highlights.calls).toEqual([true]);
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
expect(highlights.calls).toEqual([true, false]);
});
it("leaves the Android row surface alone while pressed and clips the ripple to its corners", () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<AndroidPressHighlight
testID="row"
testOnly_pressed
style={rowStyle}
highlightStyle={highlightStyle}
onHighlightChange={highlights.record}
/>,
);
const row = getComputedStyle(getByTestId("row"));
expect(row.backgroundColor).toBe("rgb(255, 255, 255)");
expect(row.borderRadius).toBe("8px");
expect(row.overflow).toBe("hidden");
expect(highlights.calls).toEqual([]);
});
it("keeps the caller's border and press handlers on the Android ripple row", async () => {
const presses = recordPresses();
const { getByTestId } = render(
<AndroidPressHighlight
testID="row"
style={borderedRowStyle}
highlightStyle={highlightStyle}
onPressIn={presses.onPressIn}
onPressOut={presses.onPressOut}
onPress={onPress}
/>,
);
const row = getByTestId("row");
expect(getComputedStyle(row).borderTopWidth).toBe("1px");
fireEvent.mouseDown(row, { button: 0 });
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(presses.calls).toEqual(["in", "out"]));
});
it("paints and reports nothing on web without a pressed fill, such as a row being renamed", () => {
const highlights = recordHighlights();
const { getByTestId } = render(
<WebPressHighlight
testID="row"
style={rowStyle}
onHighlightChange={highlights.record}
onPress={onPress}
/>,
);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
fireEvent.mouseUp(row, { button: 0 });
expect(highlights.calls).toEqual([]);
});
const midPress = { ...recordPresses(), highlights: recordHighlights() };
function PressedRow({ filled }: { filled: boolean }) {
return (
<WebPressHighlight
testID="row"
style={rowStyle}
highlightStyle={filled ? highlightStyle : undefined}
onHighlightChange={midPress.highlights.record}
onPressIn={midPress.onPressIn}
onPressOut={midPress.onPressOut}
onPress={onPress}
/>
);
}
function resetMidPress() {
midPress.calls.length = 0;
midPress.highlights.calls.length = 0;
}
it("keeps a press's fill and report together when the fill appears mid-press", async () => {
resetMidPress();
const { getByTestId, rerender } = render(<PressedRow filled={false} />);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
rerender(<PressedRow filled />);
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
expect(midPress.highlights.calls).toEqual([]);
});
it("keeps a press's fill and report together when the fill goes away mid-press", async () => {
resetMidPress();
const { getByTestId, rerender } = render(<PressedRow filled />);
const row = getByTestId("row");
fireEvent.mouseDown(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
rerender(<PressedRow filled={false} />);
expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)");
fireEvent.mouseUp(row, { button: 0 });
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
expect(midPress.highlights.calls).toEqual([true, false]);
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
});
@@ -1,60 +0,0 @@
import { useCallback, useRef, useState } from "react";
import type {
GestureResponderEvent,
PressableStateCallbackType,
StyleProp,
ViewStyle,
} from "react-native";
import type { PressHighlightProps } from "./press-highlight.types";
type PressFillProps = Pick<
PressHighlightProps,
"style" | "highlightStyle" | "onPressIn" | "onPressOut" | "onHighlightChange"
>;
/**
* Paints `highlightStyle` while pressed and reports it, for the platforms that show a fill. A press
* keeps the fill it started with until it ends, so the paint and the report always agree, even when
* the caller adds or removes `highlightStyle` mid-press.
*/
export function usePressFill({
style,
highlightStyle,
onPressIn,
onPressOut,
onHighlightChange,
}: PressFillProps) {
const [pressFill, setPressFill] = useState<StyleProp<ViewStyle> | null>(null);
// Pressable can call the press-out handler from the render that started the press.
const reportedRef = useRef(false);
const fillStyle = useCallback(
(state: PressableStateCallbackType) => [
typeof style === "function" ? style(state) : style,
pressFill,
],
[style, pressFill],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
if (highlightStyle != null) {
setPressFill(highlightStyle);
reportedRef.current = true;
onHighlightChange?.(true);
}
onPressIn?.(event);
},
[highlightStyle, onHighlightChange, onPressIn],
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
setPressFill(null);
if (reportedRef.current) {
reportedRef.current = false;
onHighlightChange?.(false);
}
onPressOut?.(event);
},
[onHighlightChange, onPressOut],
);
return { style: fillStyle, onPressIn: handlePressIn, onPressOut: handlePressOut };
}
@@ -1,25 +1,21 @@
import { forwardRef } from "react";
import { Pressable, type View } from "react-native";
import { usePressFill } from "./press-highlight.fill";
import { forwardRef, useCallback } from "react";
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
import type { PressHighlightProps } from "./press-highlight.types";
// iOS. Android resolves `press-highlight.android.tsx`.
// Feedback waits this long so a scroll that claims the touch first cancels it.
const PRESS_DELAY_MS = 130;
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{
highlightStyle,
onHighlightChange,
style,
onPressIn,
onPressOut,
unstable_pressDelay = PRESS_DELAY_MS,
...props
},
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
ref,
) {
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable {...props} {...fill} ref={ref} unstable_pressDelay={unstable_pressDelay} />;
// Give the scroll view time to claim a pan before Pressable activates its feedback.
const pressStyle = useCallback(
(state: PressableStateCallbackType) => [
typeof style === "function" ? style(state) : style,
state.pressed && highlightStyle,
],
[style, highlightStyle],
);
return (
<Pressable {...props} ref={ref} unstable_pressDelay={unstable_pressDelay} style={pressStyle} />
);
});
@@ -1,13 +1,11 @@
import { forwardRef } from "react";
import { Pressable } from "react-native";
import type { View } from "react-native";
import { usePressFill } from "./press-highlight.fill";
import type { PressHighlightProps } from "./press-highlight.types";
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
{ highlightStyle, onHighlightChange, style, onPressIn, onPressOut, ...props },
{ highlightStyle: _highlightStyle, ...props },
ref,
) {
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
return <Pressable ref={ref} {...props} {...fill} />;
return <Pressable ref={ref} {...props} />;
});
@@ -1,15 +1,6 @@
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
export interface PressHighlightProps extends PressableProps {
/**
* The pressed fill. iOS and web paint it while the pressable is pressed; Android draws the
* platform ripple over the content instead. Callers do not paint their own pressed fill.
*/
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
highlightStyle?: StyleProp<ViewStyle>;
/**
* Reports when `highlightStyle` starts and stops painting, so content that knocks out of the
* surface (a badge ring, a text scrim) can match it. The Android ripple tints over the content,
* so it never reports a highlight.
*/
onHighlightChange?: (highlighted: boolean) => void;
}
@@ -1,16 +0,0 @@
import { Image } from "react-native";
import type { ViewportSize } from "./geometry";
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
(width, height) => {
if (width > 0 && height > 0) resolve({ width, height });
else reject(new Error("Image has no size"));
},
reject,
);
});
}
@@ -1,24 +0,0 @@
import type { ViewportSize } from "./geometry";
// The size a browser gives a replaced element that has no intrinsic size of its own.
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
/**
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
* width and height, in some browsers), so the image would never show and never fail.
*/
export function measureImage(uri: string): Promise<ViewportSize> {
return new Promise((resolve, reject) => {
const image = new window.Image();
image.addEventListener("load", () =>
resolve(
image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: NO_INTRINSIC_SIZE,
),
);
image.addEventListener("error", () => reject(new Error("Image failed to load")));
image.src = uri;
});
}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { measureImage } from "./image-size";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
@@ -35,33 +34,42 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const source = useMemo(() => ({ uri }), [uri]);
const [paintedUri, setPaintedUri] = useState<string | null>(null);
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
void (async () => {
try {
const size = await measureImage(uri);
if (active) setLoaded({ uri, size });
} catch {
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
if (active) onError?.();
}
})();
},
);
return () => {
active = false;
};
}, [contentSize, onError, uri]);
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
// included, hidden until the image itself has loaded, so they appear together.
if (!resolvedSize) {
return <View style={[styles.root, style]} testID={testID} />;
return (
<View style={[styles.root, style]} testID={testID}>
<Image
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
/>
</View>
);
}
return (
@@ -73,15 +81,12 @@ export function ZoomableImage({
maxScale={maxScale}
minScale={minScale}
onPressOutsideContent={onPressOutsideContent}
style={[style, paintedUri !== uri && styles.loading]}
style={style}
testID={testID}
wheelActivation={wheelActivation}
>
<Image
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
fadeDuration={0}
onError={onError}
onLoad={handleLoad}
resizeMode="contain"
source={source}
style={styles.image}
@@ -94,5 +99,4 @@ export function ZoomableImage({
const styles = StyleSheet.create(() => ({
root: { flex: 1, minHeight: 0 },
image: { width: "100%", height: "100%" },
loading: { opacity: 0 },
}));
@@ -17,7 +17,7 @@ import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import {
buildProviderSelectorProviders,
buildSelectableProviderSelectorProviders,
buildSelectorProvider,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
@@ -1034,7 +1034,7 @@ export const AgentControls = memo(function AgentControls({
);
const agentModelSelectorProviders = useMemo(() => {
if (snapshotSelectedEntry) {
return buildSelectableProviderSelectorProviders([snapshotSelectedEntry]);
return [buildSelectorProvider(snapshotSelectedEntry)];
}
return buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
+18 -8
View File
@@ -1,5 +1,10 @@
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
import { Text, View, type GestureResponderEvent } from "react-native";
import {
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import { DiffStat } from "@/components/diff-stat";
@@ -87,17 +92,18 @@ function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean)
return { isHovered, handlePointerEnter, handlePointerLeave };
}
// Press feedback is the pressable's `highlightStyle`, so it is not part of the active fill.
function fileHeaderIsActive(input: {
isHovered: boolean;
pressed: boolean;
showsBodyState: boolean;
isSelected: boolean;
}): boolean {
return input.isHovered || (!input.showsBodyState && input.isSelected);
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
}
function fileHeaderInteractionStyle(input: {
isHovered: boolean;
pressed: boolean;
showsBodyState: boolean;
isSelected: boolean;
}) {
@@ -211,8 +217,8 @@ export const FileHeader = memo(function FileHeader({
[file.path, onHeaderHeightChange],
),
});
const pressableStyle = useMemo(
() => [
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.header,
!showsBodyState && workspaceTreeRowStyles.row,
showsBodyState && styles.documentHeader,
@@ -223,14 +229,18 @@ export const FileHeader = memo(function FileHeader({
: null,
fileHeaderInteractionStyle({
isHovered: hover.isHovered,
pressed,
showsBodyState,
isSelected,
}),
],
[depth, hover.isHovered, isSelected, showsBodyState],
);
const canvasPressableStyle = useMemo(
() => [pressableStyle, canvasRendered && styles.canvasInteraction],
const canvasPressableStyle = useCallback(
(state: PressableStateCallbackType) => [
pressableStyle(state),
canvasRendered && styles.canvasInteraction,
],
[canvasRendered, pressableStyle],
);
const accessibilityState = useMemo(
@@ -314,7 +324,7 @@ export const FileHeader = memo(function FileHeader({
} else {
trigger = (
<View
style={[pressableStyle, canvasRendered && styles.canvasInteraction]}
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
accessibilityLabel={accessibilityLabel}
>
{renderedContent}
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
@@ -18,6 +18,12 @@ import { useCheckGroupState } from "./check-group-state";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
* timeline off the pane entirely, so past this height the list scrolls in place.
*/
const LIST_MAX_HEIGHT = 268;
/**
* The three statuses the pane has always labelled for tests. Skipped has no id because
* nothing asserts on it.
@@ -106,7 +112,11 @@ export function ChecksSection({
</Pressable>
{open && summary.groups.length > 0 ? (
<View style={styles.list}>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
nestedScrollEnabled
>
{summary.groups.map((group) => (
<CheckGroup
key={group.status}
@@ -118,7 +128,7 @@ export function ChecksSection({
onAddLogsToChat={onAddLogsToChat}
/>
))}
</View>
</ScrollView>
) : null}
</View>
);
@@ -253,6 +263,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
list: {
maxHeight: LIST_MAX_HEIGHT,
},
listContent: {
paddingBottom: theme.spacing[2],
},
groupHeader: {
@@ -1,231 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { ToastApiProvider } from "@/contexts/toast-context";
import type { ToastApi } from "@/components/toast-host";
import type { PrPaneCheck, PrPaneData } from "./data";
import { PullRequestPane } from "./pane";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const PANE_HEIGHT = 600;
const silentToasts: ToastApi = {
show: () => () => {},
copied: () => {},
error: () => {},
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function successfulChecks(count: number): PrPaneCheck[] {
return Array.from({ length: count }, (_, index) => ({
provider: "github",
name: `check-${index + 1}`,
status: "success",
url: `https://example.test/checks/${index + 1}`,
}));
}
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
return {
provider: { id: "github", label: "GitHub" },
forge: "github",
number: 42,
repoOwner: "acme",
repoName: "widgets",
title: "Ship the widget",
state: "open",
url: "https://example.test/pull/42",
reviewDecision: "pending",
awaitingReviewers: [],
checks,
activity: [],
};
}
/** A phone's home indicator: the pane clears it with bottom padding. */
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
function renderPane(data: PrPaneData, insets = NO_INSETS) {
act(() =>
root?.render(
<SafeAreaInsetsContext.Provider value={insets}>
<ToastApiProvider api={silentToasts}>
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
</ToastApiProvider>
</SafeAreaInsetsContext.Provider>,
),
);
}
function mountPane(data: PrPaneData, insets = NO_INSETS) {
container = document.createElement("div");
container.style.height = `${PANE_HEIGHT}px`;
container.style.width = "600px";
container.style.display = "flex";
container.style.flexDirection = "column";
document.body.appendChild(container);
root = createRoot(container);
renderPane(data, insets);
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
if (!pane) throw new Error("The pull request pane did not render");
return pane;
}
/** The pressable row holding the Activity title. */
function activityHeader(pane: HTMLElement): HTMLElement {
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
const header = title.closest<HTMLElement>("[tabindex]");
if (!header) throw new Error("The Activity title is not inside a pressable header");
return header;
}
function checksRegionBottom(pane: HTMLElement): number {
return within(pane).getByRole("separator").getBoundingClientRect().top;
}
/** Whether a user sees the element: no scroll container clips it out of view. */
function isOnScreen(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
return hit !== null && element.contains(hit);
}
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
async function settleLayout() {
for (let frame = 0; frame < 2; frame++) {
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
}
}
/** Drags the separator through each offset from where it starts, releasing at the last one. */
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
const separator = within(pane).getByRole("separator");
const rect = separator.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + rect.height / 2;
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
act(() => {
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
});
for (const deltaY of deltasY) {
act(() => {
window.dispatchEvent(
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
);
});
}
const endY = startY + (deltasY.at(-1) ?? 0);
act(() => {
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
});
}
describe("pull request pane layout", () => {
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
const pane = mountPane(pullRequest(successfulChecks(10)));
act(() => activityHeader(pane).click());
await expect
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
.toBe(Math.round(pane.getBoundingClientRect().bottom));
const rows = within(pane).getAllByTestId("pr-pane-check-row");
expect(rows).toHaveLength(10);
expect(rows.every(isOnScreen)).toBe(true);
});
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
const header = activityHeader(pane);
act(() => header.focus());
act(() => header.click());
expect(document.activeElement).toBe(header);
act(() => header.click());
expect(document.activeElement).toBe(header);
});
it("exposes whether Activity is expanded to assistive technology", () => {
const pane = mountPane(pullRequest(successfulChecks(3)));
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
act(() => activityHeader(pane).click());
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
});
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
dragSeparator(pane, PANE_HEIGHT);
await settleLayout();
act(() => {
if (container) container.style.height = "160px";
});
await settleLayout();
await expect
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
expect(isOnScreen(activityHeader(pane))).toBe(true);
});
it.each([
["5px down", [5]],
["400px down", [400]],
["up past their end and back down", [-20, 100]],
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
const pane = mountPane(pullRequest(successfulChecks(2)));
await settleLayout();
const drawn = checksRegionBottom(pane);
dragSeparator(pane, ...path);
await settleLayout();
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
renderPane(pullRequest(successfulChecks(30)));
await settleLayout();
const overview = within(pane).getByTestId("pr-pane-overview");
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
});
it("resizes the checks against Activity by dragging the separator between them", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)));
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
});
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
await settleLayout();
const before = checksRegionBottom(pane);
dragSeparator(pane, 120);
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
});
});
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
@@ -74,14 +74,13 @@ import {
type PrTimelineEntry,
} from "./timeline";
import {
SectionHeader,
Section,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
import { PullRequestPaneSplit } from "./split";
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleX = withUnistyles(CircleX);
@@ -479,8 +478,8 @@ export function PullRequestPane({
: null;
return (
<PullRequestPaneSplit activityOpen={activityOpen}>
<>
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
@@ -500,9 +499,7 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState", {
brand: forgePresentation.brandLabel,
})
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={toolbarRefreshButtonStyle}
@@ -567,58 +564,60 @@ export function PullRequestPane({
onAddLogsToChat={handleAddCheckLogsToChat}
/>
)}
</>
<SectionHeader
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
/>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
/>
<View style={sectionKitStyles.sectionBody}>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
<View style={styles.divider} />
<Section
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
<>
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
<SummaryPill
count={changesRequested}
icon={SUMMARY_CHANGES_REQUESTED_ICON}
variant="danger"
/>
))
: null}
</View>
</PullRequestPaneSplit>
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
</>
}
>
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
<View style={styles.activityToolbar}>
<Button
variant="ghost"
size="sm"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
: null}
</Section>
</ScrollView>
</View>
);
}
@@ -1193,6 +1192,15 @@ function ThreadComment({
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
scroll: {
flex: 1,
minHeight: 0,
},
invisible: {
opacity: 0,
},
@@ -1253,6 +1261,10 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
opacity: 0,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
@@ -1,9 +1,8 @@
import React, { useMemo, type ReactNode } from "react";
import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -22,57 +21,39 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
interface SectionHeaderProps {
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
accessibilityLabel?: string;
}
interface SectionProps extends SectionHeaderProps {
children: ReactNode;
}
/** The disclosure row of a section, for layouts that place the section's body themselves. */
export function SectionHeader({
export function Section({
title,
open,
onToggle,
summary,
children,
accessibilityLabel,
}: SectionHeaderProps) {
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
const webExpandedProps = useMemo(
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
[open],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? title}
accessibilityState={accessibilityState}
{...webExpandedProps}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
);
}
export function Section({ children, ...header }: SectionProps) {
}: SectionProps) {
return (
<View>
<SectionHeader {...header} />
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
<Pressable
accessibilityLabel={accessibilityLabel}
style={sectionKitStyles.sectionHeader}
onPress={onToggle}
>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
@@ -1,162 +0,0 @@
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ResizeHandle } from "@/components/resize-handle";
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
const DEFAULT_OVERVIEW_SHARE = 0.5;
const SPLIT_GROUP_ID = "pr-pane";
interface PullRequestPaneSplitProps {
/**
* Exactly three children, in order:
* 1. the overview (the PR's header and checks), which scrolls in its own region;
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
* 3. the Activity timeline, filling the space below the separator while open.
*
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
* three unconditionally, the way `TreeRail` takes its slots.
*/
children: ReactNode;
activityOpen: boolean;
}
/**
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
* height, a separator between them resizes the share, and a collapsed Activity gives its space
* back by docking its header to the bottom.
*/
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
const insets = useSafeAreaInsets();
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
// The pointer's share while a drag is in progress. The saved share is decided once, when the
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
const [dragShare, setDragShare] = useState<number | null>(null);
const [splitHeight, setSplitHeight] = useState(0);
const [overviewHeight, setOverviewHeight] = useState(0);
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
setSplitHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
setOverviewHeight(event.nativeEvent.layout.height);
}, []);
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
setOverviewContentHeight(height);
}, []);
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
const [share] = sizes;
if (share !== undefined) setDragShare(share);
}, []);
const handleResize = useCallback(
(_groupId: string, sizes: number[]) => {
setDragShare(null);
const [share] = sizes;
if (share === undefined || splitHeight <= 0) return;
const contentShare = overviewContentHeight / splitHeight;
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
},
[overviewContentHeight, splitHeight],
);
// The overview stops at its content height, so a drag starts from where the separator is
// drawn rather than from the share the overview may grow to.
const separatorSizes = useMemo(() => {
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
return [share, 1 - share];
}, [overviewHeight, splitHeight]);
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
// Changes pane's docked Commits section does. The split is measured inside that padding: the
// overview's percentage height resolves against this box, so the drag must use the same one.
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
const drawnShare = dragShare ?? overviewShare;
const overviewStyle = useMemo(
() =>
activityOpen
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
: styles.overviewFill,
[activityOpen, drawnShare],
);
return (
<View style={rootStyle} testID="pr-pane">
<View style={styles.split} onLayout={handleSplitLayout}>
<ScrollView
style={overviewStyle}
onLayout={handleOverviewLayout}
onContentSizeChange={handleOverviewContentSizeChange}
showsVerticalScrollIndicator={false}
testID="pr-pane-overview"
>
{overview}
</ScrollView>
{activityOpen ? (
<ResizeHandle
testID="pr-pane-split-handle"
direction="vertical"
groupId={SPLIT_GROUP_ID}
index={0}
sizes={separatorSizes}
containerSize={splitHeight}
onPreviewResizeSplit={handlePreviewResize}
onResizeSplit={handleResize}
/>
) : (
<View style={styles.divider} />
)}
{/* The header keeps one slot in both states, so toggling it does not remount the row
and drop keyboard focus. Only the timeline mounts and unmounts. */}
{activityHeader}
{activityOpen ? (
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
{activityTimeline}
</ScrollView>
) : null}
</View>
</View>
);
}
/**
* The share a drag leaves the overview. Short content stops the separator at the content's
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
* at the old content height when the content grows.
*/
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
if (pointerShare < contentShare) return pointerShare;
return Math.max(currentShare, contentShare);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
split: {
flex: 1,
minHeight: 0,
},
overviewShared: {
flexGrow: 0,
flexShrink: 1,
minHeight: 0,
},
overviewFill: {
flex: 1,
minHeight: 0,
},
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
activityScroll: {
flex: 1,
minHeight: 0,
},
}));
@@ -235,8 +235,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableProviderSelectorProviders(snapshotEntries, isConnected),
[snapshotEntries, isConnected],
() =>
buildSelectableProviderSelectorProviders(snapshotEntries, {
isConnected,
selectedProvider: formState.provider || null,
}),
[snapshotEntries, isConnected, formState.provider],
);
const snapshotSelectedEntry = useMemo(
() =>
+38 -1
View File
@@ -1702,7 +1702,7 @@ export const ar: TranslationResources = {
},
providers: {
label: "المزوّدون",
title: "وكلاؤك",
title: "مزوّدوك",
description: "يشغّل Paseo أدوات CLI للوكلاء المثبتة على هذا الكمبيوتر",
},
projects: {
@@ -3047,6 +3047,22 @@ export const ar: TranslationResources = {
providers: {
title: "مقدمي الخدمات",
addProvider: "إضافة مزود",
list: {
add: "مزود",
updatingTo: "جارٍ التحديث إلى {{version}}...",
},
detail: {
backToProviders: "العودة إلى المزودين",
notOnHost: "هذا المزود غير موجود على هذا المضيف",
enabled: "مفعّل",
unavailableTitle: "{{name}} غير متاح",
troubleshooting: "استكشاف الأخطاء",
status: "حالة المزود",
checked: "تم الفحص {{time}}",
diagnosticReport: "تقرير التشخيص",
dangerZone: "منطقة الخطر",
removeHint: "يحذف هذا المزود من config.json",
},
providerDetails: "تفاصيل مزود{{name}}",
enableProvider: "تمكين{{name}}",
unavailable: "اتصل بهذا المضيف لرؤية مقدمي الخدمة",
@@ -3088,10 +3104,31 @@ export const ar: TranslationResources = {
retrying: "جارٍ إعادة المحاولة...",
noSearchMatches: "لا توجد نماذج تطابق بحثك",
noneDetected: "لم يتم اكتشاف أي نماذج",
noCustom: "لا توجد نماذج مخصصة",
discovered: "اكتشف",
custom: "نماذج مخصصة",
updated: "تم تحديث{{time}}",
},
installation: {
title: "التثبيت",
version: "الإصدار",
installed: "مثبّت",
notInstalled: "غير مثبّت",
install: "تثبيت",
checking: "جارٍ التحقق...",
failed: "تعذّر التحقق من التثبيت",
updates: "التحديثات",
upToDate: "محدّث",
updateAvailable: "يتوفر تحديث",
updateInstructions: "تعليمات التحديث",
update: "تحديث",
updating: "جارٍ التحديث...",
updated: "تم التحديث",
updateFailed: "فشل التحديث",
outputTruncated: "تم حذف المخرجات السابقة.",
updatesFailed: "تعذّر التحقق من التحديثات",
installInstructions: "تعليمات التثبيت",
},
diagnostic: {
title: "التشخيص",
button: "التشخيص",
+38 -1
View File
@@ -1728,7 +1728,7 @@ export const en = {
},
providers: {
label: "Providers",
title: "Your agents",
title: "Your providers",
description: "Paseo runs the agent CLIs already on this computer",
},
projects: {
@@ -3142,6 +3142,22 @@ export const en = {
providers: {
title: "Providers",
addProvider: "Add provider",
list: {
add: "Provider",
updatingTo: "Updating to {{version}}...",
},
detail: {
backToProviders: "Back to providers",
notOnHost: "This provider isn't on this host",
enabled: "Enabled",
unavailableTitle: "{{name}} isn't available",
troubleshooting: "Troubleshooting",
status: "Provider status",
checked: "Checked {{time}}",
diagnosticReport: "Diagnostic report",
dangerZone: "Danger zone",
removeHint: "Deletes this provider from config.json",
},
providerDetails: "{{name}} provider details",
enableProvider: "Enable {{name}}",
unavailable: "Connect to this host to see providers",
@@ -3183,10 +3199,31 @@ export const en = {
retrying: "Retrying...",
noSearchMatches: "No models match your search",
noneDetected: "No models detected",
noCustom: "No custom models",
discovered: "Discovered",
custom: "Custom models",
updated: "Updated {{time}}",
},
installation: {
title: "Installation",
version: "Version",
installed: "Installed",
notInstalled: "Not installed",
install: "Install",
checking: "Checking...",
failed: "Couldn't check this installation",
updates: "Updates",
upToDate: "Up to date",
updateAvailable: "Update available",
updateInstructions: "Update instructions",
update: "Update",
updating: "Updating...",
updated: "Updated",
updateFailed: "Update failed",
outputTruncated: "Earlier output was dropped.",
updatesFailed: "Couldn't check for updates",
installInstructions: "Install instructions",
},
diagnostic: {
title: "Diagnostic",
button: "Diagnostic",
+38 -1
View File
@@ -1749,7 +1749,7 @@ export const es: TranslationResources = {
},
providers: {
label: "Proveedores",
title: "Tus agentes",
title: "Tus proveedores",
description: "Paseo usa las CLI de agentes que ya están en este ordenador",
},
projects: {
@@ -3076,6 +3076,22 @@ export const es: TranslationResources = {
providers: {
title: "Proveedores",
addProvider: "Agregar proveedor",
list: {
add: "Proveedor",
updatingTo: "Actualizando a {{version}}...",
},
detail: {
backToProviders: "Volver a proveedores",
notOnHost: "Este proveedor no está en este host",
enabled: "Activado",
unavailableTitle: "{{name}} no está disponible",
troubleshooting: "Solución de problemas",
status: "Estado del proveedor",
checked: "Comprobado {{time}}",
diagnosticReport: "Informe de diagnóstico",
dangerZone: "Zona de peligro",
removeHint: "Elimina este proveedor de config.json",
},
providerDetails: "Detalles del proveedor{{name}}",
enableProvider: "Habilitar{{name}}",
unavailable: "Conéctese a este host para ver proveedores",
@@ -3117,10 +3133,31 @@ export const es: TranslationResources = {
retrying: "Reintentando...",
noSearchMatches: "Ningún modelo coincide con tu búsqueda",
noneDetected: "No se detectaron modelos",
noCustom: "No hay modelos personalizados",
discovered: "descubierto",
custom: "Modelos personalizados",
updated: "{{time}}actualizado",
},
installation: {
title: "Instalación",
version: "Versión",
installed: "Instalado",
notInstalled: "No instalado",
install: "Instalar",
checking: "Comprobando...",
failed: "No se pudo comprobar la instalación",
updates: "Actualizaciones",
upToDate: "Actualizado",
updateAvailable: "Actualización disponible",
updateInstructions: "Instrucciones de actualización",
update: "Actualizar",
updating: "Actualizando...",
updated: "Actualizado",
updateFailed: "No se pudo actualizar",
outputTruncated: "Se descartó la salida anterior.",
updatesFailed: "No se pudo comprobar si hay actualizaciones",
installInstructions: "Instrucciones de instalación",
},
diagnostic: {
title: "Diagnóstico",
button: "Diagnóstico",
+38 -1
View File
@@ -1747,7 +1747,7 @@ export const fr: TranslationResources = {
},
providers: {
label: "Fournisseurs",
title: "Vos agents",
title: "Vos fournisseurs",
description: "Paseo utilise les CLI d'agents déjà installées sur cet ordinateur",
},
projects: {
@@ -3083,6 +3083,22 @@ export const fr: TranslationResources = {
providers: {
title: "Fournisseurs",
addProvider: "Ajouter un fournisseur",
list: {
add: "Fournisseur",
updatingTo: "Mise à jour vers {{version}}…",
},
detail: {
backToProviders: "Retour aux fournisseurs",
notOnHost: "Ce fournisseur n'est pas sur cet hôte",
enabled: "Activé",
unavailableTitle: "{{name}} n'est pas disponible",
troubleshooting: "Dépannage",
status: "État du fournisseur",
checked: "Vérifié {{time}}",
diagnosticReport: "Rapport de diagnostic",
dangerZone: "Zone de danger",
removeHint: "Supprime ce fournisseur de config.json",
},
providerDetails: "Détails du fournisseur {{name}}",
enableProvider: "Activer {{name}}",
unavailable: "Connectez-vous à cet hôte pour voir les fournisseurs",
@@ -3125,10 +3141,31 @@ export const fr: TranslationResources = {
retrying: "Nouvelle tentative…",
noSearchMatches: "Aucun modèle ne correspond à votre recherche",
noneDetected: "Aucun modèle détecté",
noCustom: "Aucun modèle personnalisé",
discovered: "Détectés",
custom: "Modèles personnalisés",
updated: "Mis à jour {{time}}",
},
installation: {
title: "Installation",
version: "Version",
installed: "Installé",
notInstalled: "Non installé",
install: "Installer",
checking: "Vérification...",
failed: "Impossible de vérifier l'installation",
updates: "Mises à jour",
upToDate: "À jour",
updateAvailable: "Mise à jour disponible",
updateInstructions: "Instructions de mise à jour",
update: "Mettre à jour",
updating: "Mise à jour...",
updated: "Mis à jour",
updateFailed: "Échec de la mise à jour",
outputTruncated: "La sortie précédente a été supprimée.",
updatesFailed: "Impossible de vérifier les mises à jour",
installInstructions: "Instructions d'installation",
},
diagnostic: {
title: "Diagnostic",
button: "Diagnostic",
+38 -1
View File
@@ -1720,7 +1720,7 @@ export const ja: TranslationResources = {
},
providers: {
label: "プロバイダー",
title: "エージェント",
title: "プロバイダー",
description: "Paseo はこのコンピューターにインストール済みのエージェント CLI を使います",
},
projects: {
@@ -3044,6 +3044,22 @@ export const ja: TranslationResources = {
providers: {
title: "プロバイダー",
addProvider: "プロバイダーを追加",
list: {
add: "プロバイダー",
updatingTo: "{{version}} に更新中...",
},
detail: {
backToProviders: "プロバイダーに戻る",
notOnHost: "このホストにはこのプロバイダーがありません",
enabled: "有効",
unavailableTitle: "{{name}} は利用できません",
troubleshooting: "トラブルシューティング",
status: "プロバイダーの状態",
checked: "{{time}}に確認",
diagnosticReport: "診断レポート",
dangerZone: "危険な操作",
removeHint: "このプロバイダーを config.json から削除します",
},
providerDetails: "{{name}}プロバイダーの詳細",
enableProvider: "{{name}}を有効にする",
unavailable: "プロバイダーを見るにはこのホストに接続してください",
@@ -3085,10 +3101,31 @@ export const ja: TranslationResources = {
retrying: "再試行中...",
noSearchMatches: "検索に一致するモデルがありません",
noneDetected: "モデルが検出されませんでした",
noCustom: "カスタムモデルはありません",
discovered: "検出済み",
custom: "カスタムモデル",
updated: "{{time}}に更新",
},
installation: {
title: "インストール",
version: "バージョン",
installed: "インストール済み",
notInstalled: "未インストール",
install: "インストール",
checking: "確認中...",
failed: "インストールを確認できませんでした",
updates: "アップデート",
upToDate: "最新です",
updateAvailable: "アップデートがあります",
updateInstructions: "アップデート手順",
update: "アップデート",
updating: "アップデート中...",
updated: "アップデート済み",
updateFailed: "アップデートに失敗しました",
outputTruncated: "以前の出力は破棄されました。",
updatesFailed: "アップデートを確認できませんでした",
installInstructions: "インストール手順",
},
diagnostic: {
title: "診断",
button: "診断",
+38 -1
View File
@@ -1713,7 +1713,7 @@ export const ko: TranslationResources = {
},
providers: {
label: "제공자",
title: "에이전트",
title: "제공자",
description: "Paseo는 이 컴퓨터에 이미 설치된 에이전트 CLI를 사용합니다",
},
projects: {
@@ -3029,6 +3029,22 @@ export const ko: TranslationResources = {
providers: {
title: "프로바이더",
addProvider: "프로바이더 추가",
list: {
add: "프로바이더",
updatingTo: "{{version}}(으)로 업데이트 중...",
},
detail: {
backToProviders: "프로바이더로 돌아가기",
notOnHost: "이 호스트에 없는 프로바이더입니다",
enabled: "사용",
unavailableTitle: "{{name}}을(를) 사용할 수 없습니다",
troubleshooting: "문제 해결",
status: "프로바이더 상태",
checked: "{{time}} 확인됨",
diagnosticReport: "진단 보고서",
dangerZone: "위험 구역",
removeHint: "config.json에서 이 프로바이더를 삭제합니다",
},
providerDetails: "{{name}} 프로바이더 세부 정보",
enableProvider: "{{name}} 활성화",
unavailable: "프로바이더를 보려면 이 호스트에 연결하세요",
@@ -3071,10 +3087,31 @@ export const ko: TranslationResources = {
retrying: "다시 시도 중...",
noSearchMatches: "검색과 일치하는 모델이 없습니다",
noneDetected: "감지된 모델이 없습니다",
noCustom: "사용자 지정 모델 없음",
discovered: "발견됨",
custom: "사용자 지정 모델",
updated: "{{time}} 업데이트됨",
},
installation: {
title: "설치",
version: "버전",
installed: "설치됨",
notInstalled: "설치되지 않음",
install: "설치",
checking: "확인 중...",
failed: "설치를 확인할 수 없습니다",
updates: "업데이트",
upToDate: "최신 상태",
updateAvailable: "업데이트 가능",
updateInstructions: "업데이트 안내",
update: "업데이트",
updating: "업데이트 중...",
updated: "업데이트됨",
updateFailed: "업데이트 실패",
outputTruncated: "이전 출력이 삭제되었습니다.",
updatesFailed: "업데이트를 확인할 수 없습니다",
installInstructions: "설치 안내",
},
diagnostic: {
title: "진단",
button: "진단",
+38 -1
View File
@@ -1734,7 +1734,7 @@ export const ptBR: TranslationResources = {
},
providers: {
label: "Provedores",
title: "Seus agentes",
title: "Seus provedores",
description: "O Paseo usa as CLIs de agentes já instaladas neste computador",
},
projects: {
@@ -3057,6 +3057,22 @@ export const ptBR: TranslationResources = {
providers: {
title: "Provedores",
addProvider: "Adicionar provedor",
list: {
add: "Provedor",
updatingTo: "Atualizando para {{version}}...",
},
detail: {
backToProviders: "Voltar para provedores",
notOnHost: "Este provedor não está neste host",
enabled: "Ativado",
unavailableTitle: "{{name}} não está disponível",
troubleshooting: "Solução de problemas",
status: "Status do provedor",
checked: "Verificado {{time}}",
diagnosticReport: "Relatório de diagnóstico",
dangerZone: "Zona de perigo",
removeHint: "Exclui este provedor do config.json",
},
providerDetails: "Detalhes do provedor {{name}}",
enableProvider: "Ativar {{name}}",
unavailable: "Conecte-se a este host para ver provedores",
@@ -3098,10 +3114,31 @@ export const ptBR: TranslationResources = {
retrying: "Tentando novamente...",
noSearchMatches: "Nenhum modelo corresponde à sua busca",
noneDetected: "Nenhum modelo detectado",
noCustom: "Nenhum modelo personalizado",
discovered: "Descobertos",
custom: "Modelos personalizados",
updated: "Atualizado {{time}}",
},
installation: {
title: "Instalação",
version: "Versão",
installed: "Instalado",
notInstalled: "Não instalado",
install: "Instalar",
checking: "Verificando...",
failed: "Não foi possível verificar a instalação",
updates: "Atualizações",
upToDate: "Atualizado",
updateAvailable: "Atualização disponível",
updateInstructions: "Instruções de atualização",
update: "Atualizar",
updating: "Atualizando...",
updated: "Atualizado",
updateFailed: "Falha na atualização",
outputTruncated: "A saída anterior foi descartada.",
updatesFailed: "Não foi possível verificar atualizações",
installInstructions: "Instruções de instalação",
},
diagnostic: {
title: "Diagnóstico",
button: "Diagnóstico",
+38 -1
View File
@@ -1732,7 +1732,7 @@ export const ru: TranslationResources = {
},
providers: {
label: "Провайдеры",
title: "Ваши агенты",
title: "Ваши провайдеры",
description: "Paseo использует CLI агентов, уже установленные на этом компьютере",
},
projects: {
@@ -3067,6 +3067,22 @@ export const ru: TranslationResources = {
providers: {
title: "Провайдеры",
addProvider: "Добавить провайдера",
list: {
add: "Провайдер",
updatingTo: "Обновление до {{version}}...",
},
detail: {
backToProviders: "Назад к провайдерам",
notOnHost: "Этого провайдера нет на этом хосте",
enabled: "Включён",
unavailableTitle: "{{name}} недоступен",
troubleshooting: "Диагностика",
status: "Состояние провайдера",
checked: "Проверено {{time}}",
diagnosticReport: "Диагностический отчёт",
dangerZone: "Опасная зона",
removeHint: "Удаляет этого провайдера из config.json",
},
providerDetails: "Сведения о провайдере {{name}}",
enableProvider: "Включить {{name}}",
unavailable: "Подключитесь к этому хосту, чтобы увидеть провайдеров",
@@ -3109,10 +3125,31 @@ export const ru: TranslationResources = {
retrying: "Повторная попытка...",
noSearchMatches: "Ни одна модель не соответствует вашему запросу",
noneDetected: "Модели не обнаружены",
noCustom: "Нет пользовательских моделей",
discovered: "Обнаруженные модели",
custom: "Пользовательские модели",
updated: "Обновлено {{time}}",
},
installation: {
title: "Установка",
version: "Версия",
installed: "Установлено",
notInstalled: "Не установлено",
install: "Установить",
checking: "Проверка...",
failed: "Не удалось проверить установку",
updates: "Обновления",
upToDate: "Актуальная версия",
updateAvailable: "Доступно обновление",
updateInstructions: "Инструкции по обновлению",
update: "Обновить",
updating: "Обновление...",
updated: "Обновлено",
updateFailed: "Не удалось обновить",
outputTruncated: "Более ранний вывод был отброшен.",
updatesFailed: "Не удалось проверить обновления",
installInstructions: "Инструкции по установке",
},
diagnostic: {
title: "Диагностика",
button: "Диагностика",
+38 -1
View File
@@ -1685,7 +1685,7 @@ export const zhCN: TranslationResources = {
},
providers: {
label: "提供方",
title: "你的代理",
title: "你的提供方",
description: "Paseo 使用这台电脑上已安装的代理 CLI",
},
projects: {
@@ -2976,6 +2976,22 @@ export const zhCN: TranslationResources = {
providers: {
title: "Providers",
addProvider: "添加 Provider",
list: {
add: "Provider",
updatingTo: "正在更新到 {{version}}...",
},
detail: {
backToProviders: "返回 Provider 列表",
notOnHost: "此 Host 上没有这个 Provider",
enabled: "已启用",
unavailableTitle: "{{name}} 不可用",
troubleshooting: "故障排查",
status: "Provider 状态",
checked: "{{time}}检查",
diagnosticReport: "诊断报告",
dangerZone: "危险操作",
removeHint: "从 config.json 中删除此 Provider",
},
providerDetails: "{{name}} Provider 详情",
enableProvider: "启用 {{name}}",
unavailable: "连接到这个 Host 以查看 Providers",
@@ -3017,10 +3033,31 @@ export const zhCN: TranslationResources = {
retrying: "正在重试...",
noSearchMatches: "没有匹配搜索的 Model",
noneDetected: "未检测到 Model",
noCustom: "没有自定义模型",
discovered: "已发现",
custom: "自定义 Models",
updated: "已更新 {{time}}",
},
installation: {
title: "安装",
version: "版本",
installed: "已安装",
notInstalled: "未安装",
install: "安装",
checking: "正在检查...",
failed: "无法检查安装状态",
updates: "更新",
upToDate: "已是最新",
updateAvailable: "有可用更新",
updateInstructions: "更新说明",
update: "更新",
updating: "正在更新...",
updated: "已更新",
updateFailed: "更新失败",
outputTruncated: "较早的输出已被丢弃。",
updatesFailed: "无法检查更新",
installInstructions: "安装说明",
},
diagnostic: {
title: "诊断",
button: "诊断",
@@ -7,6 +7,7 @@ import {
buildOpenProjectRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
buildProviderSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
type HostSectionSlug,
@@ -39,7 +40,8 @@ export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "project"; serverId: string; projectId: string };
| { kind: "project"; serverId: string; projectId: string }
| { kind: "provider"; serverId: string; providerId: string };
export function openHostOverview(serverId: string): void {
router.push(buildSettingsHostSectionRoute(serverId, "host"));
@@ -49,6 +51,10 @@ export function openProjectSettings(serverId: string, projectId: string): void {
router.push(buildProjectSettingsRoute(serverId, projectId));
}
export function openProviderSettings(serverId: string, providerId: string): void {
router.push(buildProviderSettingsRoute(serverId, providerId));
}
export function returnFromSettings(view: SettingsView): void {
if (view.kind === "root") {
const destination = getSettingsSidebarReturn();
@@ -69,5 +75,6 @@ export function returnFromSettings(view: SettingsView): void {
let parent: Href = buildSettingsRoute();
if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins");
if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId);
if (view.kind === "provider") parent = buildSettingsHostSectionRoute(view.serverId, "providers");
router.dismissTo(parent as Href);
}
@@ -3,11 +3,7 @@ import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import {
AdaptiveModalSheet,
FULL_SHEET_SNAP_POINTS,
SheetFooter,
} from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, SheetFooter } from "@/components/adaptive-modal-sheet";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { PressHighlight } from "@/components/ui/press-highlight";
@@ -17,6 +13,7 @@ import { useProjectSuggestions, type ProjectSuggestionsController } from "@/proj
import type { Theme } from "@/styles/theme";
import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model";
import { useOrchestrationSetup, type OrchestrationSetupController } from "./orchestration-setup";
import { SETUP_CARD_FRAME } from "./setup-card";
import { OnboardingStepContent } from "./steps";
type OpenState = Extract<OnboardingState, { status: "open" }>;
@@ -112,9 +109,9 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
header={isCompact ? compactHeader : undefined}
footer={isCompact ? actions : undefined}
scrollable={false}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={880}
desktopHeight={640}
snapPoints={SETUP_CARD_FRAME.snapPoints}
desktopMaxWidth={SETUP_CARD_FRAME.desktopMaxWidth}
desktopHeight={SETUP_CARD_FRAME.desktopHeight}
contentStyle={styles.content}
testID="onboarding-modal"
>
@@ -0,0 +1,8 @@
import { FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet";
/** The setup card's frame. A sheet opened from a setup step takes the same one to cover the card. */
export const SETUP_CARD_FRAME = {
snapPoints: FULL_SHEET_SNAP_POINTS,
desktopMaxWidth: 880,
desktopHeight: 640,
};
@@ -1,10 +1,44 @@
import { useCallback, useState } from "react";
import { View } from "react-native";
import { ProvidersSection } from "@/screens/settings/providers-section";
import { ProviderDetailSheet, ProviderList, type ProviderSheetFrame } from "@/provider-settings";
import { SETUP_CARD_FRAME } from "../setup-card";
/** Provider sheets cover the setup card and return to it, so they read as pages of setup. */
const SETUP_SHEET_FRAME: ProviderSheetFrame = { ...SETUP_CARD_FRAME, returns: true };
interface OpenedProvider {
provider: string;
visible: boolean;
}
export function ProvidersStep({ serverId }: { serverId: string }) {
// The provider outlives `visible` so the sheet can animate out.
const [opened, setOpened] = useState<OpenedProvider | null>(null);
const openProvider = useCallback(
(provider: string) => setOpened({ provider, visible: true }),
[],
);
const closeProvider = useCallback(
() => setOpened((current) => (current ? { ...current, visible: false } : null)),
[],
);
return (
<View>
<ProvidersSection serverId={serverId} />
<ProviderList
serverId={serverId}
onOpenProvider={openProvider}
sheetFrame={SETUP_SHEET_FRAME}
/>
{opened ? (
<ProviderDetailSheet
key={opened.provider}
serverId={serverId}
provider={opened.provider}
visible={opened.visible}
onClose={closeProvider}
frame={SETUP_SHEET_FRAME}
/>
) : null}
</View>
);
}
+9 -10
View File
@@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft, Settings } from "lucide-react-native";
import { Settings } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { SettingsBackLink } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
@@ -181,14 +182,12 @@ export function PluginSettingsContent({
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
<View style={styles.backLink}>
<SettingsBackLink
label={t("settings.plugins.screens.backToPlugins")}
onPress={onBackToPlugins}
/>
</View>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
@@ -197,5 +196,5 @@ export function PluginSettingsContent({
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
backLink: { marginBottom: theme.spacing[4] },
}));
@@ -7,6 +7,7 @@ import {
buildProviderSelectorProviders,
buildSelectableProviderSelectorProviders,
buildSelectedTriggerLabel,
buildSelectorProvider,
filterAndRankModelRows,
matchesModelSearch,
resolveEffectiveComposerModeId,
@@ -21,7 +22,7 @@ describe("combined model selector data", () => {
snapshotEntry({ provider: "waiting", status: "loading", models: [] }),
snapshotEntry({ provider: "codex", status: "ready", models: [codexModel] }),
],
false,
{ isConnected: false, selectedProvider: null },
);
expect(providers[0].modelSelection).toEqual({
kind: "error",
@@ -54,13 +55,16 @@ describe("combined model selector data", () => {
it("builds selector providers from ready enabled snapshot entries", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
]),
buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
],
{ selectedProvider: null },
),
).toEqual([
{
id: "codex",
@@ -91,9 +95,10 @@ describe("combined model selector data", () => {
isSelectable: false,
};
const [provider] = buildSelectableProviderSelectorProviders([
snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] }),
]);
const [provider] = buildSelectableProviderSelectorProviders(
[snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] })],
{ selectedProvider: null },
);
expect(provider?.modelSelection).toMatchObject({
kind: "models",
@@ -103,13 +108,16 @@ describe("combined model selector data", () => {
it("synthesizes a default model row for ready enabled providers without explicit models", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
models: [],
}),
]),
buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
models: [],
}),
],
{ selectedProvider: null },
),
).toEqual([
{
id: "codewhale",
@@ -134,33 +142,34 @@ describe("combined model selector data", () => {
it("excludes disabled providers from selector data", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
enabled: false,
models: [],
}),
]),
buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
enabled: false,
models: [],
}),
],
{ selectedProvider: null },
),
).toEqual([]);
});
it("surfaces non-ready providers with their state-specific selection", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
snapshotEntry({
provider: "error-provider",
status: "error",
error: "boom",
models: [],
}),
snapshotEntry({
provider: "unavailable-provider",
status: "unavailable",
models: [],
}),
]),
buildSelectableProviderSelectorProviders(
[
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
snapshotEntry({
provider: "error-provider",
status: "error",
error: "boom",
models: [],
}),
],
{ selectedProvider: null },
),
).toEqual([
{
id: "loading-provider",
@@ -172,14 +181,45 @@ describe("combined model selector data", () => {
label: "error-provider",
modelSelection: { kind: "error", message: "boom" },
},
{
id: "unavailable-provider",
label: "unavailable-provider",
modelSelection: { kind: "error", message: "Unavailable" },
},
]);
});
it("leaves out enabled providers that are not installed", () => {
expect(
buildSelectableProviderSelectorProviders(
[
snapshotEntry({ provider: "codex", label: "Codex" }),
snapshotEntry({ provider: "gemini", status: "unavailable", models: [] }),
],
{ selectedProvider: null },
).map((provider) => provider.id),
).toEqual(["codex"]);
});
it("keeps a saved choice whose provider is not installed, labelled Unavailable", () => {
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({ provider: "codex", label: "Codex" }),
snapshotEntry({ provider: "gemini", status: "unavailable", models: [] }),
],
{ selectedProvider: "gemini" },
);
expect(providers.map((provider) => provider.id)).toEqual(["codex", "gemini"]);
expect(providers[1].modelSelection).toEqual({ kind: "error", message: "Unavailable" });
});
it("keeps an agent's own provider in its selector when that provider is not installed", () => {
expect(
buildSelectorProvider(
snapshotEntry({ provider: "gemini", status: "unavailable", models: [] }),
),
).toEqual({
id: "gemini",
label: "gemini",
modelSelection: { kind: "error", message: "Unavailable" },
});
});
it("builds selector providers from an already-curated provider list", () => {
const providerDefinitions: AgentProviderDefinition[] = [
{
@@ -278,18 +318,21 @@ describe("combined model selector data", () => {
});
it("resolves selected labels from explicit provider model-selection state", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
models: [],
}),
]);
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
snapshotEntry({
provider: "codewhale",
label: "CodeWhale",
models: [],
}),
],
{ selectedProvider: null },
);
expect(
resolveSelectedModelLabel({
@@ -329,13 +372,16 @@ describe("combined model selector data", () => {
});
it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
}),
]);
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "codex",
label: "Codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
}),
],
{ selectedProvider: null },
);
expect(
resolveSelectedModelLabel({
@@ -348,15 +394,18 @@ describe("combined model selector data", () => {
});
it("keeps provider snapshot errors visible in the selected trigger label", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "opencode",
label: "OpenCode",
status: "error",
error: "OpenCode app.agents timed out after 10s",
models: [],
}),
]);
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "opencode",
label: "OpenCode",
status: "error",
error: "OpenCode app.agents timed out after 10s",
models: [],
}),
],
{ selectedProvider: null },
);
expect(
resolveSelectedModelLabel({
@@ -410,21 +459,27 @@ describe("combined model selector data", () => {
it("uses the active app language for utility labels", async () => {
await i18n.changeLanguage("zh-CN");
try {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
snapshotEntry({
provider: "unavailable-provider",
status: "unavailable",
models: [],
}),
]);
const providers = buildSelectableProviderSelectorProviders(
[
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
],
{ selectedProvider: null },
);
expect(getAllModelLabels(providers)).toContain("默认");
expect(providers[1]?.modelSelection).toEqual({
expect(
buildSelectorProvider(
snapshotEntry({
provider: "unavailable-provider",
status: "unavailable",
models: [],
}),
).modelSelection,
).toEqual({
kind: "error",
message: "不可用",
});
@@ -143,20 +143,35 @@ export function buildProviderSelectorProviders(input: {
}));
}
/** One provider in a selector, whatever its state. */
export function buildSelectorProvider(
entry: ProviderSnapshotEntry,
isConnected = true,
): ProviderSelectorProvider {
const label = entry.label ?? entry.provider;
return {
id: entry.provider,
label,
modelSelection: buildEntryModelSelection(entry, label, isConnected),
};
}
/**
* The providers a user can pick a model from: enabled, and installed or still being probed. The
* provider of the picker's current choice stays whatever its state, so the selector still names
* it. Every picker says what that choice is, or `null` for none, so none can forget it.
*/
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
isConnected = true,
options: { isConnected?: boolean; selectedProvider: string | null },
): ProviderSelectorProvider[] {
const { isConnected = true, selectedProvider } = options;
return (entries ?? [])
.filter((entry) => entry.enabled)
.map((entry) => {
const label = entry.label ?? entry.provider;
return {
id: entry.provider,
label,
modelSelection: buildEntryModelSelection(entry, label, isConnected),
};
});
.filter(
(entry) =>
entry.provider === selectedProvider || (entry.enabled && entry.status !== "unavailable"),
)
.map((entry) => buildSelectorProvider(entry, isConnected));
}
export function getProviderModelRows(
@@ -0,0 +1,4 @@
export { ProviderList } from "./internal/provider-list";
export { ProviderDetailPage } from "./internal/provider-detail-page";
export { ProviderDetailSheet } from "./internal/provider-detail-sheet";
export type { ProviderSheetFrame } from "./internal/sheet-frame";
@@ -0,0 +1,187 @@
import { PackagePlus } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { rankProviderCatalog } from "@/components/provider-catalog-search";
import { SettingsCard, SettingsRow } from "@/components/settings";
import { Button } from "@/components/ui/button";
import {
useAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useProviderConfigActions } from "./provider-config";
import { OVERLAY_SHEET_FRAME, type ProviderSheetFrame } from "./sheet-frame";
const ThemedPackagePlus = withUnistyles(PackagePlus);
const ThemedSvgXml = withUnistyles(SvgXml);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export interface AddProviderSheetProps {
serverId: string;
visible: boolean;
onClose: () => void;
/** The provider is in config and listed; open its detail. */
onAdded: (providerId: string) => void;
frame?: ProviderSheetFrame;
}
/** The catalog of providers not added yet, searchable, each with Add. */
export function AddProviderSheet({
serverId,
visible,
onClose,
onAdded,
frame = OVERLAY_SHEET_FRAME,
}: AddProviderSheetProps) {
const { t } = useTranslation();
const { entries: catalog } = useAcpProviderCatalog();
const { entries: providers } = useProvidersSnapshot(serverId);
const { add, addingProviderId } = useProviderConfigActions(serverId);
const [search, setSearch] = useState("");
// Each opening starts from an empty search; the field clears when this changes.
const [session, setSession] = useState(0);
// Read when an add finishes, to tell whether the sheet it started in is still open.
const sessionRef = useRef(0);
const endSession = useCallback(() => {
setSearch("");
sessionRef.current += 1;
setSession(sessionRef.current);
}, []);
const handleClose = useCallback(() => {
endSession();
onClose();
}, [endSession, onClose]);
const available = useMemo(() => {
const added = new Set(providers?.map((entry) => entry.provider) ?? []);
return rankProviderCatalog(
catalog.filter((entry) => !added.has(entry.id)),
search,
);
}, [catalog, providers, search]);
const handleAdd = useCallback(
(entry: AcpProviderCatalogItem) => {
const startedIn = sessionRef.current;
void add(entry).then((added) => {
// Closed before the add finished: the provider is added, but the user has moved on.
if (!added || sessionRef.current !== startedIn) return added;
endSession();
onAdded(entry.id);
return added;
});
},
[add, endSession, onAdded],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.addProvider"),
back: frame.returns
? {
onPress: handleClose,
accessibilityLabel: t("settings.providers.detail.backToProviders"),
}
: undefined,
search: {
onChange: setSearch,
placeholder: t("providerCatalog.search"),
resetKey: session,
testID: "provider-catalog-search",
},
}),
[frame.returns, handleClose, session, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
showCloseButton={!frame.returns}
desktopMaxWidth={frame.desktopMaxWidth}
desktopHeight={frame.desktopHeight}
snapPoints={frame.snapPoints}
testID="add-provider-sheet"
>
<SettingsCard>
{available.length === 0 ? (
<View style={settingsStyles.row}>
<Text style={styles.empty}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
available.map((entry) => (
<CatalogRow
key={entry.id}
entry={entry}
adding={addingProviderId === entry.id}
disabled={addingProviderId !== null}
onAdd={handleAdd}
/>
))
)}
</SettingsCard>
</AdaptiveModalSheet>
);
}
function CatalogRow({
entry,
adding,
disabled,
onAdd,
}: {
entry: AcpProviderCatalogItem;
adding: boolean;
disabled: boolean;
onAdd: (entry: AcpProviderCatalogItem) => void;
}) {
const { t } = useTranslation();
const handleAdd = useCallback(() => onAdd(entry), [entry, onAdd]);
const icon = useMemo(
() =>
entry.iconSvg ? (
<ThemedSvgXml
xml={entry.iconSvg}
width={ICON_SIZE.md}
height={ICON_SIZE.md}
uniProps={foregroundColorMapping}
/>
) : (
<ThemedPackagePlus size={ICON_SIZE.md} uniProps={foregroundColorMapping} />
),
[entry.iconSvg],
);
return (
<SettingsRow
leading={icon}
label={entry.title}
hint={entry.description}
testID={`provider-catalog-row-${entry.id}`}
>
<Button
variant="outline"
onPress={handleAdd}
loading={adding}
disabled={disabled}
accessibilityLabel={t("providerCatalog.actions.addProvider", { provider: entry.title })}
testID={`install-provider-${entry.id}`}
>
{adding ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add")}
</Button>
</SettingsRow>
);
}
const styles = StyleSheet.create((theme) => ({
empty: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { ProviderInstallationState } from "@/components/provider-installation-section";
import { readDetailPresence, readDetailSections } from "./detail-sections";
function entry(overrides: Partial<ProviderSnapshotEntry> = {}): ProviderSnapshotEntry {
return {
provider: "opencode",
label: "OpenCode",
status: "ready",
enabled: true,
modes: [],
models: [],
...overrides,
};
}
const unsupported: ProviderInstallationState = { status: "unsupported" };
const notInstalled: ProviderInstallationState = {
status: "ready",
installation: { inspection: { status: "not_installed" }, canUpdate: false },
};
describe("provider detail sections", () => {
it("shows models for a provider that runs", () => {
expect(readDetailSections(entry(), unsupported)).toEqual({ error: null, models: true });
});
it("keeps models while the provider is probed again", () => {
expect(readDetailSections(entry({ status: "loading" }), unsupported).models).toBe(true);
});
it("leads with the error and hides models when the provider fails", () => {
expect(
readDetailSections(
entry({ status: "error", error: "opencode exited with code 1" }),
unsupported,
),
).toEqual({ error: "opencode exited with code 1", models: false });
});
it("says nothing extra when Installation already says Not installed", () => {
expect(readDetailSections(entry({ status: "unavailable" }), unsupported)).toEqual({
error: null,
models: false,
});
expect(
readDetailSections(entry({ status: "error", error: "spawn ENOENT" }), notInstalled),
).toEqual({ error: null, models: false });
});
it("hides models and the error for a disabled provider", () => {
expect(
readDetailSections(entry({ enabled: false, status: "error", error: "boom" }), unsupported),
).toEqual({ error: null, models: false });
});
it("gives an error with no message an empty one, so the alert still shows", () => {
expect(readDetailSections(entry({ status: "error" }), unsupported).error).toBe("");
});
});
describe("provider detail presence", () => {
const loaded = [entry(), entry({ provider: "codex", label: "Codex" })];
it("finds the provider among the host's providers", () => {
expect(
readDetailPresence({
provider: "codex",
connected: true,
entries: loaded,
error: null,
removing: null,
}),
).toEqual({ kind: "found", entry: loaded[1] });
});
it("is loading until the host's providers arrive", () => {
expect(
readDetailPresence({
provider: "codex",
connected: true,
entries: undefined,
error: null,
removing: null,
}),
).toEqual({ kind: "loading" });
});
it("says the provider is missing once the host's providers have arrived without it", () => {
expect(
readDetailPresence({
provider: "gone",
connected: true,
entries: loaded,
error: null,
removing: null,
}),
).toEqual({ kind: "missing" });
});
it("keeps showing a provider this page is removing once it leaves the list", () => {
const gone = entry({ provider: "gone", label: "Gone" });
expect(
readDetailPresence({
provider: "gone",
connected: true,
entries: loaded,
error: null,
removing: gone,
}),
).toEqual({ kind: "found", entry: gone });
});
it("says the host is offline rather than loading forever", () => {
expect(
readDetailPresence({
provider: "codex",
connected: false,
entries: undefined,
error: null,
removing: null,
}),
).toEqual({ kind: "offline" });
});
it("shows why the host's providers could not be read", () => {
expect(
readDetailPresence({
provider: "codex",
connected: true,
entries: undefined,
error: "Request timed out",
removing: null,
}),
).toEqual({ kind: "failed", message: "Request timed out" });
});
});
@@ -0,0 +1,55 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { ProviderInstallationState } from "@/components/provider-installation-section";
export interface DetailSections {
/** The failure the page leads with, or null. Empty when the provider gave no message. */
error: string | null;
/** Models are listed only while the provider runs. */
models: boolean;
}
/** Which of the detail's state-dependent parts show, so each fact is said once. */
export function readDetailSections(
entry: ProviderSnapshotEntry,
installation: ProviderInstallationState,
): DetailSections {
const notInstalled =
entry.status === "unavailable" ||
(installation.status === "ready" &&
installation.installation?.inspection?.status === "not_installed");
const failing = entry.enabled && entry.status === "error" && !notInstalled;
return {
error: failing ? (entry.error?.trim() ?? "") : null,
models: entry.enabled && (entry.status === "ready" || entry.status === "loading"),
};
}
export type DetailPresence =
| { kind: "found"; entry: ProviderSnapshotEntry }
| { kind: "loading" }
| { kind: "missing" }
| { kind: "offline" }
| { kind: "failed"; message: string };
/**
* Whether the page has a provider to show. A page opened from its URL renders before the host's
* providers arrive, and its id may name a provider the host no longer has. A provider this page is
* removing leaves the list before the removal answers; the page keeps showing it until it closes.
*/
export function readDetailPresence(input: {
provider: string;
connected: boolean;
entries: ProviderSnapshotEntry[] | undefined;
error: string | null;
/** The provider this page's own Remove is deleting, as it was when the user confirmed. */
removing: ProviderSnapshotEntry | null;
}): DetailPresence {
const entry =
input.entries?.find((candidate) => candidate.provider === input.provider) ??
(input.removing?.provider === input.provider ? input.removing : undefined);
if (entry) return { kind: "found", entry };
if (!input.connected) return { kind: "offline" };
if (input.entries) return { kind: "missing" };
if (input.error) return { kind: "failed", message: input.error };
return { kind: "loading" };
}
@@ -0,0 +1,153 @@
import * as Clipboard from "expo-clipboard";
import { Copy, RotateCw } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
SheetHeaderIconButton,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
/** The provider's diagnostic report, run on open, with copy and run again. */
export function DiagnosticSheet({
serverId,
provider,
visible,
onClose,
}: {
serverId: string;
provider: string;
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchDiagnostic = useCallback(async () => {
if (!client) return;
setLoading(true);
try {
const result = await client.getProviderDiagnostic(provider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
);
} finally {
setLoading(false);
}
}, [client, provider, t]);
useEffect(() => {
if (visible) {
void fetchDiagnostic();
} else {
setDiagnostic(null);
}
}, [visible, fetchDiagnostic]);
const handleRefresh = useCallback(() => {
void fetchDiagnostic();
}, [fetchDiagnostic]);
const handleCopy = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
}, [diagnostic, t, toast]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<View style={styles.headerActions}>
<SheetHeaderIconButton
icon={Copy}
onPress={handleCopy}
disabled={!diagnostic}
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
/>
<SheetHeaderIconButton
icon={RotateCw}
onPress={handleRefresh}
loading={loading}
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
/>
</View>
),
}),
[diagnostic, handleCopy, handleRefresh, loading, t],
);
let body: React.ReactNode;
if (loading && !diagnostic) {
body = (
<SurfaceCard>
<View style={styles.placeholder}>
<ThemedLoadingSpinner size="small" uniProps={mutedColorMapping} />
<Text style={styles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
);
} else if (diagnostic) {
body = <ScrollableCodeSurface maxHeight={480}>{diagnostic}</ScrollableCodeSurface>;
} else {
body = (
<SurfaceCard>
<View style={styles.placeholder}>
<Text style={styles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
</SurfaceCard>
);
}
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
snapPoints={DIAGNOSTIC_SNAP_POINTS}
scrollable={false}
testID="provider-diagnostic-sheet"
>
{body}
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
placeholder: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
}));
@@ -3,7 +3,7 @@ import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import {
resolveProviderDiscoveredModels,
type ProviderDiscoveredModelsCache,
} from "./provider-diagnostic-models";
} from "./discovered-models";
const piModel: AgentModelDefinition = {
provider: "pi",
@@ -0,0 +1,151 @@
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "react-native";
import { useToast } from "@/contexts/toast-api-context";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import {
buildAcpProviderConfigPatch,
getAcpProviderCatalog,
type AcpProviderCatalogItem,
} from "@/hooks/use-acp-provider-catalog";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { selectHostFeature, useHostFeature } from "@/runtime/host-features";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
export type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
export interface ProviderConfigActions {
/** The provider whose Enabled switch is being saved. */
togglingProviderId: string | null;
setEnabled: (providerId: string, enabled: boolean) => void;
/** Config owns the provider, so Remove can delete it. */
canRemove: (entry: ProviderEntry) => boolean;
/**
* The provider being removed, as it was when the user confirmed. It stays set once the removal
* succeeds, because whatever shows the provider closes next and keeps showing it until then, and
* ends when the host lists the provider again, as when the user adds it back.
*/
removing: ProviderEntry | null;
/** Asks first; resolves true once the provider is gone from config. */
remove: (entry: ProviderEntry, providerLabel: string) => Promise<boolean>;
/** The catalog provider being written to config. */
addingProviderId: string | null;
/** Writes a catalog provider to config and probes it; resolves true once it is listed. */
add: (entry: AcpProviderCatalogItem) => Promise<boolean>;
}
/** Writes a provider's Enabled flag, adds catalog providers and removes config-owned ones. */
export function useProviderConfigActions(serverId: string): ProviderConfigActions {
const { t } = useTranslation();
const toast = useToast();
const { config, patchConfig } = useDaemonConfig(serverId);
const { entries, refresh } = useProvidersSnapshot(serverId);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const [togglingProviderId, setTogglingProviderId] = useState<string | null>(null);
const [removal, setRemoval] = useState<{ entry: ProviderEntry; done: boolean } | null>(null);
const removingRef = useRef<string | null>(null);
const [addingProviderId, setAddingProviderId] = useState<string | null>(null);
const addingRef = useRef<string | null>(null);
const setEnabled = useCallback(
(providerId: string, enabled: boolean) => {
setTogglingProviderId(providerId);
void patchConfig({ providers: { [providerId]: { enabled } } })
.catch((error: unknown) => {
Alert.alert(
t("settings.providers.updateErrorTitle"),
error instanceof Error ? error.message : String(error),
);
})
.finally(() => {
setTogglingProviderId((current) => (current === providerId ? null : current));
});
},
[patchConfig, t],
);
// Config owns derived providers and the opt-in bundled catalog providers.
const canRemove = useCallback(
(entry: ProviderEntry) => {
const providerConfig = config?.providers?.[entry.provider];
if (!supportsProviderRemoval || entry.source !== "custom" || !providerConfig) return false;
return Boolean(
providerConfig.extends ||
getAcpProviderCatalog().some(
(catalogEntry) => catalogEntry.id === entry.provider && catalogEntry.bundledProvider,
),
);
},
[config, supportsProviderRemoval],
);
const remove = useCallback(
async (entry: ProviderEntry, providerLabel: string) => {
if (removingRef.current) return false;
removingRef.current = entry.provider;
try {
const confirmed = await confirmDialog({
title: t("settings.providers.remove.confirmTitle", { name: providerLabel }),
message: t("settings.providers.remove.confirmMessage"),
confirmLabel: t("settings.providers.remove.confirm"),
destructive: true,
});
if (!confirmed) return false;
setRemoval({ entry, done: false });
await patchConfig({ removeProviders: [entry.provider] });
setRemoval({ entry, done: true });
return true;
} catch (error) {
setRemoval(null);
Alert.alert(
t("settings.providers.remove.errorTitle"),
error instanceof Error ? error.message : String(error),
);
return false;
} finally {
removingRef.current = null;
}
},
[patchConfig, t],
);
// A sheet outlives each opening, so a finished removal ends here rather than with the sheet.
if (removal?.done && entries?.some((entry) => entry.provider === removal.entry.provider)) {
setRemoval(null);
}
const add = useCallback(
async (entry: AcpProviderCatalogItem) => {
if (addingRef.current) return false;
addingRef.current = entry.id;
setAddingProviderId(entry.id);
try {
await patchConfig(
buildAcpProviderConfigPatch(entry, (feature) =>
selectHostFeature(useSessionStore.getState(), serverId, feature),
),
);
await refresh([entry.id]);
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return false;
} finally {
if (addingRef.current === entry.id) addingRef.current = null;
setAddingProviderId((current) => (current === entry.id ? null : current));
}
},
[patchConfig, refresh, serverId, toast],
);
return {
togglingProviderId,
setEnabled,
canRemove,
removing: removal?.entry ?? null,
remove,
addingProviderId,
add,
};
}
@@ -0,0 +1,49 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { SettingsDetailHeader } from "@/components/settings";
import { ProviderDetail, useProviderDetail } from "./provider-detail";
import { ProviderGlyph } from "./provider-glyph";
/**
* A provider's detail as a Settings page, shaped like a project's: the screen titles it
* "Providers", the page names the provider. On wide layouts the page has no back button of its
* own, so it draws "Back to providers".
*/
export function ProviderDetailPage({
serverId,
provider,
onBack,
showBack,
}: {
serverId: string;
provider: string;
onBack: () => void;
showBack: boolean;
}) {
const { t } = useTranslation();
const detail = useProviderDetail({ serverId, provider, onRemoved: onBack });
const back = useMemo(
() => ({
label: t("settings.providers.detail.backToProviders"),
onPress: onBack,
testID: "provider-detail-back",
}),
[onBack, t],
);
const icon = useMemo(
() => <ProviderGlyph serverId={serverId} provider={provider} />,
[provider, serverId],
);
return (
<View testID="provider-detail-page">
<SettingsDetailHeader
back={showBack ? back : undefined}
icon={icon}
title={detail.label}
titleTestID="provider-detail-title"
/>
<ProviderDetail serverId={serverId} detail={detail} />
</View>
);
}
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProviderDetail, useProviderDetail } from "./provider-detail";
import { ProviderGlyph } from "./provider-glyph";
import { OVERLAY_SHEET_FRAME, type ProviderSheetFrame } from "./sheet-frame";
/** A provider's detail as a sheet, for surfaces with no Settings route to push. */
export function ProviderDetailSheet({
serverId,
provider,
visible,
onClose,
frame = OVERLAY_SHEET_FRAME,
}: {
serverId: string;
provider: string;
visible: boolean;
onClose: () => void;
frame?: ProviderSheetFrame;
}) {
const { t } = useTranslation();
const detail = useProviderDetail({ serverId, provider, onRemoved: onClose });
const label = detail.label;
const header = useMemo<SheetHeader>(
() => ({
title: label,
leading: <ProviderGlyph serverId={serverId} provider={provider} />,
back: frame.returns
? {
onPress: onClose,
accessibilityLabel: t("settings.providers.detail.backToProviders"),
}
: undefined,
}),
[frame.returns, label, onClose, provider, serverId, t],
);
return (
<AdaptiveModalSheet
header={header}
showCloseButton={!frame.returns}
visible={visible}
onClose={onClose}
snapPoints={frame.snapPoints}
desktopMaxWidth={frame.desktopMaxWidth}
desktopHeight={frame.desktopHeight}
testID="provider-settings-sheet"
>
<ProviderDetail serverId={serverId} detail={detail} />
</AdaptiveModalSheet>
);
}
@@ -0,0 +1,239 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { ProviderInstallationSection } from "@/components/provider-installation-section";
import { useProviderInstallation } from "@/components/provider-installation";
import {
SettingsCard,
SettingsDetailRow,
SettingsRow,
SettingsSection,
SettingsSwitch,
} from "@/components/settings";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { readDetailPresence, readDetailSections, type DetailPresence } from "./detail-sections";
import { DiagnosticSheet } from "./diagnostic-sheet";
import { useProviderConfigActions, type ProviderEntry } from "./provider-config";
import { ProviderModels } from "./provider-models";
import { ProviderNotice } from "./provider-notice";
export interface ProviderDetailState {
provider: string;
/** The provider's name, kept while this page removes it. */
label: string;
presence: DetailPresence;
/** Config owns the provider, so Remove can delete it. */
canRemove: (entry: ProviderEntry) => boolean;
/** This page's Remove is running, or has succeeded and the page is closing. */
removing: boolean;
remove: () => void;
}
/**
* What a provider's detail shows and its Remove, shared by the frame's title and the body. A
* provider this page removes leaves the host's list before the removal answers; both keep showing
* it until the frame closes.
*/
export function useProviderDetail({
serverId,
provider,
onRemoved,
}: {
serverId: string;
provider: string;
/** The provider was removed; leave its detail. */
onRemoved: () => void;
}): ProviderDetailState {
const { entries, error } = useProvidersSnapshot(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const { canRemove, remove, removing } = useProviderConfigActions(serverId);
const presence = readDetailPresence({ provider, connected, entries, error, removing });
const shown = presence.kind === "found" ? presence.entry : null;
const label = shown?.label ?? provider;
const handleRemove = useCallback(() => {
if (!shown) return;
void remove(shown, label).then((removed) => {
if (removed) onRemoved();
return removed;
});
}, [label, onRemoved, remove, shown]);
return {
provider,
label,
presence,
canRemove,
removing: removing?.provider === provider,
remove: handleRemove,
};
}
/**
* Everything about one provider, top to bottom: what is wrong, whether it runs, its installation,
* its models, then the rare actions. The page and the sheet frame the same body.
*/
export function ProviderDetail({
serverId,
detail,
}: {
serverId: string;
detail: ProviderDetailState;
}) {
const { provider, presence, label } = detail;
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
const installation = useProviderInstallation({ serverId, provider, visible: true });
const refreshInstallation = installation.refresh;
const handleRefresh = useCallback(() => {
void refresh([provider]);
refreshInstallation();
}, [provider, refresh, refreshInstallation]);
if (presence.kind !== "found") return <AbsentProvider presence={presence} />;
const entry = presence.entry;
const sections = readDetailSections(entry, installation.state);
return (
<View testID="provider-detail">
{sections.error !== null ? <ProviderErrorAlert label={label} error={sections.error} /> : null}
<EnabledCard serverId={serverId} entry={entry} />
<ProviderInstallationSection
state={installation.state}
updates={installation.updates}
action={installation.action}
/>
{sections.models ? (
<ProviderModels serverId={serverId} entry={entry} refresh={refresh} />
) : null}
<Troubleshooting
serverId={serverId}
entry={entry}
refreshing={isRefreshing || installation.refreshing}
onRefresh={handleRefresh}
/>
{/* Config no longer owns a provider whose removal has succeeded. */}
{detail.removing || detail.canRemove(entry) ? (
<DangerZone removing={detail.removing} onRemove={detail.remove} />
) : null}
</View>
);
}
function AbsentProvider({ presence }: { presence: Exclude<DetailPresence, { kind: "found" }> }) {
const { t } = useTranslation();
const messages = {
loading: t("settings.providers.loading"),
missing: t("settings.providers.detail.notOnHost"),
offline: t("settings.providers.unavailable"),
};
const message = presence.kind === "failed" ? presence.message : messages[presence.kind];
return <ProviderNotice message={message} testID={`provider-detail-${presence.kind}`} />;
}
function ProviderErrorAlert({ label, error }: { label: string; error: string }) {
const { t } = useTranslation();
return (
<View style={settingsStyles.section}>
<Alert
variant="error"
title={t("settings.providers.detail.unavailableTitle", { name: label })}
description={error || t("settings.providers.diagnostic.unknownError")}
testID="provider-error"
/>
</View>
);
}
function EnabledCard({ serverId, entry }: { serverId: string; entry: ProviderEntry }) {
const { t } = useTranslation();
const { setEnabled, togglingProviderId } = useProviderConfigActions(serverId);
const handleChange = useCallback(
(enabled: boolean) => setEnabled(entry.provider, enabled),
[entry.provider, setEnabled],
);
return (
<View style={settingsStyles.section}>
<SettingsCard>
<SettingsSwitch
label={t("settings.providers.detail.enabled")}
value={entry.enabled}
onValueChange={handleChange}
disabled={togglingProviderId === entry.provider}
/>
</SettingsCard>
</View>
);
}
function Troubleshooting({
serverId,
entry,
refreshing,
onRefresh,
}: {
serverId: string;
entry: ProviderEntry;
refreshing: boolean;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const [diagnosticOpen, setDiagnosticOpen] = useState(false);
const openDiagnostic = useCallback(() => setDiagnosticOpen(true), []);
const closeDiagnostic = useCallback(() => setDiagnosticOpen(false), []);
const checked = useTimeAgo(entry.fetchedAt ? new Date(entry.fetchedAt) : null);
// The probe running again is a refresh too, whoever asked for it.
const busy = refreshing || entry.status === "loading";
return (
<SettingsSection title={t("settings.providers.detail.troubleshooting")}>
<SettingsCard>
<SettingsRow
label={t("settings.providers.detail.status")}
hint={
entry.fetchedAt ? t("settings.providers.detail.checked", { time: checked }) : undefined
}
>
<Button variant="outline" onPress={onRefresh} loading={busy} testID="provider-refresh">
{busy
? t("settings.providers.diagnostic.refreshing")
: t("settings.providers.diagnostic.refresh")}
</Button>
</SettingsRow>
<SettingsDetailRow
label={t("settings.providers.detail.diagnosticReport")}
onPress={openDiagnostic}
testID="provider-diagnostic-report"
/>
</SettingsCard>
<DiagnosticSheet
serverId={serverId}
provider={entry.provider}
visible={diagnosticOpen}
onClose={closeDiagnostic}
/>
</SettingsSection>
);
}
function DangerZone({ removing, onRemove }: { removing: boolean; onRemove: () => void }) {
const { t } = useTranslation();
return (
<SettingsSection title={t("settings.providers.detail.dangerZone")}>
<SettingsCard>
<SettingsRow
label={t("settings.providers.actions.remove")}
hint={t("settings.providers.detail.removeHint")}
>
<Button variant="outline" onPress={onRemove} loading={removing} testID="provider-remove">
{removing
? t("settings.providers.actions.removing")
: t("settings.providers.remove.confirm")}
</Button>
</SettingsRow>
</SettingsCard>
</SettingsSection>
);
}

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