mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6df8a959 | ||
|
|
ec179aa01e | ||
|
|
0563654cb0 | ||
|
|
6ead9a9e0b | ||
|
|
4d740fd4dd | ||
|
|
ddc45fe1c1 | ||
|
|
4ed83a0ef0 | ||
|
|
969b2938e9 | ||
|
|
b759977818 | ||
|
|
35acb5a914 | ||
|
|
0869f6df33 | ||
|
|
18b19f1e78 | ||
|
|
37c99c12b1 | ||
|
|
bf3d6b5fe0 | ||
|
|
4c072573fe | ||
|
|
ee4c26f48e | ||
|
|
19d791777e | ||
|
|
b2a83d335f | ||
|
|
c523b3ad9b | ||
|
|
4858e62270 | ||
|
|
d6efcc453b | ||
|
|
2e90865134 | ||
|
|
603c52b294 | ||
|
|
737792ad7c | ||
|
|
3edd502aac | ||
|
|
bd3d86749c | ||
|
|
b152308340 | ||
|
|
2bd55c5ff7 | ||
|
|
f7a442f4d4 | ||
|
|
1f79e07feb | ||
|
|
c7f55fd13f | ||
|
|
7349b26b56 | ||
|
|
0d9cf2b982 | ||
|
|
dc4379ee01 | ||
|
|
8e0a18f595 | ||
|
|
b927be1bc7 | ||
|
|
e755886c4f | ||
|
|
857808f7c9 | ||
|
|
ad5e76b133 | ||
|
|
d7e8099288 | ||
|
|
27bbf5355b | ||
|
|
3536689ffb | ||
|
|
322c542eaf | ||
|
|
fa8dcb372a | ||
|
|
59ab8b8c1f | ||
|
|
75a1ccdcee | ||
|
|
4b67d035ce | ||
|
|
5afbd0d7d3 | ||
|
|
d30aa16beb |
+6
-6
@@ -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` |
|
||||
|
||||
+6
-30
@@ -7,12 +7,12 @@ canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover/` | Slash-command autocomplete anchored to the focused composer input |
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
@@ -280,22 +280,6 @@ popover or leaves it behind the keyboard. `measureAtRest` measures through
|
||||
Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving`
|
||||
settles; the composer's resting position changes as the draft grows.
|
||||
|
||||
A measured rect is an asynchronous copy, so it lands after the anchor's new
|
||||
layout has painted. The composer resizes while the autocomplete is open: a
|
||||
wrapped line, cleared text, a window resize. On web a `ResizeObserver` reads
|
||||
the anchor's rect against the layer and commits the placement with `flushSync`,
|
||||
which runs after layout and before paint. On native a Reanimated frame callback
|
||||
reads only the anchor's height on the UI thread and holds its bottom edge at
|
||||
the rest measurement. A height carries no transform. On iOS the absolute
|
||||
position does not reliably include one: the keyboard lift runs on the native
|
||||
driver and reaches the shadow tree only when the view re-renders. The
|
||||
fixed-bottom model is wrong for the centered New workspace composer on tablets,
|
||||
which grows around its middle, and for width or x changes. The popover is off
|
||||
there by half the growth, or by the old width, until the next rest measurement.
|
||||
Keep the portal's `KeyboardTranslateView` host-sized with
|
||||
`pointerEvents="box-none"` and position the popover inside it, so the popover
|
||||
moves without leaving its parent's bounds (Gotcha 1).
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
interactive move, but not at the terminal event, so JS contention can otherwise
|
||||
@@ -469,14 +453,6 @@ Before you write a new one, ask:
|
||||
`top` paints over its anchor until the next placement lands, about 150 ms
|
||||
on Android. Web hides this because React commits the resize and the
|
||||
re-placement in one task.
|
||||
7. **Does the panel hold a list that reads away from its anchor?** Make the
|
||||
anchor edge the list's resting scroll position: an inverted list, as in
|
||||
`components/ui/autocomplete.tsx`. On Android a scroll command issued after
|
||||
the rows commit reaches the screen one or two frames after them, so a list
|
||||
scrolled to its anchor edge paints its far end first. Give each result set
|
||||
its own list with a `key`, so it starts at the anchor edge with the rows
|
||||
that fill the panel. A list that mounts every result holds the panel's
|
||||
first frame until the last row is ready.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,85 +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, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
!disabled && hovered && styles.hovered,
|
||||
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, layout],
|
||||
[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,
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
import React, { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { View } from "react-native";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import type { AutocompleteOption } from "./autocomplete";
|
||||
import { AutocompletePopover } from "./autocomplete-popover";
|
||||
import { FloatingPanelPortalHost } from "./floating-panel-portal";
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
/**
|
||||
* The popover must paint its first frame at its place. On native, Reanimated computes a style's
|
||||
* first value when its hook first runs and sends later values after the mounting commit, which
|
||||
* native paints: a bound computed while the popover was closed hung the popover over the composer
|
||||
* for a frame on every open. The frame now mounts only with a measured anchor. This suite runs the
|
||||
* web frame; the native frame was checked frame by frame on Android.
|
||||
*/
|
||||
|
||||
const SAFE_AREA = {
|
||||
frame: { x: 0, y: 0, width: 390, height: 844 },
|
||||
insets: { top: 0, left: 0, right: 0, bottom: 0 },
|
||||
};
|
||||
// The composer rests on the dock's bottom edge and grows upward, as in the app.
|
||||
const COMPOSER_BOTTOM = 124;
|
||||
const OFFSET_FROM_ANCHOR = 12;
|
||||
const OPTIONS: AutocompleteOption[] = ["help", "hello", "history"].map((name) => ({
|
||||
id: name,
|
||||
label: `/${name}`,
|
||||
icon: "command",
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
const SCREEN_STYLE = { width: 390, height: 844 };
|
||||
const styleCache = new Map<string, object>();
|
||||
function cachedStyle<T extends object>(key: string, create: () => T): T {
|
||||
const cached = styleCache.get(key);
|
||||
if (cached) return cached as T;
|
||||
const style = create();
|
||||
styleCache.set(key, style);
|
||||
return style;
|
||||
}
|
||||
function composerStyle(width: number): object {
|
||||
return cachedStyle(`composer:${width}`, () => ({
|
||||
position: "absolute" as const,
|
||||
bottom: COMPOSER_BOTTOM,
|
||||
left: 16,
|
||||
width,
|
||||
}));
|
||||
}
|
||||
function inputStyle(height: number): object {
|
||||
return cachedStyle(`input:${height}`, () => ({ height }));
|
||||
}
|
||||
function ignoreSelection(): void {}
|
||||
|
||||
function Providers({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={SAFE_AREA}>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
visible: boolean;
|
||||
panelActive?: boolean;
|
||||
inputHeight?: number;
|
||||
composerWidth?: number;
|
||||
options?: AutocompleteOption[];
|
||||
/** Counted up from the best match, which is the last option. */
|
||||
selectedFromBest?: number;
|
||||
}
|
||||
|
||||
function Screen({
|
||||
visible,
|
||||
panelActive = true,
|
||||
inputHeight = 120,
|
||||
composerWidth = 358,
|
||||
options = OPTIONS,
|
||||
selectedFromBest = 0,
|
||||
}: ScreenProps): ReactNode {
|
||||
return (
|
||||
<Providers>
|
||||
<RetainedPanel active={panelActive}>
|
||||
<View style={SCREEN_STYLE}>
|
||||
<View style={composerStyle(composerWidth)}>
|
||||
<AutocompletePopover
|
||||
visible={visible}
|
||||
options={options}
|
||||
selectedIndex={options.length - 1 - selectedFromBest}
|
||||
onSelect={ignoreSelection}
|
||||
>
|
||||
<View testID="composer-input" style={inputStyle(inputHeight)} />
|
||||
</AutocompletePopover>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
</View>
|
||||
</RetainedPanel>
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
act(() => root?.render(node));
|
||||
}
|
||||
|
||||
/** Commits a change to the screen synchronously and lets the browser paint it. */
|
||||
async function changeAndPaint(node: ReactNode): Promise<void> {
|
||||
flushSync(() => root?.render(node));
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => resolve(), { once: true });
|
||||
channel.port1.start();
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rectOf(testID: string): DOMRect | null {
|
||||
return document.querySelector(`[data-testid="${testID}"]`)?.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
/** Where the popover sits against the composer, or null when it is not painted. */
|
||||
function placementAgainstComposer(): { gap: number; x: number; width: number } | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const composer = rectOf("composer-input");
|
||||
if (!popover || !composer) return null;
|
||||
return {
|
||||
gap: Math.round(composer.top - popover.bottom),
|
||||
x: Math.round(popover.x - composer.x),
|
||||
width: Math.round(popover.width - composer.width),
|
||||
};
|
||||
}
|
||||
|
||||
async function openSettled(props: Omit<ScreenProps, "visible"> = {}): Promise<void> {
|
||||
render(<Screen visible={false} {...props} />);
|
||||
render(<Screen visible {...props} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
}
|
||||
|
||||
/** Every max-height the popover's frame has held, starting with the one it mounted with. */
|
||||
function recordPopoverMaxHeights(): { values: string[]; stop: () => void } {
|
||||
const values: string[] = [];
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === "childList") {
|
||||
for (const node of record.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
const popover = node.matches('[data-testid="composer-autocomplete-popover"]')
|
||||
? node
|
||||
: node.querySelector<HTMLElement>('[data-testid="composer-autocomplete-popover"]');
|
||||
// The attribute records below carry what the style was before each change; this
|
||||
// carries the style at insertion when nothing changed it afterwards.
|
||||
if (popover && values.length === 0) values.push(popover.style.maxHeight);
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.type === "attributes" &&
|
||||
record.target instanceof HTMLElement &&
|
||||
record.target.dataset.testid === "composer-autocomplete-popover"
|
||||
) {
|
||||
const before = /max-height:\s*([^;]+)/.exec(record.oldValue ?? "")?.[1]?.trim() ?? "";
|
||||
if (values.length <= 1) values.splice(0, values.length, before);
|
||||
values.push(record.target.style.maxHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
return { values, stop: () => observer.disconnect() };
|
||||
}
|
||||
|
||||
async function waitForPopover(): Promise<HTMLElement> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const popover = document.querySelector<HTMLElement>(
|
||||
'[data-testid="composer-autocomplete-popover"]',
|
||||
);
|
||||
if (popover) return popover;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("The popover did not open");
|
||||
}
|
||||
|
||||
it("opens with its height bound instead of the bound it had while closed", async () => {
|
||||
// The composer mounts first and opens the popover later, as typing `/` does.
|
||||
render(<Screen visible={false} />);
|
||||
const recording = recordPopoverMaxHeights();
|
||||
render(<Screen visible />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
const settled = popover.style.maxHeight;
|
||||
expect(Number.parseFloat(settled)).toBeGreaterThan(0);
|
||||
expect(recording.values[0]).toBe(settled);
|
||||
});
|
||||
|
||||
/**
|
||||
* The popover lives in a portal and copies the composer's rect. A copy taken after the composer's
|
||||
* layout paints the composer at its new size against the popover's old place: the popover overlapped
|
||||
* a composer that grew a line, floated above one that shrank, and kept the old width after a resize.
|
||||
*/
|
||||
it("moves with the composer in the frame the composer grows", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible inputHeight={160} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("follows the composer down when its text clears before the popover closes", async () => {
|
||||
// The input relayouts on the cleared text before the autocomplete hides the popover.
|
||||
await openSettled({ inputHeight: 160 });
|
||||
await changeAndPaint(<Screen visible inputHeight={120} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
it("matches the composer's width in the frame the composer resizes", async () => {
|
||||
await openSettled();
|
||||
await changeAndPaint(<Screen visible composerWidth={240} />);
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* A retained workspace stays mounted while another route shows. A popover left open there tracked a
|
||||
* composer it could not measure, and on native stopped following the composer after the user came
|
||||
* back.
|
||||
*/
|
||||
it("closes while its retained panel is hidden and reopens in place when it shows again", async () => {
|
||||
await openSettled();
|
||||
render(<Screen visible panelActive={false} />);
|
||||
expect(rectOf("composer-autocomplete-popover")).toBeNull();
|
||||
render(<Screen visible />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(placementAgainstComposer()).toEqual({ gap: OFFSET_FROM_ANCHOR, x: 0, width: 0 });
|
||||
});
|
||||
|
||||
/** As many commands as a workspace with skills installed lists; the best match is last. */
|
||||
function commandOptions(prefix: string, count: number): AutocompleteOption[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `${prefix}-${count - index}`,
|
||||
label: `/${prefix}-${count - index}`,
|
||||
icon: "skill" as const,
|
||||
description: `Description of ${prefix} ${count - index}`,
|
||||
tag: "Skill",
|
||||
}));
|
||||
}
|
||||
|
||||
const LIST_PADDING_AND_BORDER = 5;
|
||||
const ROW_HEIGHT_AT_LEAST = 36;
|
||||
|
||||
/** How far the best match's row sits above the popover's bottom edge, or null when not painted. */
|
||||
function bestMatchAgainstPopoverBottom(label: string): number | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover) return null;
|
||||
if (!row) return Number.NaN;
|
||||
return Math.round(popover.bottom - row.bottom);
|
||||
}
|
||||
|
||||
/** Samples a reading after every paint until stopped. */
|
||||
function recordPaintedFrames<T>(read: () => T): { frames: T[]; stop: () => void } {
|
||||
const frames: T[] = [];
|
||||
let running = true;
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.addEventListener("message", () => {
|
||||
if (running) frames.push(read());
|
||||
});
|
||||
channel.port1.start();
|
||||
const tick = (): void => {
|
||||
if (!running) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return {
|
||||
frames,
|
||||
stop: () => {
|
||||
running = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The list is longer than the popover and reads upward from its best match, which sits against the
|
||||
* bottom edge. Scrolling there after the list mounted painted the list at its old offset first: a
|
||||
* few pixels short on open, a different stretch of the list when the results changed.
|
||||
*/
|
||||
it("paints a long list with its best match against the bottom from the first frame", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
const painted = recording.frames.filter((frame) => frame !== null);
|
||||
expect(painted.length).toBeGreaterThan(0);
|
||||
expect([...new Set(painted)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
it("keeps the best match against the bottom in the frame the results change", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Narrowing keeps `/skill-1` the best match; widening brings the rest back above it.
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
function mountedRows(prefix = "skill"): Element[] {
|
||||
return [...document.querySelectorAll(`[aria-label^="/${prefix}-"]`)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every result used to mount before the popover's first frame, so a workspace with a hundred
|
||||
* commands waited on a hundred rows to see six.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first, however many results there are", async () => {
|
||||
const options = commandOptions("skill", 500);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
const recording = recordPaintedFrames(() =>
|
||||
rectOf("composer-autocomplete-popover") ? mountedRows().length : null,
|
||||
);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((frame) => frame !== null);
|
||||
expect(firstFrame).toBeGreaterThan(0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/**
|
||||
* A list that had mounted its whole window kept that window for the next results, so narrowing a
|
||||
* long list to another long one waited on ten times the rows the popover shows.
|
||||
*/
|
||||
it("mounts the rows nearest the composer first when the results change", async () => {
|
||||
const commands = commandOptions("skill", 500);
|
||||
const matches = commandOptions("plugin", 300);
|
||||
render(<Screen visible={false} options={commands} />);
|
||||
render(<Screen visible options={commands} />);
|
||||
await waitForPopover();
|
||||
await expect.poll(() => mountedRows().length, { timeout: 3000 }).toBeGreaterThan(40);
|
||||
|
||||
const recording = recordPaintedFrames(() => mountedRows("plugin").length);
|
||||
await changeAndPaint(<Screen visible options={matches} />);
|
||||
recording.stop();
|
||||
|
||||
const firstFrame = recording.frames.find((count) => count > 0);
|
||||
expect(firstFrame).toBeLessThan(20);
|
||||
expect(bestMatchAgainstPopoverBottom("/plugin-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
function scrollerOf(popover: HTMLElement): HTMLElement {
|
||||
const scroller = [...popover.querySelectorAll<HTMLElement>("*")].find(
|
||||
(element) =>
|
||||
element.scrollHeight > element.clientHeight + 1 &&
|
||||
["auto", "scroll"].includes(getComputedStyle(element).overflowY),
|
||||
);
|
||||
if (!scroller) throw new Error("The list does not scroll");
|
||||
return scroller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native paints a scroll command a frame or two after the rows it was meant for. A list that rests
|
||||
* against the composer only because script scrolled it there shows the rows somewhere else first,
|
||||
* so the composer's edge is where the list's scroll starts.
|
||||
*/
|
||||
it("rests against the composer where its scroll starts", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
|
||||
await changeAndPaint(<Screen visible options={all} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(scrollerOf(popover).scrollTop).toBe(0);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("returns to the best match in the frame results arrive while the list is scrolled away", async () => {
|
||||
const all = commandOptions("skill", 100);
|
||||
const fewer = all.filter((_, index) => index % 3 === 0);
|
||||
render(<Screen visible={false} options={all} />);
|
||||
render(<Screen visible options={all} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={fewer} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([LIST_PADDING_AND_BORDER]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer hands over a new list on every keystroke and every refetch, often holding the results
|
||||
* it already showed. Starting over for those returned a list the user had scrolled to its rest.
|
||||
*/
|
||||
it("keeps its place when the same results arrive again", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
scrollerOf(popover).scrollTop = 400;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const scrolledAway = bestMatchAgainstPopoverBottom("/skill-1");
|
||||
expect(scrolledAway).not.toBe(LIST_PADDING_AND_BORDER);
|
||||
|
||||
const recording = recordPaintedFrames(() => bestMatchAgainstPopoverBottom("/skill-1"));
|
||||
await changeAndPaint(<Screen visible options={commandOptions("skill", 100)} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
recording.stop();
|
||||
|
||||
expect([...new Set(recording.frames)]).toEqual([scrolledAway]);
|
||||
});
|
||||
|
||||
/** Whether the row is wholly inside the popover's box. */
|
||||
function isRowInView(label: string): boolean {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
const row = document.querySelector(`[aria-label^="${label},"]`)?.getBoundingClientRect();
|
||||
if (!popover || !row) return false;
|
||||
return row.top >= popover.top - 0.5 && row.bottom <= popover.bottom + 0.5;
|
||||
}
|
||||
|
||||
it("reveals the row the arrow keys select, including across the wrap to the far end", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
for (const selectedFromBest of [1, 5, 6, 7, 12, 6, 0, 99, 98, 93, 0]) {
|
||||
render(<Screen visible options={options} selectedFromBest={selectedFromBest} />);
|
||||
// Rows beyond the mounted ones come into view as they mount.
|
||||
await expect
|
||||
.poll(() => [selectedFromBest, isRowInView(`/skill-${selectedFromBest + 1}`)], {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toEqual([selectedFromBest, true]);
|
||||
}
|
||||
await expect.poll(() => bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
/** The composer grew until the space above it holds fewer rows than the popover was showing. */
|
||||
it("keeps the selected row in view when the popover gets shorter", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// Beyond the rows the popover opened with, so it came into view at the popover's top edge.
|
||||
render(<Screen visible options={options} selectedFromBest={7} />);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
const height = rectOf("composer-autocomplete-popover")!.height;
|
||||
|
||||
render(<Screen visible options={options} selectedFromBest={7} inputHeight={560} />);
|
||||
await expect
|
||||
.poll(() => rectOf("composer-autocomplete-popover")!.height, { timeout: 3000 })
|
||||
.toBeLessThan(height - ROW_HEIGHT_AT_LEAST);
|
||||
await expect.poll(() => isRowInView("/skill-8"), { timeout: 3000 }).toBe(true);
|
||||
});
|
||||
|
||||
/** The label of the row at the top of the popover's box. */
|
||||
function topRowInView(): string | null {
|
||||
const popover = rectOf("composer-autocomplete-popover");
|
||||
if (!popover) return null;
|
||||
const rows = mountedRows()
|
||||
.map((row) => ({ rect: row.getBoundingClientRect(), label: row.getAttribute("aria-label") }))
|
||||
.filter(({ rect }) => rect.top >= popover.top - 0.5 && rect.bottom <= popover.bottom + 0.5)
|
||||
.toSorted((a, b) => a.rect.top - b.rect.top);
|
||||
return rows[0]?.label?.split(",")[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The far end of a long list is not mounted while the list rests against the composer. Scrolling to
|
||||
* it as rows mounted painted the list at several places on the way.
|
||||
*/
|
||||
it("jumps between the two ends in one frame when the arrow keys wrap", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const restingAtComposer = topRowInView();
|
||||
|
||||
const toFarEnd = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={99} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toFarEnd.stop();
|
||||
expect([...new Set(toFarEnd.frames)]).toEqual(["/skill-100"]);
|
||||
expect(isRowInView("/skill-100")).toBe(true);
|
||||
|
||||
const toComposer = recordPaintedFrames(topRowInView);
|
||||
await changeAndPaint(<Screen visible options={options} selectedFromBest={0} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
toComposer.stop();
|
||||
expect([...new Set(toComposer.frames)]).toEqual([restingAtComposer]);
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER);
|
||||
});
|
||||
|
||||
it("scrolls towards rows further from the composer when the wheel turns up", async () => {
|
||||
const options = commandOptions("skill", 100);
|
||||
render(<Screen visible={false} options={options} />);
|
||||
render(<Screen visible options={options} />);
|
||||
const popover = await waitForPopover();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const row = document.querySelector('[aria-label^="/skill-1,"]');
|
||||
row?.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// The best match moved down, out of the popover, by the distance the wheel turned.
|
||||
expect(bestMatchAgainstPopoverBottom("/skill-1")).toBe(LIST_PADDING_AND_BORDER - 120);
|
||||
expect(isRowInView("/skill-7")).toBe(true);
|
||||
expect(popover.getBoundingClientRect().bottom).toBe(rectOf("composer-input")!.top - 12);
|
||||
});
|
||||
+58
-44
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useAnimatedReaction, useAnimatedRef } from "react-native-reanimated";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -19,10 +21,18 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift";
|
||||
import { PopoverFrame } from "./internal/frame";
|
||||
import type { AnchorAtRest } from "./internal/placement";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
interface RelativeAnchorRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
@@ -45,16 +55,8 @@ export function AutocompletePopover({
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorRef = useAnimatedRef<View>();
|
||||
// The animated ref returns its native handle, which React 19 would call as a ref cleanup.
|
||||
// The anchor stays a plain View: its style is the caller's Unistyles style.
|
||||
const setAnchorElement = useCallback(
|
||||
(element: View | null) => {
|
||||
anchorRef(element);
|
||||
},
|
||||
[anchorRef],
|
||||
);
|
||||
const [anchorAtRest, setAnchorAtRest] = useState<AnchorAtRest | null>(null);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
@@ -62,9 +64,7 @@ export function AutocompletePopover({
|
||||
const anchor = useKeyboardAnchor();
|
||||
const { measureAtRest } = anchor;
|
||||
const measurementGeneration = useRef(0);
|
||||
// A retained panel stays mounted while hidden; its anchor cannot be measured until it shows.
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const canMeasure = isPanelActive && visible && (options.length === 0 || selectedIndex >= 0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
const remeasure = useCallback(() => {
|
||||
if (!canMeasure) return;
|
||||
@@ -78,11 +78,10 @@ export function AutocompletePopover({
|
||||
if (generation !== measurementGeneration.current || !anchorRect || !hostRect) {
|
||||
return undefined;
|
||||
}
|
||||
setAnchorAtRest({
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
return undefined;
|
||||
@@ -92,7 +91,7 @@ export function AutocompletePopover({
|
||||
useEffect(() => {
|
||||
measurementGeneration.current += 1;
|
||||
if (!canMeasure) {
|
||||
setAnchorAtRest(null);
|
||||
setRelativeAnchorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,32 +114,47 @@ export function AutocompletePopover({
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
// The layer covers the host and rides the anchor's keyboard translation; the frame inside it
|
||||
// places the popover. A layer that spans the host keeps the popover inside its parent's bounds,
|
||||
// which Android hit-testing requires.
|
||||
// Lay out at the anchor's resting position; the KeyboardTranslateView below lifts
|
||||
// it with the anchor. Height changes only at keyboard boundaries.
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const reservedLift = anchor.reservedLift;
|
||||
const heightStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
}),
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
return (
|
||||
<View ref={setAnchorElement} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{canMeasure && anchorAtRest ? (
|
||||
{showPopover ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView
|
||||
{...anchor.translation}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<PopoverFrame
|
||||
anchorRef={anchorRef}
|
||||
anchorAtRest={anchorAtRest}
|
||||
reservedLift={anchor.reservedLift}
|
||||
topInset={safeAreaInsets.top}
|
||||
>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
status={status}
|
||||
/>
|
||||
</PopoverFrame>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import Animated, {
|
||||
measure,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { placeAboveAnchor, type PopoverFrameProps } from "./placement";
|
||||
|
||||
/**
|
||||
* The composer grows upward from a fixed bottom edge as its text wraps. Its height is read on the
|
||||
* UI thread every frame, so the popover moves in the frame the composer's new layout mounts; an
|
||||
* asynchronous measurement through JS painted the composer over or away from the popover first.
|
||||
*
|
||||
* The frame mounts only with a measured anchor. Reanimated computes the first style when the hook
|
||||
* first runs and sends later values after the mounting commit, which native paints: a placement
|
||||
* computed before the measurement painted the popover over the composer for a frame on every open.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorHeight = useSharedValue(anchorAtRest.height);
|
||||
const anchorLost = useSharedValue(false);
|
||||
const readAnchorHeight = useCallback(() => {
|
||||
"worklet";
|
||||
if (anchorLost.value) return;
|
||||
const measured = measure(anchorRef);
|
||||
if (!measured) {
|
||||
// The anchor left the shadow tree, usually as the composer unmounts; the portal removes the
|
||||
// frame a commit later. Each failed measure() logs a warning, and a busy JS thread holds
|
||||
// that commit for many frames, so stop measuring and follow the rest measurement.
|
||||
anchorLost.value = true;
|
||||
return;
|
||||
}
|
||||
if (measured.height !== anchorHeight.value) {
|
||||
anchorHeight.value = measured.height;
|
||||
}
|
||||
}, [anchorHeight, anchorLost, anchorRef]);
|
||||
useFrameCallback(readAnchorHeight);
|
||||
|
||||
const style = useAnimatedStyle(() => {
|
||||
const anchorBottom = anchorAtRest.y + anchorAtRest.height;
|
||||
const liveHeight = anchorLost.value ? anchorAtRest.height : anchorHeight.value;
|
||||
return {
|
||||
position: "absolute",
|
||||
left: anchorAtRest.x,
|
||||
width: anchorAtRest.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchorBottom - liveHeight,
|
||||
hostHeight: anchorAtRest.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
};
|
||||
}, [anchorAtRest, anchorHeight, anchorLost, reservedLift, topInset]);
|
||||
|
||||
return (
|
||||
<Animated.View testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { View } from "react-native";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { placeAboveAnchor, type AnchorAtRest, type PopoverFrameProps } from "./placement";
|
||||
|
||||
type AnchorRect = Omit<AnchorAtRest, "height">;
|
||||
|
||||
function rectWithin(anchor: HTMLElement, host: HTMLElement): AnchorRect {
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer resizes as its text wraps and as the window resizes. ResizeObserver callbacks run
|
||||
* after layout and before paint, so committing the new placement synchronously there paints the
|
||||
* popover against the composer's new rect in the same frame.
|
||||
*/
|
||||
export function PopoverFrame({
|
||||
anchorRef,
|
||||
anchorAtRest,
|
||||
reservedLift,
|
||||
topInset,
|
||||
children,
|
||||
}: PopoverFrameProps): ReactElement {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const frameRef = useRef<View>(null);
|
||||
const [anchor, setAnchor] = useState<AnchorRect>(anchorAtRest);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchorElement = anchorRef.current as unknown as HTMLElement | null;
|
||||
// The frame's parent is the portal layer, which covers the host.
|
||||
const host = (frameRef.current as unknown as HTMLElement | null)?.parentElement;
|
||||
if (!anchorElement || !host) return undefined;
|
||||
setAnchor(rectWithin(anchorElement, host));
|
||||
const observer = new ResizeObserver(() => {
|
||||
flushSync(() => setAnchor(rectWithin(anchorElement, host)));
|
||||
});
|
||||
observer.observe(anchorElement);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorAtRest, anchorRef]);
|
||||
|
||||
const style = inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
left: anchor.x,
|
||||
width: anchor.width,
|
||||
...placeAboveAnchor({
|
||||
anchorTop: anchor.y,
|
||||
hostHeight: anchor.hostHeight,
|
||||
reservedLift: reservedLift.value,
|
||||
topInset,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={frameRef} testID="composer-autocomplete-popover" style={style}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { AnimatedRef, DerivedValue } from "react-native-reanimated";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const OFFSET_FROM_ANCHOR = SPACING[3];
|
||||
|
||||
/** The anchor's resting rect, relative to the portal host the popover renders in. */
|
||||
export interface AnchorAtRest {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
hostHeight: number;
|
||||
}
|
||||
|
||||
export interface PopoverFrameProps {
|
||||
anchorRef: AnimatedRef<View>;
|
||||
/** Taken asynchronously; the frame follows the anchor's live size between measurements. */
|
||||
anchorAtRest: AnchorAtRest;
|
||||
reservedLift: DerivedValue<number>;
|
||||
topInset: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Places the popover above an anchor whose top edge is `anchorTop` within the host. */
|
||||
export function placeAboveAnchor(input: {
|
||||
anchorTop: number;
|
||||
hostHeight: number;
|
||||
reservedLift: number;
|
||||
topInset: number;
|
||||
}): { bottom: number; maxHeight: number } {
|
||||
"worklet";
|
||||
return {
|
||||
bottom: input.hostHeight - input.anchorTop + OFFSET_FROM_ANCHOR,
|
||||
maxHeight: Math.max(
|
||||
0,
|
||||
input.anchorTop - input.reservedLift - input.topInset - OFFSET_FROM_ANCHOR * 2,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
Pressable,
|
||||
type ListRenderItemInfo,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
type ViewToken,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -28,6 +29,7 @@ import { Button } from "./button";
|
||||
import { HighlightedText } from "./highlighted-text";
|
||||
import { mutedIconColorMapping } from "./icon-color";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
export type AutocompleteOptionIcon =
|
||||
@@ -78,8 +80,6 @@ const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
|
||||
|
||||
const ROW_ICON_SIZE = 14;
|
||||
const ROW_MIN_HEIGHT = 36;
|
||||
const DEFAULT_MAX_HEIGHT = 220;
|
||||
|
||||
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
|
||||
|
||||
@@ -92,17 +92,21 @@ function removeBoltGlyphs(value?: string): string | undefined {
|
||||
}
|
||||
|
||||
interface AutocompleteRowProps {
|
||||
index: number;
|
||||
option: AutocompleteOption;
|
||||
isSelected: boolean;
|
||||
showArgumentHint: boolean;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
const AutocompleteRow = memo(function AutocompleteRow({
|
||||
function AutocompleteRow({
|
||||
index,
|
||||
option,
|
||||
isSelected,
|
||||
showArgumentHint,
|
||||
onSelect,
|
||||
onRowLayout,
|
||||
}: AutocompleteRowProps) {
|
||||
const label = removeBoltGlyphs(option.label) ?? option.label;
|
||||
const description = removeBoltGlyphs(option.description);
|
||||
@@ -110,6 +114,10 @@ const AutocompleteRow = memo(function AutocompleteRow({
|
||||
const Icon = ROW_ICONS[option.icon];
|
||||
const labelMatches = label === option.label ? option.labelMatches : undefined;
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onRowLayout(index, event),
|
||||
[index, onRowLayout],
|
||||
);
|
||||
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -121,6 +129,7 @@ const AutocompleteRow = memo(function AutocompleteRow({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onLayout={handleLayout}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
// Name and description only; the hint and tag repeat what the name implies.
|
||||
@@ -146,7 +155,7 @@ const AutocompleteRow = memo(function AutocompleteRow({
|
||||
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -193,212 +202,94 @@ function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
type ListEnd = "composer" | "far";
|
||||
|
||||
/**
|
||||
* The scroller a list is shown in: one for each result set, resting at one end of it. A scroller
|
||||
* starts at its resting end with only the rows nearest it, so the list opens against the composer
|
||||
* and reaches the far end when the selection wraps there without mounting the rows in between.
|
||||
* A scroller that outlived its results would keep its scroll position and its mounted rows: on
|
||||
* native a scroll command reaches the screen after the rows it was meant for.
|
||||
*/
|
||||
function useScroller(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): { key: string; restsAt: ListEnd } {
|
||||
const [scroller, setScroller] = useState<{
|
||||
options: readonly AutocompleteOption[];
|
||||
resultSet: number;
|
||||
restsAt: ListEnd;
|
||||
}>({ options, resultSet: 0, restsAt: "composer" });
|
||||
let next = scroller;
|
||||
if (next.options !== options) {
|
||||
next = isSameResultSet(next.options, options)
|
||||
? { ...next, options }
|
||||
: { options, resultSet: next.resultSet + 1, restsAt: "composer" };
|
||||
}
|
||||
const restsAt = endSelected(options, selectedIndex) ?? next.restsAt;
|
||||
if (restsAt !== next.restsAt) {
|
||||
next = { ...next, restsAt };
|
||||
}
|
||||
if (next !== scroller) {
|
||||
setScroller(next);
|
||||
}
|
||||
return { key: `${next.resultSet}:${next.restsAt}`, restsAt: next.restsAt };
|
||||
}
|
||||
|
||||
/** The composer builds a new list on every keystroke and refetch, often of the results it had. */
|
||||
function isSameResultSet(
|
||||
shown: readonly AutocompleteOption[],
|
||||
arrived: readonly AutocompleteOption[],
|
||||
): boolean {
|
||||
return (
|
||||
shown.length === arrived.length && shown.every((option, row) => option.id === arrived[row].id)
|
||||
);
|
||||
}
|
||||
|
||||
/** The end of the list the selection sits at, if it sits at one. The best match is last. */
|
||||
function endSelected(
|
||||
options: readonly AutocompleteOption[],
|
||||
selectedIndex: number,
|
||||
): ListEnd | null {
|
||||
if (selectedIndex === options.length - 1) return "composer";
|
||||
if (selectedIndex === 0) return "far";
|
||||
return null;
|
||||
}
|
||||
|
||||
const FULLY_VISIBLE = { itemVisiblePercentThreshold: 100 };
|
||||
|
||||
function ListPadding() {
|
||||
return <View style={styles.listPadding} />;
|
||||
}
|
||||
|
||||
function keyOfOption(option: AutocompleteOption): string {
|
||||
return option.id;
|
||||
}
|
||||
|
||||
interface AutocompleteRowsProps {
|
||||
/** Nearest the resting end first. */
|
||||
rows: readonly AutocompleteOption[];
|
||||
selectedRow: number;
|
||||
restsAt: ListEnd;
|
||||
maxHeight: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows, read from the end the list rests at. Against the composer the list is inverted. Either
|
||||
* way its resting scroll position is that end and the rows nearest it mount first, so a list of any
|
||||
* length opens where it rests.
|
||||
*/
|
||||
function AutocompleteRows({
|
||||
rows,
|
||||
selectedRow,
|
||||
restsAt,
|
||||
maxHeight,
|
||||
onSelect,
|
||||
}: AutocompleteRowsProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const listRef = useRef<FlatList<AutocompleteOption>>(null);
|
||||
const fullyVisibleRows = useRef<{ first: number; last: number } | null>(null);
|
||||
|
||||
// The row the keyboard selected, until it has been brought into the viewport. The row at the
|
||||
// other end is never revealed here: selecting it gives the list a scroller that rests there.
|
||||
const pendingReveal = useRef<number | null>(null);
|
||||
const isPendingRowMeasured = useRef(true);
|
||||
const revealPendingRow = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
const row = pendingReveal.current;
|
||||
const visible = fullyVisibleRows.current;
|
||||
if (!list || row === null || !visible) return;
|
||||
const span = visible.last - visible.first;
|
||||
if (row >= visible.first && row <= visible.last) {
|
||||
pendingReveal.current = null;
|
||||
} else if (row === 0) {
|
||||
list.scrollToOffset({ offset: 0, animated: false });
|
||||
pendingReveal.current = null;
|
||||
fullyVisibleRows.current = { first: 0, last: span };
|
||||
} else {
|
||||
// The row comes in at the edge it was beyond.
|
||||
const isNearer = row < visible.first;
|
||||
isPendingRowMeasured.current = true;
|
||||
list.scrollToIndex({ index: row, viewPosition: isNearer ? 0 : 1, animated: false });
|
||||
// A row that has not mounted has no position yet: the list was sent towards it instead, and
|
||||
// is asked again as rows mount.
|
||||
if (!isPendingRowMeasured.current) return;
|
||||
pendingReveal.current = null;
|
||||
// The list reports what it shows after it has scrolled; the next key press may come first.
|
||||
fullyVisibleRows.current = isNearer
|
||||
? { first: row, last: row + span }
|
||||
: { first: row - span, last: row };
|
||||
}
|
||||
}, []);
|
||||
const handleScrollToIndexFailed = useCallback(
|
||||
(info: { index: number; averageItemLength: number }) => {
|
||||
isPendingRowMeasured.current = false;
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: info.index * info.averageItemLength,
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: ViewToken<AutocompleteOption>[] }) => {
|
||||
const indexes = viewableItems.flatMap((token) => (token.index === null ? [] : [token.index]));
|
||||
if (indexes.length === 0) return;
|
||||
fullyVisibleRows.current = { first: Math.min(...indexes), last: Math.max(...indexes) };
|
||||
revealPendingRow();
|
||||
},
|
||||
[revealPendingRow],
|
||||
);
|
||||
|
||||
// A viewport that got shorter can leave the selected row outside it.
|
||||
const handleLayout = useCallback(() => {
|
||||
// The list reports the rows its new height shows before the row is checked against them.
|
||||
listRef.current?.recordInteraction();
|
||||
pendingReveal.current = selectedRow < 0 ? null : selectedRow;
|
||||
revealPendingRow();
|
||||
}, [revealPendingRow, selectedRow]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<AutocompleteOption>) => (
|
||||
<AutocompleteRow
|
||||
option={item}
|
||||
isSelected={index === selectedRow}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
),
|
||||
[isCompact, onSelect, selectedRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted={restsAt === "composer"}
|
||||
data={rows}
|
||||
renderItem={renderRow}
|
||||
keyExtractor={keyOfOption}
|
||||
// The first commit fills the tallest popover, so the popover never opens short and grows.
|
||||
initialNumToRender={Math.ceil(maxHeight / ROW_MIN_HEIGHT) + 1}
|
||||
removeClippedSubviews={false}
|
||||
viewabilityConfig={FULLY_VISIBLE}
|
||||
onViewableItemsChanged={handleViewableItemsChanged}
|
||||
onLayout={handleLayout}
|
||||
onContentSizeChange={revealPendingRow}
|
||||
onScrollToIndexFailed={handleScrollToIndexFailed}
|
||||
// The list's padding. As a header and footer it counts in the list's own scroll positions.
|
||||
ListHeaderComponent={ListPadding}
|
||||
ListFooterComponent={ListPadding}
|
||||
style={styles.scrollView}
|
||||
keyboardShouldPersistTaps="always"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
status,
|
||||
maxHeight = DEFAULT_MAX_HEIGHT,
|
||||
maxHeight = 220,
|
||||
}: AutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const scroller = useScroller(options, selectedIndex);
|
||||
const rows = useMemo(
|
||||
() => (scroller.restsAt === "composer" ? options.toReversed() : options),
|
||||
[options, scroller.restsAt],
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
||||
const viewportHeightRef = useRef(0);
|
||||
const scrollOffsetRef = useRef(0);
|
||||
|
||||
const ensureActiveItemVisible = useCallback(() => {
|
||||
if (selectedIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = getScrollOffsetToRevealItem({
|
||||
currentOffset: scrollOffsetRef.current,
|
||||
viewportHeight: viewportHeightRef.current,
|
||||
itemTop: layout.top,
|
||||
itemHeight: layout.height,
|
||||
});
|
||||
|
||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = nextOffset;
|
||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
rowLayoutsRef.current.clear();
|
||||
scrollOffsetRef.current = 0;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
pinToBottom();
|
||||
}, [options, pinToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [ensureActiveItemVisible, options.length]);
|
||||
|
||||
const handleScrollViewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
const selectedRow =
|
||||
scroller.restsAt === "composer" && selectedIndex >= 0
|
||||
? options.length - 1 - selectedIndex
|
||||
: selectedIndex;
|
||||
|
||||
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
rowLayoutsRef.current.set(index, {
|
||||
top: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
ensureActiveItemVisible();
|
||||
},
|
||||
[ensureActiveItemVisible],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
|
||||
|
||||
const noResultsMessage = t("common.empty.noResults");
|
||||
@@ -417,14 +308,28 @@ export function Autocomplete({
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<AutocompleteRows
|
||||
key={scroller.key}
|
||||
rows={rows}
|
||||
selectedRow={selectedRow}
|
||||
restsAt={scroller.restsAt}
|
||||
maxHeight={maxHeight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
onLayout={handleScrollViewLayout}
|
||||
onContentSizeChange={pinToBottom}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<AutocompleteRow
|
||||
key={option.id}
|
||||
index={index}
|
||||
option={option}
|
||||
isSelected={index === selectedIndex}
|
||||
showArgumentHint={!isCompact}
|
||||
onSelect={onSelect}
|
||||
onRowLayout={handleRowLayout}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -445,14 +350,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
listPadding: {
|
||||
height: theme.spacing[1],
|
||||
scrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: ROW_MIN_HEIGHT,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface RevealActiveItemHandlers {
|
||||
* content. Measuring on selection/list changes also covers retained rows that
|
||||
* move or keep their size without emitting another onLayout event on web.
|
||||
* A list replacement resets to the top only when selection restarts there.
|
||||
* Bottom-anchored lists share only getScrollOffsetToRevealItem.
|
||||
*/
|
||||
export function useRevealActiveItem(options: {
|
||||
activeIndex: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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: "التشخيص",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "診断",
|
||||
|
||||
@@ -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: "진단",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Диагностика",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
|
||||
/** A provider's icon at row size, drawn in the foreground colour. */
|
||||
export function ProviderGlyph({ serverId, provider }: { serverId: string; provider: string }) {
|
||||
const Icon = useProviderIcon(provider, serverId);
|
||||
return <Icon size={ICON_SIZE.md} color={styles.glyph.color} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
glyph: { color: theme.colors.foreground },
|
||||
}));
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useProviderInstallation } from "@/components/provider-installation";
|
||||
import { SettingsAddRow, SettingsCard, SettingsSection } from "@/components/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { AddProviderSheet } from "./add-provider-sheet";
|
||||
import { useProviderConfigActions, type ProviderEntry } from "./provider-config";
|
||||
import { ProviderGlyph } from "./provider-glyph";
|
||||
import { ProviderNotice } from "./provider-notice";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
import { readRowSummary } from "./row-summary";
|
||||
import type { ProviderSheetFrame } from "./sheet-frame";
|
||||
|
||||
export interface ProviderListProps {
|
||||
serverId: string;
|
||||
/** Opens a provider's detail wherever the host shows it: a Settings page or a sheet. */
|
||||
onOpenProvider: (providerId: string) => void;
|
||||
/** Where the sheets the list opens sit. Defaults to a sheet over the current screen. */
|
||||
sheetFrame?: ProviderSheetFrame;
|
||||
}
|
||||
|
||||
/** The host's providers, each saying how it is, and the way to add one. */
|
||||
export function ProviderList({ serverId, onOpenProvider, sheetFrame }: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId) && serverId.length > 0;
|
||||
const { entries, isLoading } = useProvidersSnapshot(serverId);
|
||||
const { setEnabled, togglingProviderId } = useProviderConfigActions(serverId);
|
||||
const definitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const openAdd = useCallback(() => setAdding(true), []);
|
||||
const closeAdd = useCallback(() => setAdding(false), []);
|
||||
const handleAdded = useCallback(
|
||||
(providerId: string) => {
|
||||
setAdding(false);
|
||||
onOpenProvider(providerId);
|
||||
},
|
||||
[onOpenProvider],
|
||||
);
|
||||
|
||||
const addButton = useMemo(
|
||||
() =>
|
||||
isConnected ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={Plus}
|
||||
onPress={openAdd}
|
||||
accessibilityLabel={t("settings.providers.addProvider")}
|
||||
testID="provider-list-add"
|
||||
>
|
||||
{t("settings.providers.list.add")}
|
||||
</Button>
|
||||
) : null,
|
||||
[isConnected, openAdd, t],
|
||||
);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (!isConnected) {
|
||||
body = <ProviderNotice message={t("settings.providers.unavailable")} />;
|
||||
} else if (isLoading) {
|
||||
body = <ProviderRowsSkeleton />;
|
||||
} else {
|
||||
body = (
|
||||
<SettingsCard testID="provider-list">
|
||||
{definitions.map((definition) => {
|
||||
const entry = entries?.find((candidate) => candidate.provider === definition.id);
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<ListedProvider
|
||||
key={definition.id}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
label={definition.label}
|
||||
toggling={togglingProviderId === definition.id}
|
||||
onOpen={onOpenProvider}
|
||||
onToggle={setEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<SettingsAddRow
|
||||
label={t("settings.providers.addProvider")}
|
||||
onPress={openAdd}
|
||||
testID="provider-list-add-row"
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t("settings.providers.title")}
|
||||
trailing={addButton}
|
||||
testID="host-page-providers-card"
|
||||
>
|
||||
{body}
|
||||
</SettingsSection>
|
||||
{isConnected ? (
|
||||
<AddProviderSheet
|
||||
serverId={serverId}
|
||||
visible={adding}
|
||||
onClose={closeAdd}
|
||||
onAdded={handleAdded}
|
||||
frame={sheetFrame}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row reads its provider's installation itself; the detail shares the same requests. */
|
||||
function ListedProvider({
|
||||
serverId,
|
||||
entry,
|
||||
label,
|
||||
toggling,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
serverId: string;
|
||||
entry: ProviderEntry;
|
||||
label: string;
|
||||
toggling: boolean;
|
||||
onOpen: (provider: string) => void;
|
||||
onToggle: (provider: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const installation = useProviderInstallation({
|
||||
serverId,
|
||||
provider: entry.provider,
|
||||
visible: true,
|
||||
});
|
||||
const icon = useMemo(
|
||||
() => <ProviderGlyph serverId={serverId} provider={entry.provider} />,
|
||||
[entry.provider, serverId],
|
||||
);
|
||||
return (
|
||||
<ProviderRow
|
||||
provider={entry.provider}
|
||||
label={label}
|
||||
icon={icon}
|
||||
summary={readRowSummary(entry, installation)}
|
||||
enabled={entry.enabled}
|
||||
toggling={toggling}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_ROW_KEYS = [0, 1, 2, 3].map((index) => `provider-skeleton-row-${index}`);
|
||||
|
||||
function ProviderRowsSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<View
|
||||
style={settingsStyles.card}
|
||||
accessibilityLabel={t("settings.providers.loading")}
|
||||
testID="providers-skeleton"
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key, index) => (
|
||||
<View key={key} style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]}>
|
||||
<View style={styles.skeletonContent}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonIcon} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
|
||||
</View>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonSwitch} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
skeletonContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
skeletonIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonTitle: {
|
||||
width: 160,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSwitch: {
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,436 @@
|
||||
import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsSection } from "@/components/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { SearchField } from "@/components/ui/search-field";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./discovered-models";
|
||||
import type { ProviderEntry } from "./provider-config";
|
||||
|
||||
const ThemedTrash = withUnistyles(Trash2);
|
||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
const ThemedTextInput = withUnistyles(AdaptiveTextInput, (theme: Theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
function rankModels<T>(items: T[], query: string, fields: (item: T) => string[]): T[] {
|
||||
if (!query) 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's models: search, the ones it reports, and the ones added by hand. Shown only while
|
||||
* the provider runs, so the caller decides whether it appears.
|
||||
*/
|
||||
export function ProviderModels({
|
||||
serverId,
|
||||
entry,
|
||||
refresh,
|
||||
}: {
|
||||
serverId: string;
|
||||
entry: ProviderEntry;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const provider = entry.provider;
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
|
||||
|
||||
const additionalModels = useMemo(
|
||||
() => config?.providers?.[provider]?.additionalModels ?? [],
|
||||
[config?.providers, provider],
|
||||
);
|
||||
const loading = entry.status === "loading";
|
||||
// A refresh briefly empties the snapshot's models; keep the last list on screen meanwhile.
|
||||
const discoveredCacheRef = useRef<ProviderDiscoveredModelsCache | null>(null);
|
||||
const { models: discoveredModels, cache } = resolveProviderDiscoveredModels({
|
||||
serverId,
|
||||
provider,
|
||||
currentModels: entry.models,
|
||||
providerSnapshotRefreshing: loading,
|
||||
previousCache: discoveredCacheRef.current,
|
||||
});
|
||||
discoveredCacheRef.current = cache;
|
||||
|
||||
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 handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
|
||||
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(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 customTrailing = useMemo(
|
||||
() => (
|
||||
<View style={styles.headerTrailing}>
|
||||
{additionalModels.length > 0 ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{additionalModels.length}</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={Plus}
|
||||
onPress={handleOpenAddSheet}
|
||||
accessibilityLabel={t("settings.providers.models.addModel")}
|
||||
testID="provider-add-model"
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[additionalModels.length, handleOpenAddSheet, t],
|
||||
);
|
||||
|
||||
const discoveredTrailing = useMemo(
|
||||
() =>
|
||||
discoveredModels.length > 0 ? (
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{discoveredModels.length}</Text>
|
||||
) : null,
|
||||
[discoveredModels.length],
|
||||
);
|
||||
|
||||
let discoveredBody: React.ReactNode;
|
||||
if (discoveredModels.length === 0) {
|
||||
discoveredBody = (
|
||||
<MutedRow>
|
||||
{loading
|
||||
? t("settings.providers.models.loading")
|
||||
: t("settings.providers.models.noneDetected")}
|
||||
</MutedRow>
|
||||
);
|
||||
} else if (filteredDiscovered.length === 0) {
|
||||
discoveredBody = <MutedRow>{t("settings.providers.models.noSearchMatches")}</MutedRow>;
|
||||
} else {
|
||||
discoveredBody = filteredDiscovered.map((model, index) => (
|
||||
<DiscoveredModelRow key={model.id} model={model} first={index === 0} />
|
||||
));
|
||||
}
|
||||
|
||||
let customBody: React.ReactNode;
|
||||
if (additionalModels.length === 0) {
|
||||
customBody = <MutedRow>{t("settings.providers.models.noCustom")}</MutedRow>;
|
||||
} else if (filteredCustom.length === 0) {
|
||||
customBody = <MutedRow>{t("settings.providers.models.noSearchMatches")}</MutedRow>;
|
||||
} else {
|
||||
customBody = filteredCustom.map((model, index) => (
|
||||
<CustomModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
first={index === 0}
|
||||
deleting={deletingModelId === model.id}
|
||||
onDelete={handleDeleteCustom}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="provider-models">
|
||||
<View style={styles.searchRail}>
|
||||
<SearchField
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("settings.providers.models.searchPlaceholder")}
|
||||
clearAccessibilityLabel={t("sessions.actions.clearSearch")}
|
||||
testID="provider-settings-search"
|
||||
/>
|
||||
</View>
|
||||
<SettingsSection
|
||||
title={t("settings.providers.models.discovered")}
|
||||
trailing={discoveredTrailing}
|
||||
>
|
||||
<View style={settingsStyles.card}>{discoveredBody}</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.providers.models.custom")} trailing={customTrailing}>
|
||||
<View style={settingsStyles.card}>{customBody}</View>
|
||||
</SettingsSection>
|
||||
<AddCustomModelSheet
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
additionalModels={additionalModels}
|
||||
visible={addSheetOpen}
|
||||
onClose={handleCloseAddSheet}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MutedRow({ children }: { children: string }) {
|
||||
return (
|
||||
<View style={styles.modelRow}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscoveredModelRow({ model, first }: { model: AgentModelDefinition; first: boolean }) {
|
||||
return (
|
||||
<View style={[styles.modelRow, !first && settingsStyles.rowBorder]}>
|
||||
<Text style={styles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={styles.monoHint} numberOfLines={1} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
<Text style={styles.descriptionInline} numberOfLines={1}>
|
||||
{model.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomModelRow({
|
||||
model,
|
||||
first,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
model: ProviderProfileModel;
|
||||
first: boolean;
|
||||
deleting: boolean;
|
||||
onDelete: (modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
||||
|
||||
return (
|
||||
<View style={[styles.modelRow, !first && settingsStyles.rowBorder]}>
|
||||
<Text style={[styles.modelTitle, styles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.monoHint, styles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={styles.modelRowFiller} />
|
||||
<ToolbarButton
|
||||
label={t("settings.providers.models.removeModel", { id: model.id })}
|
||||
onPress={handleDelete}
|
||||
disabled={deleting}
|
||||
compact={compact}
|
||||
>
|
||||
<ThemedTrash
|
||||
size={paneContentToolbarIconSize(compact)}
|
||||
uniProps={destructiveColorMapping}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCustomModelSheet({
|
||||
serverId,
|
||||
provider,
|
||||
additionalModels,
|
||||
visible,
|
||||
onClose,
|
||||
refresh,
|
||||
}: {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
additionalModels: ProviderProfileModel[];
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
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: unknown) => {
|
||||
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={styles.formGroup}>
|
||||
<Text style={styles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
||||
<ThemedTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.formInput, isWeb && { outlineStyle: "none" }]}
|
||||
/>
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<View style={styles.formActions}>
|
||||
<Button variant="secondary" onPress={onClose} disabled={saving}>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" onPress={handleAdd} disabled={!canAdd} loading={saving}>
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchRail: {
|
||||
flexDirection: "row",
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
headerTrailing: {
|
||||
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],
|
||||
},
|
||||
modelTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
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,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
formGroup: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
formLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
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,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
formActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
/** A card holding one muted line, where a provider list or page has nothing to show yet. */
|
||||
export function ProviderNotice({ message, testID }: { message: string; testID?: string }) {
|
||||
return (
|
||||
<View style={[settingsStyles.card, styles.card]} testID={testID}>
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
card: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,178 @@
|
||||
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 { Text } from "react-native";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
import type { RowSummary } from "./row-summary";
|
||||
|
||||
// Load translations so rows expose their real accessible names and copy.
|
||||
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 ICON = <Text testID="provider-icon">*</Text>;
|
||||
|
||||
function mount(summary: RowSummary, options: { enabled?: boolean; label?: string } = {}) {
|
||||
const onOpen = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
const label = options.label ?? "Claude";
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<ProviderRow
|
||||
provider="claude"
|
||||
label={label}
|
||||
icon={ICON}
|
||||
summary={summary}
|
||||
enabled={options.enabled ?? true}
|
||||
toggling={false}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
mounted.push({ root, container });
|
||||
const view = within(container);
|
||||
return {
|
||||
view,
|
||||
onOpen,
|
||||
onToggle,
|
||||
row: () => view.getByTestId("provider-row-claude"),
|
||||
opener: () => view.getByRole("button", { name: `${label} provider details` }),
|
||||
toggle: () => view.queryByRole("switch", { name: `Enable ${label}` }),
|
||||
};
|
||||
}
|
||||
|
||||
function pressEnter(target: HTMLElement) {
|
||||
target.focus();
|
||||
for (const type of ["keydown", "keyup"]) {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent(type, { key: "Enter", bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function textOrder(row: HTMLElement, texts: string[]): number[] {
|
||||
const content = row.textContent ?? "";
|
||||
return texts.map((text) => content.indexOf(text));
|
||||
}
|
||||
|
||||
describe("provider row", () => {
|
||||
it("names the provider with its label and says Disabled when it is off", () => {
|
||||
const row = mount({ kind: "disabled" }, { enabled: false, label: "Codex" });
|
||||
expect(row.row().textContent).toContain("Codex");
|
||||
expect(row.view.getByText("Disabled")).toBeTruthy();
|
||||
expect(row.toggle()?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("composes the row as icon, label, version and models, then switch", () => {
|
||||
const row = mount({ kind: "ready", version: "2.1.321", models: 3 });
|
||||
const element = row.row();
|
||||
expect(row.view.getByText("2.1.321 · 3 models")).toBeTruthy();
|
||||
const [icon, label, summary] = textOrder(element, ["*", "Claude", "2.1.321 · 3 models"]);
|
||||
expect(icon).toBeLessThan(label);
|
||||
expect(label).toBeLessThan(summary);
|
||||
const toggle = row.toggle();
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(
|
||||
row.view.getByText("2.1.321 · 3 models").compareDocumentPosition(toggle as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks its host to open the provider when the row is pressed", () => {
|
||||
const row = mount({ kind: "disabled" }, { enabled: false });
|
||||
act(() => row.opener().click());
|
||||
expect(row.onOpen).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
|
||||
it("keeps the switch out of the row's button, so Enter on the switch only toggles", () => {
|
||||
const row = mount({ kind: "ready", version: null, models: 3 });
|
||||
const toggle = row.toggle() as HTMLElement;
|
||||
expect(row.opener().contains(toggle)).toBe(false);
|
||||
// Dispatched in one act, so the test ends before the Switch's animation runs a frame.
|
||||
act(() => pressEnter(toggle));
|
||||
expect(row.onToggle).toHaveBeenCalledWith("claude", false);
|
||||
expect(row.onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles the provider from the switch without opening it", () => {
|
||||
const row = mount({ kind: "ready", version: null, models: 3 });
|
||||
act(() => row.toggle()?.click());
|
||||
expect(row.onToggle).toHaveBeenCalledWith("claude", false);
|
||||
expect(row.onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the state line readable to screen readers, and the name to the row's button", () => {
|
||||
const row = mount({ kind: "failing", message: "opencode exited with code 1" });
|
||||
expect(row.view.getByText("opencode exited with code 1").closest("[aria-hidden]")).toBeNull();
|
||||
expect(row.view.getByText("Claude").closest("[aria-hidden]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("says an update is available after the version", () => {
|
||||
const row = mount({ kind: "updateAvailable", version: "2.1.321" });
|
||||
expect(row.view.getByText("2.1.321 · Update available")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the target while an update runs, or says Updating... without one", () => {
|
||||
expect(
|
||||
mount({ kind: "updating", target: "1.0.31" }).view.getByText("Updating to 1.0.31..."),
|
||||
).toBeTruthy();
|
||||
expect(mount({ kind: "updating", target: null }).view.getByText("Updating...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the error in the hint", () => {
|
||||
const row = mount({ kind: "failing", message: "opencode exited with code 1" });
|
||||
expect(row.view.getByText("opencode exited with code 1")).toBeTruthy();
|
||||
expect(row.toggle()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("offers no switch on a provider that is not installed", () => {
|
||||
const row = mount({ kind: "notInstalled" });
|
||||
expect(row.view.getByText("Not installed")).toBeTruthy();
|
||||
expect(row.toggle()).toBeNull();
|
||||
act(() => row.opener().click());
|
||||
expect(row.onOpen).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
|
||||
it("keeps the row as tall while its line loads as once it has arrived", () => {
|
||||
const pending = mount({ kind: "pending" }).row().getBoundingClientRect().height;
|
||||
const ready = mount({ kind: "ready", version: "2.1.321", models: 3 })
|
||||
.row()
|
||||
.getBoundingClientRect().height;
|
||||
const updating = mount({ kind: "updating", target: "1.0.31" })
|
||||
.row()
|
||||
.getBoundingClientRect().height;
|
||||
const failing = mount({ kind: "failing", message: "boom" })
|
||||
.row()
|
||||
.getBoundingClientRect().height;
|
||||
expect(pending).toBe(ready);
|
||||
expect(updating).toBe(ready);
|
||||
expect(failing).toBe(ready);
|
||||
});
|
||||
|
||||
it("keeps a long, multi-line error to the row's one line", () => {
|
||||
const ready = mount({ kind: "ready", version: "2.1.321", models: 3 })
|
||||
.row()
|
||||
.getBoundingClientRect().height;
|
||||
const message = [
|
||||
"ACP agent process exited before initialization completed (exit code 1)",
|
||||
"broken-acp: exited with code 1",
|
||||
].join("\n");
|
||||
const failing = mount({ kind: "failing", message }).row().getBoundingClientRect().height;
|
||||
expect(failing).toBe(ready);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsDetailRow, type SettingsRowHintStatus } from "@/components/settings";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { rowHasSwitch, type RowSummary } from "./row-summary";
|
||||
|
||||
export interface ProviderRowProps {
|
||||
provider: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
summary: RowSummary;
|
||||
enabled: boolean;
|
||||
/** The Enabled switch is being saved. */
|
||||
toggling: boolean;
|
||||
onOpen: (provider: string) => void;
|
||||
onToggle: (provider: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider in the list: its icon and name, a line saying how it is, the Enabled switch when
|
||||
* there is something to switch, and the chevron to its detail.
|
||||
*/
|
||||
export function ProviderRow({
|
||||
provider,
|
||||
label,
|
||||
icon,
|
||||
summary,
|
||||
enabled,
|
||||
toggling,
|
||||
onOpen,
|
||||
onToggle,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleOpen = useCallback(() => onOpen(provider), [onOpen, provider]);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => onToggle(provider, value),
|
||||
[onToggle, provider],
|
||||
);
|
||||
const hint = describeRowSummary(summary, t);
|
||||
const hasSwitch = rowHasSwitch(summary);
|
||||
const toggle = useMemo(
|
||||
() =>
|
||||
hasSwitch ? (
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggle}
|
||||
disabled={toggling}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: label })}
|
||||
/>
|
||||
) : null,
|
||||
[enabled, handleToggle, hasSwitch, label, t, toggling],
|
||||
);
|
||||
return (
|
||||
<SettingsDetailRow
|
||||
leading={icon}
|
||||
label={label}
|
||||
hint={hint.text}
|
||||
hintStatus={hint.status}
|
||||
control={toggle}
|
||||
accessibilityLabel={t("settings.providers.providerDetails", { name: label })}
|
||||
onPress={handleOpen}
|
||||
testID={`provider-row-${provider}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function describeRowSummary(
|
||||
summary: RowSummary,
|
||||
t: TFunction,
|
||||
): { text: string | undefined; status: SettingsRowHintStatus | undefined } {
|
||||
switch (summary.kind) {
|
||||
case "pending":
|
||||
return { text: undefined, status: "pending" };
|
||||
case "ready": {
|
||||
const models =
|
||||
summary.models === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: summary.models });
|
||||
const parts = [summary.version, summary.models > 0 ? models : null].filter(Boolean);
|
||||
return {
|
||||
text: parts.length ? parts.join(" · ") : t("settings.providers.statuses.available"),
|
||||
status: undefined,
|
||||
};
|
||||
}
|
||||
case "updateAvailable":
|
||||
return {
|
||||
text: [summary.version, t("settings.providers.installation.updateAvailable")]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
status: undefined,
|
||||
};
|
||||
case "updating":
|
||||
return {
|
||||
text: summary.target
|
||||
? t("settings.providers.list.updatingTo", { version: summary.target })
|
||||
: t("settings.providers.installation.updating"),
|
||||
status: "busy",
|
||||
};
|
||||
case "failing":
|
||||
return {
|
||||
text: summary.message ?? t("settings.providers.diagnostic.unknownError"),
|
||||
status: "error",
|
||||
};
|
||||
case "notInstalled":
|
||||
return { text: t("settings.providers.statuses.notInstalled"), status: undefined };
|
||||
case "disabled":
|
||||
return { text: t("settings.providers.statuses.disabled"), status: undefined };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderInstallationUpdate } from "@getpaseo/protocol/messages";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderInstallationRead } from "@/components/provider-installation";
|
||||
import { readRowSummary } from "./row-summary";
|
||||
|
||||
type Read = Pick<ProviderInstallationRead, "state" | "updates" | "action">;
|
||||
|
||||
function entry(overrides: Partial<ProviderSnapshotEntry> = {}): ProviderSnapshotEntry {
|
||||
return {
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
modes: [],
|
||||
models: [
|
||||
{ provider: "claude", id: "opus", label: "Opus" },
|
||||
{ provider: "claude", id: "sonnet", label: "Sonnet" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installed(version: string | undefined, overrides: Partial<Read> = {}): Read {
|
||||
return {
|
||||
state: {
|
||||
status: "ready",
|
||||
installation: { inspection: { status: "installed", version }, canUpdate: true },
|
||||
},
|
||||
updates: { status: "ready", updateCheck: { status: "current" } },
|
||||
action: { current: null, starting: false, startError: null, start: () => undefined },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function update(operation: ProviderInstallationUpdate["operation"]): ProviderInstallationUpdate {
|
||||
return { operation, output: [] };
|
||||
}
|
||||
|
||||
const oldHost: Read = {
|
||||
state: { status: "unsupported" },
|
||||
updates: { status: "hidden" },
|
||||
action: undefined,
|
||||
};
|
||||
|
||||
describe("provider row summary", () => {
|
||||
it("reads version and model count for a provider that runs", () => {
|
||||
expect(readRowSummary(entry(), installed("2.1.321"))).toEqual({
|
||||
kind: "ready",
|
||||
version: "2.1.321",
|
||||
models: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("says an update is available after the version", () => {
|
||||
const read = installed("2.1.321", {
|
||||
updates: { status: "ready", updateCheck: { status: "available", versions: ["2.1.400"] } },
|
||||
});
|
||||
expect(readRowSummary(entry(), read)).toEqual({ kind: "updateAvailable", version: "2.1.321" });
|
||||
});
|
||||
|
||||
it("names the target version while an update runs, when the check reported one", () => {
|
||||
const running = update({ status: "running", id: "u1", startedAt: "2026-11-14T10:00:00Z" });
|
||||
const one = installed("1.0.30", {
|
||||
updates: { status: "ready", updateCheck: { status: "available", versions: ["1.0.31"] } },
|
||||
action: { current: running, starting: false, startError: null, start: () => undefined },
|
||||
});
|
||||
expect(readRowSummary(entry(), one)).toEqual({ kind: "updating", target: "1.0.31" });
|
||||
|
||||
const unknown = installed("1.0.30", {
|
||||
updates: { status: "ready", updateCheck: { status: "available" } },
|
||||
action: { current: null, starting: true, startError: null, start: () => undefined },
|
||||
});
|
||||
expect(readRowSummary(entry(), unknown)).toEqual({ kind: "updating", target: null });
|
||||
});
|
||||
|
||||
it("reports a failed update as the fault", () => {
|
||||
const failed = update({
|
||||
status: "failed",
|
||||
id: "u1",
|
||||
startedAt: "2026-11-14T10:00:00Z",
|
||||
finishedAt: "2026-11-14T10:01:00Z",
|
||||
message: "npm exited with code 1",
|
||||
});
|
||||
const read = installed("1.0.30", {
|
||||
action: { current: failed, starting: false, startError: null, start: () => undefined },
|
||||
});
|
||||
expect(readRowSummary(entry(), read)).toEqual({
|
||||
kind: "failing",
|
||||
message: "npm exited with code 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the provider's error", () => {
|
||||
expect(
|
||||
readRowSummary(entry({ status: "error", error: " opencode exited with code 1 " }), oldHost),
|
||||
).toEqual({ kind: "failing", message: "opencode exited with code 1" });
|
||||
expect(readRowSummary(entry({ status: "error" }), oldHost)).toEqual({
|
||||
kind: "failing",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls a missing CLI Not installed, not a fault", () => {
|
||||
expect(readRowSummary(entry({ status: "unavailable" }), oldHost)).toEqual({
|
||||
kind: "notInstalled",
|
||||
});
|
||||
const missing: Read = {
|
||||
...oldHost,
|
||||
state: { status: "ready", installation: { inspection: { status: "not_installed" } } },
|
||||
};
|
||||
expect(readRowSummary(entry({ status: "error", error: "spawn ENOENT" }), missing)).toEqual({
|
||||
kind: "notInstalled",
|
||||
});
|
||||
});
|
||||
|
||||
it("says Disabled for a provider that is off", () => {
|
||||
expect(readRowSummary(entry({ enabled: false }), installed("2.1.321"))).toEqual({
|
||||
kind: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls a disabled provider whose CLI is missing Not installed, so turning it on changes nothing", () => {
|
||||
const missing: Read = {
|
||||
...oldHost,
|
||||
state: { status: "ready", installation: { inspection: { status: "not_installed" } } },
|
||||
};
|
||||
expect(readRowSummary(entry({ enabled: false, status: "unavailable" }), missing)).toEqual({
|
||||
kind: "notInstalled",
|
||||
});
|
||||
// A disabled provider is never probed, so without an inspection it is only Disabled.
|
||||
expect(readRowSummary(entry({ enabled: false, status: "unavailable" }), oldHost)).toEqual({
|
||||
kind: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("holds the line until the installation is read", () => {
|
||||
const reading: Read = { ...oldHost, state: { status: "loading" } };
|
||||
expect(readRowSummary(entry(), reading)).toEqual({ kind: "pending" });
|
||||
});
|
||||
|
||||
it("holds the line while the provider's first probe runs", () => {
|
||||
expect(readRowSummary(entry({ status: "loading", models: [] }), oldHost)).toEqual({
|
||||
kind: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the last count while the provider is probed again", () => {
|
||||
expect(readRowSummary(entry({ status: "loading" }), installed("2.1.321"))).toEqual({
|
||||
kind: "ready",
|
||||
version: "2.1.321",
|
||||
models: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a count alone on hosts that cannot report installations", () => {
|
||||
expect(readRowSummary(entry(), oldHost)).toEqual({ kind: "ready", version: null, models: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ProviderInstallationRead } from "@/components/provider-installation";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import type { ProviderEntry } from "./provider-config";
|
||||
|
||||
/** What a provider's list row says under its name. */
|
||||
export type RowSummary =
|
||||
/** Not known yet; the row holds the line's space. */
|
||||
| { kind: "pending" }
|
||||
| { kind: "ready"; version: string | null; models: number }
|
||||
| { kind: "updateAvailable"; version: string | null }
|
||||
| { kind: "updating"; target: string | null }
|
||||
/** The provider or its last update failed. A null message has no detail to show. */
|
||||
| { kind: "failing"; message: string | null }
|
||||
| { kind: "notInstalled" }
|
||||
| { kind: "disabled" };
|
||||
|
||||
type InstallationFacts = Pick<ProviderInstallationRead, "state" | "updates" | "action">;
|
||||
|
||||
/**
|
||||
* One line per provider, from the snapshot and the installation read. A running update leads
|
||||
* because the user just started it; then whatever keeps the provider from running, where a missing
|
||||
* CLI is not a fault; then the version and update state once the host has answered.
|
||||
*/
|
||||
export function readRowSummary(entry: ProviderEntry, facts: InstallationFacts): RowSummary {
|
||||
return readRunningUpdate(facts) ?? readBlocker(entry, facts) ?? readSettled(entry, facts);
|
||||
}
|
||||
|
||||
function readRunningUpdate({ updates, action }: InstallationFacts): RowSummary | null {
|
||||
if (!action || !(action.starting || action.current?.operation.status === "running")) return null;
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
const versions = updateCheck?.status === "available" ? updateCheck.versions : undefined;
|
||||
return { kind: "updating", target: versions?.length === 1 ? versions[0] : null };
|
||||
}
|
||||
|
||||
function readBlocker(
|
||||
entry: ProviderEntry,
|
||||
{ state, action }: InstallationFacts,
|
||||
): RowSummary | null {
|
||||
// A missing CLI wins over Disabled, so the row offers no switch that turning on would take away.
|
||||
// Without an inspection a disabled provider is only Disabled: the host never probes it.
|
||||
const inspection = state.status === "ready" ? state.installation?.inspection : undefined;
|
||||
if (inspection?.status === "not_installed") return { kind: "notInstalled" };
|
||||
if (!entry.enabled) return { kind: "disabled" };
|
||||
if (entry.status === "unavailable") return { kind: "notInstalled" };
|
||||
if (entry.status === "error") return { kind: "failing", message: entry.error?.trim() || null };
|
||||
const operation = action?.current?.operation;
|
||||
if (operation?.status === "failed") {
|
||||
return { kind: "failing", message: operation.message.trim() || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readSettled(entry: ProviderEntry, { state, updates }: InstallationFacts): RowSummary {
|
||||
const models = filterSelectableModels(entry.models ?? null)?.length ?? 0;
|
||||
// The last count stays while the provider is probed again; only a first probe holds the line.
|
||||
if (state.status === "loading" || (entry.status === "loading" && models === 0)) {
|
||||
return { kind: "pending" };
|
||||
}
|
||||
const inspection = state.status === "ready" ? state.installation?.inspection : undefined;
|
||||
const version = inspection?.status === "installed" ? (inspection.version ?? null) : null;
|
||||
const updateCheck = updates.status === "ready" ? updates.updateCheck : null;
|
||||
if (updateCheck?.status === "available") return { kind: "updateAvailable", version };
|
||||
return { kind: "ready", version, models };
|
||||
}
|
||||
|
||||
/** A provider that is not installed has nothing to switch: the picker leaves it out already. */
|
||||
export function rowHasSwitch(summary: RowSummary): boolean {
|
||||
return summary.kind !== "notInstalled";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DimensionValue } from "react-native";
|
||||
|
||||
/**
|
||||
* Where a provider sheet sits, decided by the surface that opens it. Setup passes its own card's
|
||||
* size so the sheet covers the card exactly and reads as a page of setup.
|
||||
*/
|
||||
export interface ProviderSheetFrame {
|
||||
desktopMaxWidth?: number;
|
||||
desktopHeight?: DimensionValue;
|
||||
snapPoints?: string[];
|
||||
/** A back arrow replaces the close button: the sheet stands in for a page of the surface below. */
|
||||
returns?: boolean;
|
||||
}
|
||||
|
||||
export const OVERLAY_SHEET_FRAME: ProviderSheetFrame = { snapPoints: ["65%", "92%"] };
|
||||
@@ -1000,7 +1000,9 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
||||
}
|
||||
publish({
|
||||
...resolved,
|
||||
modelSelectorProviders: buildSelectableProviderSelectorProviders(providerEntries),
|
||||
modelSelectorProviders: buildSelectableProviderSelectorProviders(providerEntries, {
|
||||
selectedProvider: resolved.selectedProvider,
|
||||
}),
|
||||
providerResolutionByServerId,
|
||||
providerSnapshotRequest: isPendingResolution ? null : state.providerSnapshotRequest,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Pressable, Text, View } from "react-native";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native";
|
||||
import { MoreVertical, Pencil, Plus } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import type {
|
||||
PaseoConfigRaw,
|
||||
@@ -29,6 +29,7 @@ import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsGroup } from "@/components/settings/headings/settings-group";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { SettingsBackLink, SettingsDetailHeader } from "@/components/settings";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import type { ProjectEditFormSnapshot } from "@/projects/edit-form";
|
||||
@@ -170,16 +171,11 @@ function NoEditableTarget({
|
||||
function BackToProjectsButton({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
testID="project-settings-back-link"
|
||||
accessibilityLabel={t("settings.project.backToProjects")}
|
||||
<SettingsBackLink
|
||||
label={t("settings.project.backToProjects")}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
leftIcon={ArrowLeft}
|
||||
style={styles.backButton}
|
||||
>
|
||||
{t("settings.project.backToProjects")}
|
||||
</Button>
|
||||
testID="project-settings-back-link"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,33 +256,51 @@ function ProjectSettingsBody({
|
||||
const handleReload = useCallback(() => {
|
||||
void readQuery.refetch();
|
||||
}, [readQuery]);
|
||||
const backLink = useMemo(
|
||||
() =>
|
||||
showBackToProjects
|
||||
? {
|
||||
label: t("settings.project.backToProjects"),
|
||||
onPress: onBackToProjects,
|
||||
testID: "project-settings-back-link",
|
||||
}
|
||||
: undefined,
|
||||
[onBackToProjects, showBackToProjects, t],
|
||||
);
|
||||
const titleIcon = useMemo(
|
||||
() => (
|
||||
<ProjectTitleIcon
|
||||
iconDataUri={projectIconDataUri}
|
||||
projectName={selectedHost.projectName}
|
||||
projectViewKey={project.viewKey}
|
||||
/>
|
||||
),
|
||||
[project.viewKey, projectIconDataUri, selectedHost.projectName],
|
||||
);
|
||||
const editButton = useMemo(
|
||||
() => (
|
||||
<Pressable
|
||||
testID="project-edit-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.edit.title")}
|
||||
onPress={openEditSheet}
|
||||
hitSlop={8}
|
||||
style={styles.editButton}
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
),
|
||||
[openEditSheet, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<View role="main" style={styles.body}>
|
||||
{showBackToProjects ? <BackToProjectsButton onPress={onBackToProjects} /> : null}
|
||||
|
||||
<View style={styles.headerBlock}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProjectTitleIcon
|
||||
iconDataUri={projectIconDataUri}
|
||||
projectName={selectedHost.projectName}
|
||||
projectViewKey={project.viewKey}
|
||||
/>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{selectedHost.projectName}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-edit-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.edit.title")}
|
||||
onPress={openEditSheet}
|
||||
hitSlop={8}
|
||||
style={styles.editButton}
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<SettingsDetailHeader
|
||||
back={backLink}
|
||||
icon={titleIcon}
|
||||
title={selectedHost.projectName}
|
||||
trailing={editButton}
|
||||
/>
|
||||
|
||||
<ProjectEditSheet
|
||||
// A new instance per open seeds the form from the project as it stands now.
|
||||
@@ -1108,26 +1122,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
headerBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
marginBottom: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
editButton: {
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { ProviderDetailPage } from "@/provider-settings";
|
||||
import {
|
||||
SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
@@ -987,6 +988,7 @@ function SettingsSidebar({
|
||||
let selectedHostSection: HostSectionSlug | null = null;
|
||||
if (view.kind === "host") selectedHostSection = view.section;
|
||||
if (view.kind === "project") selectedHostSection = "projects";
|
||||
if (view.kind === "provider") selectedHostSection = "providers";
|
||||
if (view.kind === "plugin") selectedHostSection = "plugins";
|
||||
|
||||
const sidebarBody = (
|
||||
@@ -1130,7 +1132,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
// The host the four sections scope to: the host on the active view, otherwise
|
||||
// the picker choice, otherwise the connected local daemon, otherwise the first host.
|
||||
const activeHostServerId = useMemo(() => {
|
||||
if (view.kind === "host" || view.kind === "project" || view.kind === "plugin")
|
||||
if (
|
||||
view.kind === "host" ||
|
||||
view.kind === "project" ||
|
||||
view.kind === "plugin" ||
|
||||
view.kind === "provider"
|
||||
)
|
||||
return view.serverId;
|
||||
return resolveActiveHostServerId({
|
||||
selectedServerId: selectedSettingsHostServerId,
|
||||
@@ -1208,8 +1215,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string) => {
|
||||
selectSettingsHost(serverId);
|
||||
if (view.kind === "project") {
|
||||
const target = buildSettingsHostSectionRoute(serverId, "projects");
|
||||
if (view.kind === "project" || view.kind === "provider") {
|
||||
// The detail belongs to the old host; open the same list on the new one.
|
||||
const target = buildSettingsHostSectionRoute(
|
||||
serverId,
|
||||
view.kind === "project" ? "projects" : "providers",
|
||||
);
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
@@ -1278,6 +1289,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (view.kind === "project") {
|
||||
return t("settings.projects");
|
||||
}
|
||||
if (view.kind === "provider") return t("settings.hostSections.providers");
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
@@ -1305,6 +1317,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "provider") {
|
||||
return (
|
||||
<ProviderDetailPage
|
||||
serverId={view.serverId}
|
||||
provider={view.providerId}
|
||||
onBack={handleBackFromDetail}
|
||||
showBack={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section);
|
||||
if (!item || !isSectionAvailable(item, isDesktopApp, hardwareKeyboardPresent)) return null;
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||
import { ProviderList } from "@/provider-settings";
|
||||
import { openProviderSettings } from "@/navigation/settings-navigation";
|
||||
import { HostUsageSection } from "@/usage";
|
||||
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
@@ -334,6 +335,10 @@ export function HostWorkspacesPage({ serverId }: { serverId: string }) {
|
||||
|
||||
export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const openProvider = useCallback(
|
||||
(providerId: string) => openProviderSettings(serverId, providerId),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
@@ -341,7 +346,7 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ProvidersSection serverId={serverId} />
|
||||
<ProviderList serverId={serverId} onOpenProvider={openProvider} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,12 +19,15 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId);
|
||||
const snapshot = useProvidersSnapshot(serverId);
|
||||
const providers = useMemo(
|
||||
() => buildSelectableProviderSelectorProviders(snapshot.entries),
|
||||
[snapshot.entries],
|
||||
);
|
||||
const configuredProviders = config?.metadataGeneration.providers;
|
||||
const configuredProvider = configuredProviders?.[0] ?? null;
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
buildSelectableProviderSelectorProviders(snapshot.entries, {
|
||||
selectedProvider: configuredProvider?.provider ?? null,
|
||||
}),
|
||||
[configuredProvider?.provider, snapshot.entries],
|
||||
);
|
||||
const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic";
|
||||
const [draftMode, setDraftMode] = useState<SelectionMode | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
|
||||
const { snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
|
||||
() => ({
|
||||
snapshotState: {
|
||||
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
},
|
||||
configState: {
|
||||
config: null as MutableDaemonConfig | null,
|
||||
},
|
||||
patchConfigMock: vi.fn(async () => undefined),
|
||||
openProviderSettingsMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("react-native", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-native")>()),
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
Pressable: ({
|
||||
children,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
disabled,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
||||
onPress?: (event: React.MouseEvent) => void;
|
||||
onHoverIn?: () => void;
|
||||
onHoverOut?: () => void;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"aria-disabled": disabled ? "true" : undefined,
|
||||
"data-testid": testID,
|
||||
onClick: disabled ? undefined : onPress,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
},
|
||||
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
||||
),
|
||||
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreHorizontal: icon("MoreHorizontal"),
|
||||
Trash2: icon("Trash2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string | number>) =>
|
||||
(
|
||||
({
|
||||
"settings.providers.providerDetails": "{{name}} provider details",
|
||||
"settings.providers.enableProvider": "Enable {{name}}",
|
||||
"settings.providers.statuses.disabled": "Disabled",
|
||||
"settings.providers.statuses.available": "Available",
|
||||
"settings.providers.statuses.loading": "Loading",
|
||||
"settings.providers.statuses.error": "Error",
|
||||
"settings.providers.statuses.notInstalled": "Not installed",
|
||||
"settings.providers.models.one": "1 model",
|
||||
"settings.providers.models.many": "{{count}} models",
|
||||
"settings.providers.addErrorTitle": "Unable to add provider",
|
||||
"settings.providers.updateErrorTitle": "Unable to update provider",
|
||||
"settings.providers.actions.menu": "{{name}} actions",
|
||||
"settings.providers.actions.remove": "Remove provider",
|
||||
"settings.providers.actions.removing": "Removing...",
|
||||
"settings.providers.remove.confirmTitle": "Remove {{name}}?",
|
||||
"settings.providers.remove.confirmMessage":
|
||||
"This deletes the provider entry from config.json. It cannot be undone.",
|
||||
"settings.providers.remove.confirm": "Remove",
|
||||
"settings.providers.remove.errorTitle": "Unable to remove provider",
|
||||
})[key] ?? key
|
||||
)
|
||||
.replaceAll("{{name}}", String(values?.name ?? ""))
|
||||
.replaceAll("{{count}}", String(values?.count ?? "")),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
value: boolean;
|
||||
onValueChange?: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement("div", {
|
||||
role: "switch",
|
||||
"aria-checked": value ? "true" : "false",
|
||||
"aria-disabled": disabled ? "true" : undefined,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID ?? "provider-switch",
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (disabled) return;
|
||||
onValueChange?.(!value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/headings/settings-info-tip", () => ({
|
||||
SettingsInfoTip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/external-link", () => ({
|
||||
ExternalLink: () => null,
|
||||
ExternalLinkLabel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onPressIn,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
onPressIn?: (event: { stopPropagation: () => void }) => void;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onMouseDown: (event: React.MouseEvent) => onPressIn?.(event),
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
status,
|
||||
pendingLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
status?: "idle" | "pending" | "success";
|
||||
pendingLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
disabled: status === "pending" || status === "success",
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
status === "pending" ? pendingLabel : children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
useProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/provider-settings-store", () => ({
|
||||
useProviderSettingsStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({ open: openProviderSettingsMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-catalog-list", () => ({
|
||||
ProviderCatalogList: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries: snapshotState.entries,
|
||||
isLoading: snapshotState.isLoading,
|
||||
isFetching: false,
|
||||
isRefreshing: snapshotState.isRefreshing,
|
||||
error: null,
|
||||
supportsSnapshot: true,
|
||||
refresh: vi.fn(async () => {}),
|
||||
refetchIfStale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-daemon-config", () => ({
|
||||
useDaemonConfig: () => ({
|
||||
config: configState.config,
|
||||
isLoading: false,
|
||||
patchConfig: patchConfigMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/confirm-dialog", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { ProvidersSection } from "./providers-section";
|
||||
|
||||
function unexpectedToast(): never {
|
||||
throw new Error("Provider row interactions should not display a toast");
|
||||
}
|
||||
|
||||
const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast };
|
||||
|
||||
const claudeEntry: ProviderSnapshotEntry = {
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
description: "Claude Code",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
models: [
|
||||
{ provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
||||
],
|
||||
};
|
||||
|
||||
const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Codex",
|
||||
description: "OpenAI Codex",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers,
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
return Array.from(el.querySelectorAll<HTMLElement>("*"));
|
||||
}
|
||||
|
||||
function indexOfMatches(nodes: HTMLElement[], selector: string): number {
|
||||
return nodes.findIndex((node) => node.matches(selector));
|
||||
}
|
||||
|
||||
function indexOfText(nodes: HTMLElement[], text: string): number {
|
||||
return nodes.findIndex((node) => node.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
describe("ProvidersSection", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
snapshotState.entries = undefined;
|
||||
snapshotState.isLoading = false;
|
||||
snapshotState.isRefreshing = false;
|
||||
configState.config = null;
|
||||
patchConfigMock.mockReset();
|
||||
patchConfigMock.mockResolvedValue(undefined);
|
||||
openProviderSettingsMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<ToastApiProvider api={toastApi}>
|
||||
<ProvidersSection serverId="server-1" />
|
||||
</ToastApiProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function findRow(accessibilityLabel: string): HTMLElement {
|
||||
const row = container?.querySelector<HTMLElement>(
|
||||
`[role="button"][aria-label="${accessibilityLabel}"]`,
|
||||
);
|
||||
if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("renders the disabled provider with its server-provided label in snapshot order", () => {
|
||||
snapshotState.entries = [claudeEntry, disabledCodexEntry];
|
||||
configState.config = makeConfig({ codex: { enabled: false } });
|
||||
|
||||
render();
|
||||
|
||||
const rows = Array.from(
|
||||
container?.querySelectorAll<HTMLElement>('[role="button"][aria-label$="provider details"]') ??
|
||||
[],
|
||||
);
|
||||
expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
|
||||
"Claude provider details",
|
||||
"Codex provider details",
|
||||
]);
|
||||
|
||||
const codexRow = findRow("Codex provider details");
|
||||
const codexNodes = descendants(codexRow);
|
||||
expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
|
||||
expect(indexOfText(codexNodes, "codex")).toBe(-1);
|
||||
expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("composes the row as chevron, icon, label, status, model count, then switch", () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Claude provider details");
|
||||
const nodes = descendants(row);
|
||||
const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
|
||||
const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
|
||||
const label = indexOfText(nodes, "Claude");
|
||||
const status = indexOfText(nodes, "Available");
|
||||
const modelCount = indexOfText(nodes, "3 models");
|
||||
const switchEl = indexOfMatches(nodes, '[role="switch"]');
|
||||
|
||||
expect(chevron).toBeGreaterThanOrEqual(0);
|
||||
expect(icon).toBeGreaterThan(chevron);
|
||||
expect(label).toBeGreaterThan(icon);
|
||||
expect(status).toBeGreaterThan(label);
|
||||
expect(modelCount).toBeGreaterThan(status);
|
||||
expect(switchEl).toBeGreaterThan(modelCount);
|
||||
});
|
||||
|
||||
it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
|
||||
snapshotState.entries = [disabledCodexEntry];
|
||||
configState.config = makeConfig({ codex: { enabled: false } });
|
||||
|
||||
render();
|
||||
|
||||
expect(openProviderSettingsMock).not.toHaveBeenCalled();
|
||||
|
||||
const row = findRow("Codex provider details");
|
||||
act(() => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(openProviderSettingsMock).toHaveBeenCalledTimes(1);
|
||||
expect(openProviderSettingsMock).toHaveBeenCalledWith({
|
||||
serverId: "server-1",
|
||||
provider: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Claude provider details");
|
||||
const switchEl = row.querySelector<HTMLElement>('[role="switch"]');
|
||||
expect(switchEl).not.toBeNull();
|
||||
expect(switchEl?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(patchConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(patchConfigMock).toHaveBeenCalledWith({
|
||||
providers: { claude: { enabled: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,622 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectHostFeature, useHostFeature } from "@/runtime/host-features";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildAcpProviderConfigPatch,
|
||||
getAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
|
||||
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
|
||||
|
||||
interface ProviderStatus {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
modelCount: number | null;
|
||||
}
|
||||
|
||||
function getProviderStatus(
|
||||
status: string,
|
||||
enabled: boolean,
|
||||
modelCount: number,
|
||||
t: TFunction,
|
||||
): ProviderStatus {
|
||||
if (!enabled)
|
||||
return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null };
|
||||
if (status === "loading") {
|
||||
return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null };
|
||||
}
|
||||
if (status === "error") {
|
||||
return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null };
|
||||
}
|
||||
if (status === "ready") {
|
||||
return {
|
||||
tone: "success",
|
||||
label: t("settings.providers.statuses.available"),
|
||||
modelCount: modelCount > 0 ? modelCount : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "warning",
|
||||
label: t("settings.providers.statuses.notInstalled"),
|
||||
modelCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
serverId: string;
|
||||
def: ProviderDefinition;
|
||||
entry: ProviderEntry;
|
||||
enabled: boolean;
|
||||
isToggling: boolean;
|
||||
isRemoving: boolean;
|
||||
canRemove: boolean;
|
||||
isFirst: boolean;
|
||||
onPress: (providerId: string) => void;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function stopPressInPropagation(event: GestureResponderEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
interface ProviderActionsMenuProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
isRemoving: boolean;
|
||||
iconSize: number;
|
||||
foregroundColor: string;
|
||||
foregroundMutedColor: string;
|
||||
dangerColor: string;
|
||||
onRemove: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function ProviderActionsMenu({
|
||||
providerId,
|
||||
providerLabel,
|
||||
isRemoving,
|
||||
iconSize,
|
||||
foregroundColor,
|
||||
foregroundMutedColor,
|
||||
dangerColor,
|
||||
onRemove,
|
||||
}: ProviderActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(providerId, providerLabel);
|
||||
}, [onRemove, providerId, providerLabel]);
|
||||
const triggerStyle = useCallback(
|
||||
({
|
||||
pressed,
|
||||
hovered,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
|
||||
styles.menuButton,
|
||||
(hovered || open) && styles.menuButtonHovered,
|
||||
pressed && styles.menuButtonPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const trashLeading = useMemo(() => <Trash2 size={16} color={dangerColor} />, [dangerColor]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isRemoving}
|
||||
hitSlop={8}
|
||||
onPressIn={stopPressInPropagation}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.actions.menu", { name: providerLabel })}
|
||||
testID={`provider-actions-${providerId}`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<MoreHorizontal
|
||||
size={iconSize}
|
||||
color={hovered || open ? foregroundColor : foregroundMutedColor}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
destructive
|
||||
leading={trashLeading}
|
||||
onSelect={handleRemove}
|
||||
status={isRemoving ? "pending" : "idle"}
|
||||
pendingLabel={t("settings.providers.actions.removing")}
|
||||
testID={`provider-remove-${providerId}`}
|
||||
>
|
||||
{t("settings.providers.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_ROW_KEYS = [0, 1, 2, 3].map((index) => `provider-skeleton-row-${index}`);
|
||||
|
||||
function ProviderRowsSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<View
|
||||
style={settingsStyles.card}
|
||||
accessibilityLabel={t("settings.providers.loading")}
|
||||
testID="providers-skeleton"
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key, index) => (
|
||||
<View
|
||||
key={key}
|
||||
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder, styles.row]}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonIcon} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
|
||||
</View>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonSwitch} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
serverId,
|
||||
def,
|
||||
entry,
|
||||
enabled,
|
||||
isToggling,
|
||||
isRemoving,
|
||||
canRemove,
|
||||
isFirst,
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
}: ProviderRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const ProviderIcon = useProviderIcon(def.id, serverId);
|
||||
const providerError =
|
||||
enabled &&
|
||||
entry.status === "error" &&
|
||||
typeof entry.error === "string" &&
|
||||
entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = filterSelectableModels(entry.models ?? null)?.length ?? 0;
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(def.id);
|
||||
}, [def.id, onPress]);
|
||||
const handleToggleValueChange = useCallback(
|
||||
(value: boolean) => {
|
||||
onToggleEnabled(def.id, value);
|
||||
},
|
||||
[def.id, onToggleEnabled],
|
||||
);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
styles.row,
|
||||
hovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isFirst],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.providerDetails", { name: def.label })}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={styles.rowContent}>
|
||||
<ChevronRight
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<View style={styles.textColumn}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{def.label}
|
||||
</Text>
|
||||
{!isCompact ? <Text style={styles.separator}>·</Text> : null}
|
||||
<StatusIndicator status={providerStatus} compact={isCompact} />
|
||||
</View>
|
||||
{providerError && !isCompact ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{/* The menu leads the switch, so every switch sits on the row's right edge without
|
||||
rows that have no menu reserving room for one. */}
|
||||
<View style={styles.trailingControls}>
|
||||
{canRemove ? (
|
||||
<ProviderActionsMenu
|
||||
providerId={def.id}
|
||||
providerLabel={def.label}
|
||||
isRemoving={isRemoving}
|
||||
iconSize={theme.iconSize.sm}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
dangerColor={theme.colors.statusDanger}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : null}
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling || isRemoving}
|
||||
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
switch (tone) {
|
||||
case "success":
|
||||
return theme.colors.statusSuccess;
|
||||
case "warning":
|
||||
return theme.colors.statusWarning;
|
||||
case "danger":
|
||||
return theme.colors.statusDanger;
|
||||
default:
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIndicator({ status, compact }: { status: ProviderStatus; compact: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const dotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
|
||||
[status.tone, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
{status.tone === "loading" ? (
|
||||
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={dotStyle} />
|
||||
)}
|
||||
{!compact ? (
|
||||
<>
|
||||
<Text style={styles.statusLabel}>{status.label}</Text>
|
||||
{status.modelCount !== null ? (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1
|
||||
? t("settings.providers.models.one")
|
||||
: t("settings.providers.models.many", { count: status.modelCount })}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProvidersSectionProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
const removingProviderIdRef = useRef<string | null>(null);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const hasServer = serverId.length > 0;
|
||||
|
||||
/** Config owns derived providers and opt-in bundled catalog providers. */
|
||||
const isConfigOwnedProvider = useCallback(
|
||||
(providerId: string) =>
|
||||
Boolean(
|
||||
config?.providers?.[providerId] &&
|
||||
(config.providers[providerId].extends ||
|
||||
getAcpProviderCatalog().some(
|
||||
(entry) => entry.id === providerId && entry.bundledProvider,
|
||||
)),
|
||||
),
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOpenProviderSettings = useCallback(
|
||||
(providerId: string) => {
|
||||
openProviderSettings({ serverId, provider: providerId });
|
||||
},
|
||||
[openProviderSettings, serverId],
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (providerId: string, enabled: boolean) => {
|
||||
setPendingProviderId(providerId);
|
||||
try {
|
||||
await patchConfig({ providers: { [providerId]: { enabled } } });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.providers.updateErrorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setPendingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleRemoveProvider = useCallback(
|
||||
async (providerId: string, providerLabel: string) => {
|
||||
if (removingProviderIdRef.current) return;
|
||||
removingProviderIdRef.current = providerId;
|
||||
setRemovingProviderId(providerId);
|
||||
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;
|
||||
}
|
||||
|
||||
await patchConfig({ removeProviders: [providerId] });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("settings.providers.remove.errorTitle"),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
if (removingProviderIdRef.current === providerId) {
|
||||
removingProviderIdRef.current = null;
|
||||
}
|
||||
setRemovingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig, t],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(
|
||||
buildAcpProviderConfigPatch(entry, (feature) =>
|
||||
selectHostFeature(useSessionStore.getState(), serverId, feature),
|
||||
),
|
||||
);
|
||||
await refresh([entry.id]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, patchConfig, refresh, serverId, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t("settings.providers.title")}
|
||||
testID="host-page-providers-card"
|
||||
style={styles.sectionSpacing}
|
||||
>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>{t("settings.providers.unavailable")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && isLoading ? <ProviderRowsSkeleton /> : null}
|
||||
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const entry = entries?.find((candidate) => candidate.provider === def.id);
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
serverId={serverId}
|
||||
def={def}
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isRemoving={removingProviderId === def.id}
|
||||
canRemove={
|
||||
supportsProviderRemoval &&
|
||||
entry.source === "custom" &&
|
||||
isConfigOwnedProvider(def.id)
|
||||
}
|
||||
isFirst={index === 0}
|
||||
onPress={handleOpenProviderSettings}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{hasServer && isConnected ? (
|
||||
<SettingsSection
|
||||
title={t("settings.providers.addProvider")}
|
||||
testID="host-page-add-provider-card"
|
||||
style={styles.addProviderSection}
|
||||
>
|
||||
<ProviderCatalogList
|
||||
serverId={serverId}
|
||||
installingProviderId={installingProviderId}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
addProviderSection: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
minHeight: 56,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginLeft: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonTitle: {
|
||||
width: 160,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSwitch: {
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
textColumn: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
separator: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
trailingControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuButtonPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
}));
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isSettingsSectionSlug,
|
||||
normalizeHostSectionSlug,
|
||||
normalizeProjectSettingsRouteId,
|
||||
buildProviderSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -199,6 +200,14 @@ describe("projects settings routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider settings routes", () => {
|
||||
it("buildProviderSettingsRoute addresses one provider on a host", () => {
|
||||
expect(buildProviderSettingsRoute("host a", "acp/gemini")).toBe(
|
||||
"/settings/hosts/host%20a/providers/acp%2Fgemini",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global routes", () => {
|
||||
it("buildSessionsRoute returns the all-host Sessions route", () => {
|
||||
expect(buildSessionsRoute()).toBe("/sessions");
|
||||
|
||||
@@ -584,6 +584,13 @@ export function buildProjectSettingsRoute(serverId: string, projectId: string) {
|
||||
return `/settings/hosts/${encodeSegment(serverId)}/projects/${encodeSegment(projectId)}` as const;
|
||||
}
|
||||
|
||||
export function buildProviderSettingsRoute(serverId: string, providerId: string) {
|
||||
if (!serverId.trim() || !providerId.trim()) {
|
||||
throw new Error("buildProviderSettingsRoute requires a serverId and providerId");
|
||||
}
|
||||
return `/settings/hosts/${encodeSegment(serverId)}/providers/${encodeSegment(providerId)}` as const;
|
||||
}
|
||||
|
||||
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
|
||||
const id = Array.isArray(value) ? value[0] : value;
|
||||
return typeof id === "string" ? id : "";
|
||||
|
||||
@@ -83,7 +83,6 @@ export const PinOff = StubIcon;
|
||||
export const Play = StubIcon;
|
||||
export const Plug = StubIcon;
|
||||
export const Plus = StubIcon;
|
||||
export const Puzzle = StubIcon;
|
||||
export const QrCode = StubIcon;
|
||||
export const RefreshCcw = StubIcon;
|
||||
export const RefreshCw = StubIcon;
|
||||
@@ -101,7 +100,6 @@ export const Smartphone = StubIcon;
|
||||
export const Split = StubIcon;
|
||||
export const Square = StubIcon;
|
||||
export const SquarePen = StubIcon;
|
||||
export const SquareSlash = StubIcon;
|
||||
export const SquareTerminal = StubIcon;
|
||||
export const Star = StubIcon;
|
||||
export const Sun = StubIcon;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { lightHighlightColors } from "@getpaseo/highlight";
|
||||
import { lightTheme } from "../src/styles/theme";
|
||||
|
||||
const testTheme = {
|
||||
colorScheme: "light",
|
||||
// The light theme's colours, so a `withUnistyles` mapping finds every key it reads, with the
|
||||
// values below pinned so a test can name the colour it expects.
|
||||
colors: {
|
||||
...lightTheme.colors,
|
||||
syntax: lightHighlightColors,
|
||||
foreground: "#111111",
|
||||
foregroundMuted: "#666666",
|
||||
@@ -142,7 +146,23 @@ export const StyleSheet = {
|
||||
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
|
||||
};
|
||||
|
||||
export const withUnistyles = <T>(Component: T): T => Component;
|
||||
type ThemeMapping = (theme: typeof testTheme, rt: object) => Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Applies the theme mapping and `uniProps` the way the runtime does: mapped props first, then
|
||||
* `uniProps`, then the props the caller passed, so components that read colours through the
|
||||
* theme get real values instead of undefined.
|
||||
*/
|
||||
export const withUnistyles = <T>(Component: T, mapping?: ThemeMapping): T => {
|
||||
function Unistyled({ uniProps, ...props }: { uniProps?: ThemeMapping }) {
|
||||
return createElement(Component as ComponentType<Record<string, unknown>>, {
|
||||
...mapping?.(testTheme, {}),
|
||||
...uniProps?.(testTheme, {}),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
return Unistyled as T;
|
||||
};
|
||||
|
||||
export const useUnistyles = () => ({
|
||||
theme: testTheme,
|
||||
|
||||
@@ -35,26 +35,6 @@ export default defineConfig({
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reanimated-web-style-helpers",
|
||||
transform(_source, id) {
|
||||
// Reanimated writes animated styles to the DOM through react-native-web helpers it loads
|
||||
// with `require` inside try/catch. Vite serves ESM, so the require throws, the helpers stay
|
||||
// undefined, and the first animated style update after mount crashes the component.
|
||||
if (
|
||||
!id.split("?")[0].endsWith("/lib/module/ReanimatedModule/js-reanimated/webUtils.web.js")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
code: [
|
||||
'export { default as createReactDOMStyle } from "react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle";',
|
||||
'export { createTransformValue, createTextShadowValue } from "react-native-web/dist/exports/StyleSheet/preprocess";',
|
||||
].join("\n"),
|
||||
map: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compile-react-components",
|
||||
enforce: "pre",
|
||||
@@ -207,9 +187,6 @@ export default defineConfig({
|
||||
"react/jsx-dev-runtime",
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
// Loaded by the reanimated-web-style-helpers plugin, which the dependency scan does not see.
|
||||
"react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle",
|
||||
"react-native-web/dist/exports/StyleSheet/preprocess",
|
||||
],
|
||||
exclude: [
|
||||
"react-native-enriched-markdown",
|
||||
|
||||
@@ -117,6 +117,8 @@ import type {
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderInstallationInspectResponseMessage,
|
||||
ProviderInstallationCheckUpdatesResponseMessage,
|
||||
UsageReportEntry,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
@@ -629,6 +631,13 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
type ProviderInstallationInspectPayload = ProviderInstallationInspectResponseMessage["payload"];
|
||||
type ProviderInstallationCheckUpdatesPayload =
|
||||
ProviderInstallationCheckUpdatesResponseMessage["payload"];
|
||||
type ProviderInstallationUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "provider.installation.update.response" }
|
||||
>["payload"];
|
||||
interface UsageListReportsPayload {
|
||||
requestId: string;
|
||||
reports: UsageReportEntry[];
|
||||
@@ -6224,6 +6233,69 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Install and update links plus installed state. Requires `features.providerInstallation`. */
|
||||
async inspectProviderInstallation(
|
||||
provider: AgentProvider,
|
||||
options?: { refresh?: boolean; requestId?: string },
|
||||
): Promise<ProviderInstallationInspectPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.inspect.response">({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.installation.inspect.request",
|
||||
provider,
|
||||
...(options?.refresh ? { refresh: true } : {}),
|
||||
},
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the provider reports a newer version. Requires `features.providerInstallation`. */
|
||||
async checkProviderUpdates(
|
||||
provider: AgentProvider,
|
||||
options?: { refresh?: boolean; requestId?: string },
|
||||
): Promise<ProviderInstallationCheckUpdatesPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.check_updates.response">(
|
||||
{
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.installation.check_updates.request",
|
||||
provider,
|
||||
...(options?.refresh ? { refresh: true } : {}),
|
||||
},
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts the provider's update, or joins the running one. Follow it with `observeProviderUpdates`. */
|
||||
async updateProvider(
|
||||
provider: AgentProvider,
|
||||
options?: { requestId?: string },
|
||||
): Promise<ProviderInstallationUpdatePayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"provider.installation.update.response">({
|
||||
requestId: options?.requestId,
|
||||
message: { type: "provider.installation.update.request", provider },
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's latest update, then `provider.installation.update.progress` as updates start,
|
||||
* report output and settle. Survives reconnects: each new snapshot replaces the previous one.
|
||||
*/
|
||||
observeProviderUpdates(
|
||||
provider: AgentProvider,
|
||||
options?: { signal?: AbortSignal },
|
||||
): OwnedSubscription<
|
||||
CorrelatedResponsePayload<"provider.installation.update.subscribe.response">
|
||||
> {
|
||||
return this.observe(
|
||||
"provider.installation.update.subscribe.response",
|
||||
{ type: "provider.installation.update.subscribe.request", provider },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async listProviderUsage(options?: { requestId?: string }): Promise<ProviderUsageListPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
|
||||
@@ -39,9 +39,86 @@ export interface ProviderRegistration {
|
||||
description?: string;
|
||||
/** Plugin-directory-relative path to a self-contained SVG file. */
|
||||
icon?: string;
|
||||
/** How users install the provider's CLI and which version they run. Every member is optional. */
|
||||
installation?: ProviderInstallation;
|
||||
connect(request: ProviderConnectRequest): Promise<ProviderConnection>;
|
||||
}
|
||||
|
||||
export interface ProviderInstallation {
|
||||
/** http(s) page that explains how to install the provider. Paseo opens it; it never installs. */
|
||||
installUrl?: string;
|
||||
/** http(s) page that explains how to update the provider. */
|
||||
updateUrl?: string;
|
||||
/** Report whether the provider is installed and which version. Runs only when a user asks. */
|
||||
inspect?(context: ProviderInstallationContext): Promise<ProviderInspection>;
|
||||
/**
|
||||
* Report whether a newer version is available. The provider decides how to find out and what
|
||||
* counts as newer; Paseo shows the result as written. Return `unknown` when it cannot tell.
|
||||
* Runs only when a user asks, never before a session.
|
||||
*/
|
||||
checkForUpdates?(context: ProviderInstallationContext): Promise<ProviderUpdateCheck>;
|
||||
/**
|
||||
* Update the provider, usually by running its CLI's own updater. Paseo shows everything passed
|
||||
* to `report` while it runs, then the returned message. A throw is a failure with its message.
|
||||
* Runs only when a user asks. Paseo sets no deadline; `signal` aborts when the daemon stops.
|
||||
*/
|
||||
update?(context: ProviderUpdateContext): Promise<ProviderUpdateResult>;
|
||||
}
|
||||
|
||||
export interface ProviderUpdateContext extends ProviderInstallationContext {
|
||||
/** Shows output to the user as it happens, unchanged and in order. */
|
||||
report(event: ProviderUpdateReport): void;
|
||||
}
|
||||
|
||||
export const ProviderUpdateReportSchema = z
|
||||
.object({ type: z.enum(["stdout", "stderr", "message"]), text: z.string() })
|
||||
.strip();
|
||||
|
||||
export type ProviderUpdateReport = z.infer<typeof ProviderUpdateReportSchema>;
|
||||
|
||||
export const ProviderUpdateResultSchema = z.discriminatedUnion("status", [
|
||||
/** `message` is shown as written, such as which version is now installed. */
|
||||
z.object({ status: z.literal("succeeded"), message: z.string().optional() }).strip(),
|
||||
z.object({ status: z.literal("failed"), message: z.string() }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderUpdateResult = z.infer<typeof ProviderUpdateResultSchema>;
|
||||
|
||||
export interface ProviderInstallationContext {
|
||||
/** The launch a session would use. Absent when the provider declares no `command`. */
|
||||
launch?: ProviderInstallationLaunch;
|
||||
/** Aborted when Paseo stops waiting for the result. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ProviderInstallationLaunch extends ProviderLaunch {
|
||||
/** False when the executable was not found; `command` is then the configured command as written. */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export const ProviderInspectionSchema = z.discriminatedUnion("status", [
|
||||
/** `version` is shown as written; Paseo does not parse or compare it. */
|
||||
z.object({ status: z.literal("installed"), version: z.string().optional() }).strip(),
|
||||
z.object({ status: z.literal("not_installed") }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderInspection = z.infer<typeof ProviderInspectionSchema>;
|
||||
|
||||
export const ProviderUpdateCheckSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("current") }).strip(),
|
||||
/** `versions` and `message` are shown as written; Paseo does not parse, sort or compare them. */
|
||||
z
|
||||
.object({
|
||||
status: z.literal("available"),
|
||||
versions: z.array(z.string()).optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.strip(),
|
||||
z.object({ status: z.literal("unknown"), message: z.string().optional() }).strip(),
|
||||
]);
|
||||
|
||||
export type ProviderUpdateCheck = z.infer<typeof ProviderUpdateCheckSchema>;
|
||||
|
||||
export type ProviderCatalogOptions =
|
||||
| { scope: "global"; force?: boolean; launch?: ProviderLaunch }
|
||||
| { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch };
|
||||
|
||||
@@ -1958,6 +1958,34 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationInspectRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.inspect.request"),
|
||||
provider: AgentProviderSchema,
|
||||
/** Skip the daemon's short result cache and ask the provider again. */
|
||||
refresh: z.boolean().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationCheckUpdatesRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.check_updates.request"),
|
||||
provider: AgentProviderSchema,
|
||||
/** Skip the daemon's update-check cache and ask the provider again. */
|
||||
refresh: z.boolean().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.request"),
|
||||
provider: AgentProviderSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateSubscribeRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.subscribe.request"),
|
||||
provider: AgentProviderSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.request"),
|
||||
requestId: z.string(),
|
||||
@@ -3592,6 +3620,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
ProviderDiagnosticRequestMessageSchema,
|
||||
ProviderInstallationInspectRequestMessageSchema,
|
||||
ProviderInstallationCheckUpdatesRequestMessageSchema,
|
||||
ProviderInstallationUpdateRequestMessageSchema,
|
||||
ProviderInstallationUpdateSubscribeRequestMessageSchema,
|
||||
ProviderUsageListRequestMessageSchema,
|
||||
UsageListReportsRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
@@ -3918,6 +3950,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
|
||||
usageSetting: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
// COMPAT(providerInstallation): added in v0.17.0, remove gate after 2027-05-13.
|
||||
providerInstallation: z.boolean().optional(),
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: z.boolean().optional(),
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
@@ -6742,6 +6776,129 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/** Provider-owned facts; `version` is an opaque string the app shows as written. */
|
||||
export const ProviderInstallationInspectionSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("installed"), version: z.string().optional() }),
|
||||
z.object({ status: z.literal("not_installed") }),
|
||||
z.object({ status: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const ProviderInstallationSchema = z.object({
|
||||
installUrl: z.string().optional(),
|
||||
updateUrl: z.string().optional(),
|
||||
/** Absent when the provider does not report its installation. */
|
||||
inspection: ProviderInstallationInspectionSchema.optional(),
|
||||
/** True when the provider can run its own update through `provider.installation.update`. */
|
||||
canUpdate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationInspectResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.inspect.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** Null when the provider declares no installation contract. */
|
||||
installation: ProviderInstallationSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The provider's answer to "is a newer version available". `versions` and `message` are opaque
|
||||
* strings the app shows as written. `error` means the check threw or missed its deadline.
|
||||
*/
|
||||
export const ProviderInstallationUpdateCheckSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("current") }),
|
||||
z.object({
|
||||
status: z.literal("available"),
|
||||
versions: z.array(z.string()).optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
z.object({ status: z.literal("unknown"), message: z.string().optional() }),
|
||||
z.object({ status: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const ProviderInstallationCheckUpdatesResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.check_updates.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** Null when the provider cannot check for updates. */
|
||||
updateCheck: ProviderInstallationUpdateCheckSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** One piece of updater output, shown unchanged and in order. */
|
||||
export const ProviderInstallationUpdateOutputSchema = z.object({
|
||||
type: z.enum(["stdout", "stderr", "message"]),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
/** The state of one provider update. Messages are the provider's, shown as written. */
|
||||
export const ProviderInstallationUpdateOperationSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("running"), id: z.string(), startedAt: z.string() }),
|
||||
z.object({
|
||||
status: z.literal("succeeded"),
|
||||
id: z.string(),
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
id: z.string(),
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The latest update of a provider since the daemon started, with its retained output. */
|
||||
export const ProviderInstallationUpdateSchema = z.object({
|
||||
operation: ProviderInstallationUpdateOperationSchema,
|
||||
output: z.array(ProviderInstallationUpdateOutputSchema),
|
||||
/** True when the oldest output was dropped to stay within the daemon's output limit. */
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
/** The update this request started, or the one already running. */
|
||||
update: ProviderInstallationUpdateSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderInstallationUpdateSubscribeResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.subscribe.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
subscriptionId: z.string().optional(),
|
||||
/** Null when the provider has not been updated since the daemon started. */
|
||||
update: ProviderInstallationUpdateSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sent to `provider.installation.update.subscribe` owners when an update starts, reports output
|
||||
* or finishes. `output` holds only the entries new since the previous progress for `operation.id`.
|
||||
*/
|
||||
export const ProviderInstallationUpdateProgressMessageSchema = z.object({
|
||||
type: z.literal("provider.installation.update.progress"),
|
||||
payload: z.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
provider: AgentProviderSchema,
|
||||
operation: ProviderInstallationUpdateOperationSchema,
|
||||
output: z.array(ProviderInstallationUpdateOutputSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
|
||||
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
|
||||
|
||||
@@ -7595,6 +7752,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
RefreshProvidersSnapshotResponseMessageSchema,
|
||||
ProviderDiagnosticResponseMessageSchema,
|
||||
ProviderInstallationInspectResponseMessageSchema,
|
||||
ProviderInstallationCheckUpdatesResponseMessageSchema,
|
||||
ProviderInstallationUpdateResponseMessageSchema,
|
||||
ProviderInstallationUpdateSubscribeResponseMessageSchema,
|
||||
ProviderInstallationUpdateProgressMessageSchema,
|
||||
ProviderUsageListResponseMessageSchema,
|
||||
UsageListReportsUpdateMessageSchema,
|
||||
UsageListReportsResponseMessageSchema,
|
||||
@@ -7779,6 +7941,26 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
|
||||
export type ProviderDiagnosticResponseMessage = z.infer<
|
||||
typeof ProviderDiagnosticResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallation = z.infer<typeof ProviderInstallationSchema>;
|
||||
export type ProviderInstallationInspection = z.infer<typeof ProviderInstallationInspectionSchema>;
|
||||
export type ProviderInstallationInspectResponseMessage = z.infer<
|
||||
typeof ProviderInstallationInspectResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateCheck = z.infer<typeof ProviderInstallationUpdateCheckSchema>;
|
||||
export type ProviderInstallationCheckUpdatesResponseMessage = z.infer<
|
||||
typeof ProviderInstallationCheckUpdatesResponseMessageSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateOutput = z.infer<
|
||||
typeof ProviderInstallationUpdateOutputSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdateOperation = z.infer<
|
||||
typeof ProviderInstallationUpdateOperationSchema
|
||||
>;
|
||||
export type ProviderInstallationUpdate = z.infer<typeof ProviderInstallationUpdateSchema>;
|
||||
export type ProviderInstallationUpdateProgress = Omit<
|
||||
z.infer<typeof ProviderInstallationUpdateProgressMessageSchema>["payload"],
|
||||
"subscriptionId"
|
||||
>;
|
||||
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
|
||||
export type UsageProblem = z.infer<typeof UsageProblemSchema>;
|
||||
export type UsageReport = z.infer<typeof UsageReportSchema>;
|
||||
|
||||
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
|
||||
beforeDispatch?: () => Promise<void>;
|
||||
onDispatch?: StartAgentRunOptions["onDispatch"];
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
await params.beforeDispatch?.();
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
onDispatch: params.onDispatch,
|
||||
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
ProviderInspection,
|
||||
ProviderUpdateCheck,
|
||||
ProviderUpdateReport,
|
||||
ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
@@ -804,6 +810,24 @@ export interface AgentDraftControls {
|
||||
options: ProviderOptions;
|
||||
}
|
||||
|
||||
export interface AgentClientInstallationContext {
|
||||
/** Aborted when Paseo stops waiting for the result. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface AgentClientInstallation {
|
||||
installUrl?: string;
|
||||
updateUrl?: string;
|
||||
inspect?(context: AgentClientInstallationContext): Promise<ProviderInspection>;
|
||||
checkForUpdates?(context: AgentClientInstallationContext): Promise<ProviderUpdateCheck>;
|
||||
/** Runs the provider's updater. See `ProviderInstallation.update` in the plugin contract. */
|
||||
update?(context: AgentClientUpdateContext): Promise<ProviderUpdateResult>;
|
||||
}
|
||||
|
||||
export interface AgentClientUpdateContext extends AgentClientInstallationContext {
|
||||
report(event: ProviderUpdateReport): void;
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
@@ -867,6 +891,12 @@ export interface AgentClient {
|
||||
*/
|
||||
isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise<boolean>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
/**
|
||||
* Installation links and the installed version. The same contract as plugin providers'
|
||||
* `ProviderRegistration.installation`; built-ins resolve their own launch inside `inspect`.
|
||||
* Never consulted by availability, discovery, or session creation.
|
||||
*/
|
||||
readonly installation?: AgentClientInstallation;
|
||||
/**
|
||||
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
|
||||
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
ProviderInputSchema,
|
||||
ProviderStatusSchema,
|
||||
type ProviderStatus,
|
||||
type ProviderInspection,
|
||||
type ProviderUpdateCheck,
|
||||
type ProviderUpdateResult,
|
||||
type ProviderInstallationLaunch,
|
||||
type ProviderLaunch,
|
||||
requireProviderCapabilities,
|
||||
type ProviderCapability,
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentClientInstallation,
|
||||
AgentClientUpdateContext,
|
||||
AgentCreateSessionOptions,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
@@ -156,7 +162,8 @@ class ProviderRuntime {
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
||||
) {}
|
||||
|
||||
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
|
||||
/** The effective launch, including when its executable does not resolve. */
|
||||
private async resolveEffectiveLaunch(): Promise<ProviderInstallationLaunch | undefined> {
|
||||
const command = this.registration.command;
|
||||
if (!command) return undefined;
|
||||
const launch = await resolveProviderLaunch({
|
||||
@@ -167,8 +174,45 @@ class ProviderRuntime {
|
||||
const env = createProviderEnv({ runtimeSettings: this.runtimeSettings });
|
||||
launch.env = env;
|
||||
const availability = await checkProviderLaunchAvailable(launch);
|
||||
if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`);
|
||||
return { command: resolvePath(availability.resolvedPath), args: launch.args, env };
|
||||
if (!availability.resolvedPath)
|
||||
return { command: launch.command, args: launch.args, env, resolved: false };
|
||||
return {
|
||||
command: resolvePath(availability.resolvedPath),
|
||||
args: launch.args,
|
||||
env,
|
||||
resolved: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveLaunch(): Promise<ProviderLaunch | undefined> {
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
if (!launch) return undefined;
|
||||
if (!launch.resolved) throw new Error(`${launch.command} not found on PATH`);
|
||||
return { command: launch.command, args: launch.args, env: launch.env };
|
||||
}
|
||||
|
||||
async inspectInstallation(signal: AbortSignal): Promise<ProviderInspection> {
|
||||
const inspect = this.registration.installation?.inspect;
|
||||
if (!inspect) throw new Error("Provider does not report its installation");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
signal.throwIfAborted();
|
||||
return await inspect.call(this.registration.installation, { launch, signal });
|
||||
}
|
||||
|
||||
async checkForUpdates(signal: AbortSignal): Promise<ProviderUpdateCheck> {
|
||||
const checkForUpdates = this.registration.installation?.checkForUpdates;
|
||||
if (!checkForUpdates) throw new Error("Provider does not check for updates");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
signal.throwIfAborted();
|
||||
return await checkForUpdates.call(this.registration.installation, { launch, signal });
|
||||
}
|
||||
|
||||
async update(context: AgentClientUpdateContext): Promise<ProviderUpdateResult> {
|
||||
const update = this.registration.installation?.update;
|
||||
if (!update) throw new Error("Provider does not update itself");
|
||||
const launch = await this.resolveEffectiveLaunch();
|
||||
context.signal.throwIfAborted();
|
||||
return await update.call(this.registration.installation, { ...context, launch });
|
||||
}
|
||||
|
||||
async status(): Promise<ProviderStatus> {
|
||||
@@ -961,12 +1005,26 @@ class PluginAgentClient implements AgentClient {
|
||||
private readonly pendingChildren: PendingChild[] = [];
|
||||
|
||||
readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"];
|
||||
readonly installation?: AgentClientInstallation;
|
||||
|
||||
constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
this.provider = registration.id;
|
||||
this.runtime = new ProviderRuntime(registration, runtimeSettings);
|
||||
if (registration.getCatalogCacheKey)
|
||||
this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options);
|
||||
const installation = registration.installation;
|
||||
if (installation)
|
||||
this.installation = {
|
||||
installUrl: installation.installUrl,
|
||||
updateUrl: installation.updateUrl,
|
||||
inspect: installation.inspect
|
||||
? ({ signal }) => this.runtime.inspectInstallation(signal)
|
||||
: undefined,
|
||||
checkForUpdates: installation.checkForUpdates
|
||||
? ({ signal }) => this.runtime.checkForUpdates(signal)
|
||||
: undefined,
|
||||
update: installation.update ? (context) => this.runtime.update(context) : undefined,
|
||||
};
|
||||
this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened));
|
||||
}
|
||||
|
||||
|
||||
+855
@@ -0,0 +1,855 @@
|
||||
import type {
|
||||
ProviderEvent,
|
||||
ProviderInstallation,
|
||||
ProviderInstallationContext,
|
||||
ProviderRegistration,
|
||||
ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentClient, AgentClientInstallation } from "../agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import { ProviderInstallations } from "./provider-installations.js";
|
||||
|
||||
const OPAQUE_VERSION = " v1.2.3-beta+weird (build 7)\n";
|
||||
const MISSING_COMMAND = "paseo-provider-installation-missing-binary";
|
||||
const OPAQUE_VERSIONS = ["v9-nightly+abc", " 2.0 (LTS)\n"];
|
||||
const OPAQUE_MESSAGE = " Restart to finish updating\n";
|
||||
|
||||
function pluginProvider(
|
||||
installation: ProviderInstallation | undefined,
|
||||
command: readonly [string, ...string[]] = [process.execPath],
|
||||
): ProviderRegistration {
|
||||
return {
|
||||
id: "installation-plugin",
|
||||
label: "Installation plugin",
|
||||
command,
|
||||
installation,
|
||||
async connect() {
|
||||
let listener: ((event: ProviderEvent) => void) | undefined;
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
async send(input) {
|
||||
if (input.type === "catalog")
|
||||
listener?.({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: { models: [{ id: "model", label: "Model" }], modes: [] },
|
||||
});
|
||||
},
|
||||
onEvent(next) {
|
||||
listener = next;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function builtinClient(installation: AgentClientInstallation | undefined): AgentClient {
|
||||
return {
|
||||
provider: "codex",
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
installation,
|
||||
async createSession() {
|
||||
throw new Error("not opened by this test");
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("not opened by this test");
|
||||
},
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
},
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managerWith(input: { plugin?: ProviderRegistration; codex?: AgentClient }) {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: input.codex ? { codex: input.codex } : {},
|
||||
});
|
||||
if (input.plugin) manager.replacePluginProviders([input.plugin]);
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe("provider installation", () => {
|
||||
test("exposes links and lets the plugin report not installed when its executable is missing", async () => {
|
||||
const contexts: ProviderInstallationContext[] = [];
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider(
|
||||
{
|
||||
installUrl: "https://example.com/install",
|
||||
updateUrl: "https://example.com/update",
|
||||
async inspect(context) {
|
||||
contexts.push(context);
|
||||
return context.launch?.resolved ? { status: "installed" } : { status: "not_installed" };
|
||||
},
|
||||
},
|
||||
[MISSING_COMMAND, "serve"],
|
||||
),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
updateUrl: "https://example.com/update",
|
||||
inspection: { status: "not_installed" },
|
||||
});
|
||||
expect(contexts[0]?.launch).toEqual({
|
||||
command: MISSING_COMMAND,
|
||||
args: ["serve"],
|
||||
env: expect.objectContaining({ PATH: expect.any(String) }),
|
||||
resolved: false,
|
||||
});
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "plugin",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
plugin: pluginProvider({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect({ launch }) {
|
||||
if (!launch?.resolved || launch.command !== process.execPath)
|
||||
throw new Error("Unexpected launch");
|
||||
return { status: "installed", version: OPAQUE_VERSION };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "installation-plugin",
|
||||
},
|
||||
{
|
||||
name: "built-in",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
codex: builtinClient({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect() {
|
||||
return { status: "installed", version: OPAQUE_VERSION };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "codex",
|
||||
},
|
||||
])("returns a $name provider's version string unchanged", async ({ manager, provider }) => {
|
||||
const providers = manager();
|
||||
try {
|
||||
await expect(providers.installations.inspect(provider)).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
inspection: { status: "installed", version: OPAQUE_VERSION },
|
||||
});
|
||||
} finally {
|
||||
providers.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps links and reports the failure when inspect throws", async () => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
installUrl: "https://example.com/install",
|
||||
async inspect() {
|
||||
throw new Error("version probe exploded");
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
installUrl: "https://example.com/install",
|
||||
inspection: { status: "error", message: "version probe exploded" },
|
||||
});
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for a provider without an installation contract", async () => {
|
||||
const manager = managerWith({ plugin: pluginProvider(undefined) });
|
||||
try {
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toBeNull();
|
||||
await expect(manager.installations.inspect("not-a-provider")).rejects.toThrow(
|
||||
"Provider not-a-provider is not configured",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("availability and discovery never wait on inspect", async () => {
|
||||
let inspectCalls = 0;
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
inspect() {
|
||||
inspectCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
void manager.installations.inspect("installation-plugin");
|
||||
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
|
||||
expect(entry.status).toBe("ready");
|
||||
expect(inspectCalls).toBe(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "plugin",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
plugin: pluginProvider({
|
||||
async checkForUpdates({ launch }) {
|
||||
if (!launch?.resolved) throw new Error("Unexpected launch");
|
||||
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "installation-plugin",
|
||||
},
|
||||
{
|
||||
name: "built-in",
|
||||
manager: () =>
|
||||
managerWith({
|
||||
codex: builtinClient({
|
||||
async checkForUpdates() {
|
||||
return { status: "available", versions: OPAQUE_VERSIONS, message: OPAQUE_MESSAGE };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
provider: "codex",
|
||||
},
|
||||
])("returns a $name provider's available versions unchanged", async ({ manager, provider }) => {
|
||||
const providers = manager();
|
||||
try {
|
||||
await expect(providers.installations.checkForUpdates(provider)).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: OPAQUE_VERSIONS,
|
||||
message: OPAQUE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
providers.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "returns unknown",
|
||||
checkForUpdates: async () => ({ status: "unknown" as const, message: "Offline" }),
|
||||
expected: { status: "unknown", message: "Offline" },
|
||||
},
|
||||
{
|
||||
name: "throws",
|
||||
checkForUpdates: async (): Promise<never> => {
|
||||
throw new Error("release feed exploded");
|
||||
},
|
||||
expected: { status: "error", message: "release feed exploded" },
|
||||
},
|
||||
{
|
||||
name: "breaks the contract",
|
||||
checkForUpdates: async () => ({ status: "up_to_date" }) as never,
|
||||
expected: { status: "error", message: expect.stringContaining("status") },
|
||||
},
|
||||
])("reports an inconclusive check when the provider $name", async (input) => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({ checkForUpdates: input.checkForUpdates }),
|
||||
});
|
||||
try {
|
||||
await expect(manager.installations.checkForUpdates("installation-plugin")).resolves.toEqual(
|
||||
input.expected,
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when the provider cannot check for updates", async () => {
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({ installUrl: "https://example.com/install" }),
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
manager.installations.checkForUpdates("installation-plugin"),
|
||||
).resolves.toBeNull();
|
||||
await expect(manager.installations.checkForUpdates("not-a-provider")).rejects.toThrow(
|
||||
"Provider not-a-provider is not configured",
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("availability, discovery and the installed version never wait on an update check", async () => {
|
||||
let checks = 0;
|
||||
const manager = managerWith({
|
||||
plugin: pluginProvider({
|
||||
async inspect() {
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
checkForUpdates() {
|
||||
checks += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
void manager.installations.checkForUpdates("installation-plugin");
|
||||
const entry = await manager.getProvider({ provider: "installation-plugin", wait: true });
|
||||
expect(entry.status).toBe("ready");
|
||||
await expect(manager.installations.inspect("installation-plugin")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
});
|
||||
expect(checks).toBe(1);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderInstallations", () => {
|
||||
function counting(
|
||||
inspect: NonNullable<AgentClientInstallation["inspect"]>,
|
||||
options: { now?: () => number; inspectTimeoutMs?: number } = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
inspect(context) {
|
||||
calls += 1;
|
||||
return inspect(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
...options,
|
||||
});
|
||||
return { installations, calls: () => calls };
|
||||
}
|
||||
|
||||
test("shares concurrent reads, caches briefly, and refresh asks again", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = counting(
|
||||
async () => ({ status: "installed", version: `v${time}` }),
|
||||
{ now: () => time },
|
||||
);
|
||||
const [first, second] = await Promise.all([
|
||||
installations.inspect("codex"),
|
||||
installations.inspect("codex"),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
time = 1_000;
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "v0" },
|
||||
});
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
await expect(installations.inspect("codex", { refresh: true })).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "v1000" },
|
||||
});
|
||||
expect(calls()).toBe(2);
|
||||
|
||||
time = 1_000_000;
|
||||
await installations.inspect("codex");
|
||||
expect(calls()).toBe(3);
|
||||
});
|
||||
|
||||
function countingUpdates(
|
||||
checkForUpdates: NonNullable<AgentClientInstallation["checkForUpdates"]>,
|
||||
options: { now?: () => number; updateCheckTimeoutMs?: number } = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
checkForUpdates(context) {
|
||||
calls += 1;
|
||||
return checkForUpdates(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
updateCheckCacheMs: 3_600_000,
|
||||
inspectCacheMs: 30_000,
|
||||
...options,
|
||||
});
|
||||
return { installations, calls: () => calls };
|
||||
}
|
||||
|
||||
test("shares concurrent update checks, holds an answer for the long cache, and refresh asks again", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = countingUpdates(
|
||||
async () => ({ status: "available", versions: [`v${time}`] }),
|
||||
{ now: () => time },
|
||||
);
|
||||
const [first, second] = await Promise.all([
|
||||
installations.checkForUpdates("codex"),
|
||||
installations.checkForUpdates("codex"),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
time = 3_000_000;
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: ["v0"],
|
||||
});
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
|
||||
status: "available",
|
||||
versions: ["v3000000"],
|
||||
});
|
||||
expect(calls()).toBe(2);
|
||||
|
||||
time = 3_000_000 + 3_600_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(3);
|
||||
});
|
||||
|
||||
test("retries an inconclusive update check after the short cache", async () => {
|
||||
let time = 0;
|
||||
const { installations, calls } = countingUpdates(async () => ({ status: "unknown" }), {
|
||||
now: () => time,
|
||||
});
|
||||
await installations.checkForUpdates("codex");
|
||||
time = 29_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(1);
|
||||
time = 31_000;
|
||||
await installations.checkForUpdates("codex");
|
||||
expect(calls()).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps update checks and inspection in separate caches", async () => {
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
return { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "installed", version: "1.0.0" },
|
||||
});
|
||||
});
|
||||
|
||||
test("asks for updates again once the installed version changes", async () => {
|
||||
let time = 0;
|
||||
let installed = "1.0.0";
|
||||
let checks = 0;
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
// The app sends inspect and check together; the check must not answer first.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return { status: "installed", version: installed };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
checks += 1;
|
||||
return installed === "1.0.0"
|
||||
? { status: "available", versions: ["2.0.0"] }
|
||||
: { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
inspectCacheMs: 30_000,
|
||||
updateCheckCacheMs: 3_600_000,
|
||||
now: () => time,
|
||||
});
|
||||
const together = () =>
|
||||
Promise.all([installations.inspect("codex"), installations.checkForUpdates("codex")]);
|
||||
await expect(together()).resolves.toEqual([
|
||||
{ inspection: { status: "installed", version: "1.0.0" } },
|
||||
{ status: "available", versions: ["2.0.0"] },
|
||||
]);
|
||||
|
||||
// The CLI updated itself outside Paseo.
|
||||
installed = "2.0.0";
|
||||
time = 31_000;
|
||||
await expect(together()).resolves.toEqual([
|
||||
{ inspection: { status: "installed", version: "2.0.0" } },
|
||||
{ status: "current" },
|
||||
]);
|
||||
expect(checks).toBe(2);
|
||||
|
||||
// Two clients reopening together after another change share one new check.
|
||||
installed = "3.0.0";
|
||||
time = 93_000;
|
||||
await Promise.all([together(), together()]);
|
||||
expect(checks).toBe(3);
|
||||
|
||||
// The same version again keeps the long-cached answer.
|
||||
time = 124_000;
|
||||
await together();
|
||||
expect(checks).toBe(3);
|
||||
});
|
||||
|
||||
test("an update check never starts an inspection or waits on one to ask the provider", async () => {
|
||||
let inspections = 0;
|
||||
let finishInspection: () => void = () => {};
|
||||
const client = builtinClient({
|
||||
async inspect() {
|
||||
inspections += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
finishInspection = resolve;
|
||||
});
|
||||
return { status: "installed", version: "1.0.0" };
|
||||
},
|
||||
async checkForUpdates() {
|
||||
return { status: "current" };
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => client,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({ status: "current" });
|
||||
expect(inspections).toBe(0);
|
||||
|
||||
const inspection = installations.inspect("codex");
|
||||
await expect(installations.checkForUpdates("codex", { refresh: true })).resolves.toEqual({
|
||||
status: "current",
|
||||
});
|
||||
finishInspection();
|
||||
await inspection;
|
||||
});
|
||||
|
||||
test("aborts a slow update check and reports it as an error", async () => {
|
||||
let aborted = false;
|
||||
function waitForAbort(signal: AbortSignal) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
}
|
||||
const { installations } = countingUpdates(({ signal }) => waitForAbort(signal), {
|
||||
updateCheckTimeoutMs: 10,
|
||||
});
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Timed out after 10ms",
|
||||
});
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("aborts a slow inspect and reports it as an error", async () => {
|
||||
let aborted = false;
|
||||
function waitForAbort(signal: AbortSignal) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
}
|
||||
const { installations } = counting(({ signal }) => waitForAbort(signal), {
|
||||
inspectTimeoutMs: 10,
|
||||
});
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
inspection: { status: "error", message: "Timed out after 10ms" },
|
||||
});
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider updates", () => {
|
||||
function updating(
|
||||
update: NonNullable<AgentClientInstallation["update"]>,
|
||||
options: {
|
||||
inspect?: AgentClientInstallation["inspect"];
|
||||
refreshProvider?: (provider: string) => Promise<void>;
|
||||
updateOutputLimit?: number;
|
||||
} = {},
|
||||
) {
|
||||
let calls = 0;
|
||||
const client = builtinClient({
|
||||
updateUrl: "https://example.com/update",
|
||||
inspect: options.inspect,
|
||||
update(context) {
|
||||
calls += 1;
|
||||
return update(context);
|
||||
},
|
||||
});
|
||||
const installations = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: (provider) => (provider === "codex" ? client : null),
|
||||
refreshProvider: options.refreshProvider,
|
||||
updateOutputLimit: options.updateOutputLimit,
|
||||
});
|
||||
const progress: ProviderInstallationUpdateProgress[] = [];
|
||||
installations.observeUpdates("codex", (event) => progress.push(event));
|
||||
return { installations, progress, calls: () => calls };
|
||||
}
|
||||
|
||||
function settled(installations: ProviderInstallations): Promise<ProviderInstallationUpdate> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = installations.observeUpdates("codex", (event) => {
|
||||
if (event.operation.status === "running") return;
|
||||
stop();
|
||||
resolve(installations.getUpdate("codex")!);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("passes output and the success message through unchanged, in order", async () => {
|
||||
const { installations, progress } = updating(async ({ report }) => {
|
||||
report({ type: "message", text: OPAQUE_MESSAGE });
|
||||
report({ type: "stdout", text: " added 1 package\r\n" });
|
||||
report({ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" });
|
||||
report({ type: "stdout", text: "" });
|
||||
return { status: "succeeded", message: OPAQUE_MESSAGE };
|
||||
});
|
||||
const done = settled(installations);
|
||||
const started = installations.update("codex");
|
||||
expect(started.operation.status).toBe("running");
|
||||
|
||||
const update = await done;
|
||||
const output = [
|
||||
{ type: "message", text: OPAQUE_MESSAGE },
|
||||
{ type: "stdout", text: " added 1 package\r\n" },
|
||||
{ type: "stderr", text: "npm warn \u001b[33mdeprecated\u001b[0m\n" },
|
||||
{ type: "stdout", text: "" },
|
||||
];
|
||||
expect(update).toEqual({
|
||||
operation: {
|
||||
status: "succeeded",
|
||||
id: started.operation.id,
|
||||
startedAt: expect.any(String),
|
||||
finishedAt: expect.any(String),
|
||||
message: OPAQUE_MESSAGE,
|
||||
},
|
||||
output,
|
||||
});
|
||||
expect(progress.map((event) => event.operation.status)).toEqual([
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"running",
|
||||
"succeeded",
|
||||
]);
|
||||
expect(progress.flatMap((event) => event.output)).toEqual(output);
|
||||
});
|
||||
|
||||
test("reports a failure with the provider's message and keeps its output", async () => {
|
||||
const { installations } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" });
|
||||
report({ type: "stderr", text: "npm error code EACCES\n" });
|
||||
return { status: "failed", message: OPAQUE_MESSAGE };
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toEqual({
|
||||
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
|
||||
output: [
|
||||
{ type: "stdout", text: "Updating Codex via `npm install -g @openai/codex`...\n" },
|
||||
{ type: "stderr", text: "npm error code EACCES\n" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("turns a throw into a failure with its message and keeps earlier output", async () => {
|
||||
const { installations } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "step 1\n" });
|
||||
throw new Error(OPAQUE_MESSAGE);
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toEqual({
|
||||
operation: expect.objectContaining({ status: "failed", message: OPAQUE_MESSAGE }),
|
||||
output: [{ type: "stdout", text: "step 1\n" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("a second request while an update runs joins it instead of starting another", async () => {
|
||||
let finish!: () => void;
|
||||
const { installations, calls } = updating(async ({ report }) => {
|
||||
report({ type: "stdout", text: "working\n" });
|
||||
await new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
return { status: "succeeded" };
|
||||
});
|
||||
const first = installations.update("codex");
|
||||
const second = installations.update("codex");
|
||||
expect(second.operation.id).toBe(first.operation.id);
|
||||
expect(second.output).toEqual([{ type: "stdout", text: "working\n" }]);
|
||||
expect(calls()).toBe(1);
|
||||
|
||||
const done = settled(installations);
|
||||
finish();
|
||||
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
|
||||
const third = installations.update("codex");
|
||||
expect(third.operation.id).not.toBe(first.operation.id);
|
||||
expect(calls()).toBe(2);
|
||||
});
|
||||
|
||||
test("after success the next read asks the provider again and discovery refreshes", async () => {
|
||||
let version = "1.0.0";
|
||||
const refreshed: string[] = [];
|
||||
const { installations } = updating(
|
||||
async () => {
|
||||
version = "2.0.0";
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{
|
||||
inspect: async () => ({ status: "installed", version }),
|
||||
refreshProvider: async (provider) => {
|
||||
refreshed.push(provider);
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "1.0.0" },
|
||||
});
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await done;
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "2.0.0" },
|
||||
});
|
||||
expect(refreshed).toEqual(["codex"]);
|
||||
});
|
||||
|
||||
test("a failed refresh after success still reports success; a failure keeps the caches", async () => {
|
||||
let version = "1.0.0";
|
||||
let result: "succeeded" | "failed" = "failed";
|
||||
const { installations } = updating(
|
||||
async () => {
|
||||
version = "2.0.0";
|
||||
return result === "succeeded" ? { status: result } : { status: result, message: "no" };
|
||||
},
|
||||
{
|
||||
inspect: async () => ({ status: "installed", version }),
|
||||
refreshProvider: async () => {
|
||||
throw new Error("discovery failed");
|
||||
},
|
||||
},
|
||||
);
|
||||
await installations.inspect("codex");
|
||||
let done = settled(installations);
|
||||
installations.update("codex");
|
||||
await done;
|
||||
await expect(installations.inspect("codex")).resolves.toMatchObject({
|
||||
inspection: { version: "1.0.0" },
|
||||
});
|
||||
|
||||
result = "succeeded";
|
||||
done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
|
||||
});
|
||||
|
||||
test("retains the most recent output within the limit and marks the drop", async () => {
|
||||
const { installations, progress } = updating(
|
||||
async ({ report }) => {
|
||||
report({ type: "stdout", text: "aaaa" });
|
||||
report({ type: "stdout", text: "bbbb" });
|
||||
report({ type: "stderr", text: "cccc" });
|
||||
report({ type: "stdout", text: "0123456789XYZ" });
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{ updateOutputLimit: 10 },
|
||||
);
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
const update = await done;
|
||||
expect(update.output).toEqual([{ type: "stdout", text: "3456789XYZ" }]);
|
||||
expect(update.truncated).toBe(true);
|
||||
expect(progress.flatMap((event) => event.output).at(-1)).toEqual({
|
||||
type: "stdout",
|
||||
text: "3456789XYZ",
|
||||
});
|
||||
|
||||
const partial = updating(
|
||||
async ({ report }) => {
|
||||
report({ type: "stdout", text: "aaaa" });
|
||||
report({ type: "stdout", text: "bbbb" });
|
||||
report({ type: "stderr", text: "cccc" });
|
||||
return { status: "succeeded" };
|
||||
},
|
||||
{ updateOutputLimit: 10 },
|
||||
);
|
||||
const partialDone = settled(partial.installations);
|
||||
partial.installations.update("codex");
|
||||
await expect(partialDone).resolves.toMatchObject({
|
||||
output: [
|
||||
{ type: "stdout", text: "bbbb" },
|
||||
{ type: "stderr", text: "cccc" },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("advertises the update only when the provider declares one", async () => {
|
||||
const { installations } = updating(async () => ({ status: "succeeded" }));
|
||||
await expect(installations.inspect("codex")).resolves.toEqual({
|
||||
updateUrl: "https://example.com/update",
|
||||
canUpdate: true,
|
||||
});
|
||||
// The update never depends on a check: this provider has none, and it still updates.
|
||||
await expect(installations.checkForUpdates("codex")).resolves.toBeNull();
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
await expect(done).resolves.toMatchObject({ operation: { status: "succeeded" } });
|
||||
|
||||
const plain = new ProviderInstallations({
|
||||
logger: createTestLogger(),
|
||||
resolveClient: () => builtinClient({ updateUrl: "https://example.com/update" }),
|
||||
});
|
||||
await expect(plain.inspect("codex")).resolves.toEqual({
|
||||
updateUrl: "https://example.com/update",
|
||||
});
|
||||
expect(() => plain.update("codex")).toThrow("Provider codex cannot update itself");
|
||||
expect(plain.getUpdate("codex")).toBeNull();
|
||||
});
|
||||
|
||||
test("close aborts a running update, which settles with the provider's answer", async () => {
|
||||
function failOnAbort(signal: AbortSignal): Promise<ProviderUpdateResult> {
|
||||
return new Promise((resolve) => {
|
||||
const stopped = () =>
|
||||
resolve({ status: "failed", message: `stopped: ${String(signal.reason)}` });
|
||||
signal.addEventListener("abort", stopped);
|
||||
});
|
||||
}
|
||||
const { installations } = updating(({ signal }) => failOnAbort(signal));
|
||||
const done = settled(installations);
|
||||
installations.update("codex");
|
||||
installations.close();
|
||||
await expect(done).resolves.toMatchObject({
|
||||
operation: { status: "failed", message: "stopped: Error: The daemon is stopping" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
ProviderInstallation,
|
||||
ProviderInstallationInspection,
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateCheck,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
ProviderInspectionSchema,
|
||||
ProviderUpdateCheckSchema,
|
||||
ProviderUpdateReportSchema,
|
||||
ProviderUpdateResultSchema,
|
||||
type ProviderUpdateResult,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentClientInstallationContext,
|
||||
AgentClientUpdateContext,
|
||||
AgentProvider,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { ProviderUpdateOperation } from "./provider-update-operation.js";
|
||||
|
||||
const DEFAULT_INSPECT_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_INSPECT_CACHE_MS = 30_000;
|
||||
// Below the plugin RPC timeout, so a slow plugin check reports this deadline.
|
||||
const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 25_000;
|
||||
const DEFAULT_UPDATE_CHECK_CACHE_MS = 60 * 60_000;
|
||||
/** Characters of updater output kept per update; older output is dropped first. */
|
||||
export const DEFAULT_UPDATE_OUTPUT_LIMIT = 64 * 1024;
|
||||
|
||||
export interface ProviderInstallationsOptions {
|
||||
logger: Logger;
|
||||
/** The current client for a configured provider, or null when the provider is unknown. */
|
||||
resolveClient(provider: AgentProvider): AgentClient | null;
|
||||
inspectTimeoutMs?: number;
|
||||
inspectCacheMs?: number;
|
||||
updateCheckTimeoutMs?: number;
|
||||
/** How long a conclusive update check is reused. Failed checks use `inspectCacheMs`. */
|
||||
updateCheckCacheMs?: number;
|
||||
/** Rediscovers the provider after a successful update. A failure is logged, not reported. */
|
||||
refreshProvider?(provider: AgentProvider): Promise<void>;
|
||||
updateOutputLimit?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export type ProviderUpdateListener = (progress: ProviderInstallationUpdateProgress) => void;
|
||||
|
||||
export interface InspectProviderInstallationOptions {
|
||||
/** Skip the settled-result cache. Joins a call that is already running. */
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/** A wire result: the provider's answer, or the `failed` value when the call threw or timed out. */
|
||||
interface ProviderCallResult {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ProviderCall<T extends ProviderCallResult> {
|
||||
name: string;
|
||||
/** Validates the provider's answer; throws when it breaks the contract. */
|
||||
parse(value: unknown): T;
|
||||
timeoutMs: number;
|
||||
/** How long a settled result is reused. */
|
||||
cacheMs(result: T): number;
|
||||
run(context: AgentClientInstallationContext): Promise<unknown>;
|
||||
failed(message: string): T;
|
||||
}
|
||||
|
||||
interface CachedRead<T> {
|
||||
promise: Promise<T>;
|
||||
settledAt: number | null;
|
||||
cacheMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls provider installation callbacks for every provider, built-in or plugin, through
|
||||
* `AgentClient.installation`. Owns deadlines, in-flight sharing and result caching. Results are
|
||||
* keyed by client, so a provider configuration change, which replaces the client, starts fresh.
|
||||
* Inspection and update checks are cached separately so the installed version never waits on an
|
||||
* update check.
|
||||
*/
|
||||
export class ProviderInstallations {
|
||||
private readonly logger: Logger;
|
||||
private readonly resolveClient: ProviderInstallationsOptions["resolveClient"];
|
||||
private readonly inspectTimeoutMs: number;
|
||||
private readonly inspectCacheMs: number;
|
||||
private readonly updateCheckTimeoutMs: number;
|
||||
private readonly updateCheckCacheMs: number;
|
||||
private readonly refreshProvider: ProviderInstallationsOptions["refreshProvider"];
|
||||
private readonly updateOutputLimit: number;
|
||||
private readonly now: () => number;
|
||||
private readonly updates = new Map<AgentProvider, ProviderUpdateOperation>();
|
||||
private readonly updateListeners = new Map<AgentProvider, Set<ProviderUpdateListener>>();
|
||||
private readonly runningUpdates = new Set<AbortController>();
|
||||
private readonly inspections = new WeakMap<
|
||||
AgentClient,
|
||||
CachedRead<ProviderInstallationInspection>
|
||||
>();
|
||||
private readonly updateChecks = new WeakMap<
|
||||
AgentClient,
|
||||
CachedRead<ProviderInstallationUpdateCheck>
|
||||
>();
|
||||
/** The installed version each cached update check answered for, compared as an opaque string. */
|
||||
private readonly checkedVersions = new WeakMap<
|
||||
CachedRead<ProviderInstallationUpdateCheck>,
|
||||
string | null
|
||||
>();
|
||||
|
||||
constructor(options: ProviderInstallationsOptions) {
|
||||
this.logger = options.logger;
|
||||
this.resolveClient = options.resolveClient;
|
||||
this.inspectTimeoutMs = options.inspectTimeoutMs ?? DEFAULT_INSPECT_TIMEOUT_MS;
|
||||
this.inspectCacheMs = options.inspectCacheMs ?? DEFAULT_INSPECT_CACHE_MS;
|
||||
this.updateCheckTimeoutMs = options.updateCheckTimeoutMs ?? DEFAULT_UPDATE_CHECK_TIMEOUT_MS;
|
||||
this.updateCheckCacheMs = options.updateCheckCacheMs ?? DEFAULT_UPDATE_CHECK_CACHE_MS;
|
||||
this.refreshProvider = options.refreshProvider;
|
||||
this.updateOutputLimit = options.updateOutputLimit ?? DEFAULT_UPDATE_OUTPUT_LIMIT;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
/** Links and installed state, or null when the provider declares no installation contract. */
|
||||
async inspect(
|
||||
provider: AgentProvider,
|
||||
options: InspectProviderInstallationOptions = {},
|
||||
): Promise<ProviderInstallation | null> {
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
if (!installation) return null;
|
||||
const result: ProviderInstallation = {};
|
||||
if (installation.installUrl) result.installUrl = installation.installUrl;
|
||||
if (installation.updateUrl) result.updateUrl = installation.updateUrl;
|
||||
if (installation.update) result.canUpdate = true;
|
||||
const inspect = installation.inspect;
|
||||
if (inspect) {
|
||||
result.inspection = await this.read(provider, client, this.inspections, options, {
|
||||
name: "inspect",
|
||||
parse: (value) => ProviderInspectionSchema.parse(value),
|
||||
timeoutMs: this.inspectTimeoutMs,
|
||||
cacheMs: () => this.inspectCacheMs,
|
||||
run: (context) => inspect.call(installation, context),
|
||||
failed: (message) => ({ status: "error" as const, message }),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newer version is available, as the provider reports it, or null when the provider
|
||||
* cannot check. A check that throws or misses its deadline is reported as `error`.
|
||||
*/
|
||||
async checkForUpdates(
|
||||
provider: AgentProvider,
|
||||
options: InspectProviderInstallationOptions = {},
|
||||
): Promise<ProviderInstallationUpdateCheck | null> {
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
const checkForUpdates = installation?.checkForUpdates;
|
||||
if (!installation || !checkForUpdates) return null;
|
||||
// A cached check answers only for the version it was computed against, so a CLI updated
|
||||
// outside Paseo is not offered the update it already has. The version comes from an
|
||||
// inspection that is cached or already running; a check never starts one or waits for one
|
||||
// when it asks the provider itself.
|
||||
const knownVersion = this.knownInstalledVersion(client);
|
||||
const cached = this.updateChecks.get(client);
|
||||
if (cached && knownVersion && !options.refresh && this.isFresh(cached)) {
|
||||
const version = await knownVersion;
|
||||
// Another request may already have replaced the stale entry; share its check.
|
||||
if (
|
||||
version !== undefined &&
|
||||
this.checkedVersions.get(cached) !== version &&
|
||||
this.updateChecks.get(client) === cached
|
||||
)
|
||||
this.updateChecks.delete(client);
|
||||
}
|
||||
const check = this.read(provider, client, this.updateChecks, options, {
|
||||
name: "update check",
|
||||
parse: (value) => ProviderUpdateCheckSchema.parse(value),
|
||||
timeoutMs: this.updateCheckTimeoutMs,
|
||||
// Inconclusive answers are retried soon; a conclusive one holds for the long cache.
|
||||
cacheMs: (result) =>
|
||||
result.status === "current" || result.status === "available"
|
||||
? this.updateCheckCacheMs
|
||||
: this.inspectCacheMs,
|
||||
run: (context) => checkForUpdates.call(installation, context),
|
||||
failed: (message) => ({ status: "error" as const, message }),
|
||||
});
|
||||
const entry = this.updateChecks.get(client);
|
||||
if (entry && knownVersion && !this.checkedVersions.has(entry))
|
||||
void this.recordCheckedVersion(entry, knownVersion);
|
||||
return check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the provider's update, or returns the one already running. The update runs until the
|
||||
* provider settles it, independent of any client; only `close` aborts it. Throws when the
|
||||
* provider declares no `update`.
|
||||
*/
|
||||
update(provider: AgentProvider): ProviderInstallationUpdate {
|
||||
const running = this.updates.get(provider);
|
||||
if (running?.running) return running.snapshot();
|
||||
const client = this.requireClient(provider);
|
||||
const installation = client.installation;
|
||||
const update = installation?.update;
|
||||
if (!installation || !update) throw new Error(`Provider ${provider} cannot update itself`);
|
||||
const operation = new ProviderUpdateOperation({
|
||||
id: randomUUID(),
|
||||
provider,
|
||||
startedAt: this.timestamp(),
|
||||
outputLimit: this.updateOutputLimit,
|
||||
publish: (progress) => {
|
||||
for (const listener of this.updateListeners.get(provider) ?? []) listener(progress);
|
||||
},
|
||||
});
|
||||
this.updates.set(provider, operation);
|
||||
void this.runUpdate(provider, client, operation, (context) =>
|
||||
update.call(installation, context),
|
||||
);
|
||||
return operation.snapshot();
|
||||
}
|
||||
|
||||
/** The latest update of the provider since the daemon started, or null. */
|
||||
getUpdate(provider: AgentProvider): ProviderInstallationUpdate | null {
|
||||
return this.updates.get(provider)?.snapshot() ?? null;
|
||||
}
|
||||
|
||||
/** Calls `listener` when an update of the provider starts, reports output or settles. */
|
||||
observeUpdates(provider: AgentProvider, listener: ProviderUpdateListener): () => void {
|
||||
let listeners = this.updateListeners.get(provider);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
this.updateListeners.set(provider, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) this.updateListeners.delete(provider);
|
||||
};
|
||||
}
|
||||
|
||||
/** Aborts running updates. Each settles as failed with the provider's answer to the abort. */
|
||||
close(): void {
|
||||
for (const controller of this.runningUpdates)
|
||||
controller.abort(new Error("The daemon is stopping"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed version from an inspection that is fresh or still running, or undefined when
|
||||
* there is none. Resolves to undefined when the inspection failed, which says nothing about
|
||||
* the version.
|
||||
*/
|
||||
private knownInstalledVersion(
|
||||
client: AgentClient,
|
||||
): Promise<string | null | undefined> | undefined {
|
||||
const read = this.inspections.get(client);
|
||||
if (!read || (read.settledAt !== null && !this.isFresh(read))) return undefined;
|
||||
return read.promise.then((inspection) => {
|
||||
if (inspection.status === "error") return undefined;
|
||||
return inspection.status === "installed" ? (inspection.version ?? null) : null;
|
||||
});
|
||||
}
|
||||
|
||||
private async recordCheckedVersion(
|
||||
entry: CachedRead<ProviderInstallationUpdateCheck>,
|
||||
knownVersion: Promise<string | null | undefined>,
|
||||
): Promise<void> {
|
||||
const version = await knownVersion;
|
||||
if (version !== undefined && !this.checkedVersions.has(entry))
|
||||
this.checkedVersions.set(entry, version);
|
||||
}
|
||||
|
||||
private async runUpdate(
|
||||
provider: AgentProvider,
|
||||
client: AgentClient,
|
||||
operation: ProviderUpdateOperation,
|
||||
run: (context: AgentClientUpdateContext) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
this.runningUpdates.add(controller);
|
||||
let result: ProviderUpdateResult;
|
||||
try {
|
||||
const answer = await run({
|
||||
signal: controller.signal,
|
||||
report: (event) => {
|
||||
const parsed = ProviderUpdateReportSchema.safeParse(event);
|
||||
if (parsed.success) operation.append(parsed.data);
|
||||
else this.logger.warn({ provider }, "Ignored invalid provider update output");
|
||||
},
|
||||
});
|
||||
result = ProviderUpdateResultSchema.parse(answer);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, provider }, "Provider update failed");
|
||||
result = {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.runningUpdates.delete(controller);
|
||||
}
|
||||
if (result.status === "succeeded") {
|
||||
// The installed version and update state are now stale; read them again on next request.
|
||||
this.inspections.delete(client);
|
||||
this.updateChecks.delete(client);
|
||||
}
|
||||
operation.settle(result, this.timestamp());
|
||||
if (result.status === "succeeded" && this.refreshProvider) {
|
||||
await this.refreshProvider(provider).catch((error: unknown) => {
|
||||
this.logger.warn({ err: error, provider }, "Provider refresh after update failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return new Date(this.now()).toISOString();
|
||||
}
|
||||
|
||||
private requireClient(provider: AgentProvider): AgentClient {
|
||||
const client = this.resolveClient(provider);
|
||||
if (!client) throw new Error(`Provider ${provider} is not configured`);
|
||||
return client;
|
||||
}
|
||||
|
||||
private read<T extends ProviderCallResult>(
|
||||
provider: AgentProvider,
|
||||
client: AgentClient,
|
||||
cache: WeakMap<AgentClient, CachedRead<T>>,
|
||||
options: InspectProviderInstallationOptions,
|
||||
call: ProviderCall<T>,
|
||||
): Promise<T> {
|
||||
const existing = cache.get(client);
|
||||
if (existing && (existing.settledAt === null || (!options.refresh && this.isFresh(existing)))) {
|
||||
return existing.promise;
|
||||
}
|
||||
// invoke settles asynchronously, after `read` is initialized.
|
||||
const promise = this.invoke(provider, call).then((result) => {
|
||||
read.settledAt = this.now();
|
||||
read.cacheMs = call.cacheMs(result);
|
||||
return result;
|
||||
});
|
||||
const read: CachedRead<T> = { promise, settledAt: null, cacheMs: 0 };
|
||||
cache.set(client, read);
|
||||
return read.promise;
|
||||
}
|
||||
|
||||
private isFresh(read: CachedRead<unknown>): boolean {
|
||||
return read.settledAt !== null && this.now() - read.settledAt < read.cacheMs;
|
||||
}
|
||||
|
||||
private async invoke<T extends ProviderCallResult>(
|
||||
provider: AgentProvider,
|
||||
call: ProviderCall<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error(`Timed out after ${call.timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, call.timeoutMs);
|
||||
});
|
||||
try {
|
||||
const result = await Promise.race([call.run({ signal: controller.signal }), deadline]);
|
||||
return call.parse(result);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, provider }, `Provider installation ${call.name} failed`);
|
||||
return call.failed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ProviderInstallationUpdate,
|
||||
ProviderInstallationUpdateOperation,
|
||||
ProviderInstallationUpdateOutput,
|
||||
ProviderInstallationUpdateProgress,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { ProviderUpdateResult } from "@getpaseo/plugin/server/provider";
|
||||
|
||||
interface ProviderUpdateOperationOptions {
|
||||
id: string;
|
||||
provider: string;
|
||||
startedAt: string;
|
||||
/** Characters of output text retained; the oldest output is dropped beyond it. */
|
||||
outputLimit: number;
|
||||
publish(progress: ProviderInstallationUpdateProgress): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider update: its state and the output it reported, bounded to `outputLimit`
|
||||
* characters. Output reported after the update settles is ignored.
|
||||
*/
|
||||
export class ProviderUpdateOperation {
|
||||
private state: ProviderInstallationUpdateOperation;
|
||||
private readonly output: ProviderInstallationUpdateOutput[] = [];
|
||||
private outputLength = 0;
|
||||
private truncated = false;
|
||||
|
||||
constructor(private readonly options: ProviderUpdateOperationOptions) {
|
||||
this.state = { status: "running", id: options.id, startedAt: options.startedAt };
|
||||
this.publish([]);
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
return this.state.status === "running";
|
||||
}
|
||||
|
||||
append(entry: ProviderInstallationUpdateOutput): void {
|
||||
if (!this.running) return;
|
||||
const limit = this.options.outputLimit;
|
||||
const kept =
|
||||
entry.text.length > limit ? { type: entry.type, text: entry.text.slice(-limit) } : entry;
|
||||
if (kept !== entry) this.truncated = true;
|
||||
this.output.push(kept);
|
||||
this.outputLength += kept.text.length;
|
||||
while (this.outputLength > limit) {
|
||||
const dropped = this.output.shift()!;
|
||||
this.outputLength -= dropped.text.length;
|
||||
this.truncated = true;
|
||||
}
|
||||
this.publish([kept]);
|
||||
}
|
||||
|
||||
settle(result: ProviderUpdateResult, finishedAt: string): void {
|
||||
if (!this.running) return;
|
||||
const { id, startedAt } = this.state;
|
||||
this.state =
|
||||
result.status === "succeeded"
|
||||
? {
|
||||
status: "succeeded",
|
||||
id,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
...(result.message === undefined ? {} : { message: result.message }),
|
||||
}
|
||||
: { status: "failed", id, startedAt, finishedAt, message: result.message };
|
||||
this.publish([]);
|
||||
}
|
||||
|
||||
snapshot(): ProviderInstallationUpdate {
|
||||
return {
|
||||
operation: this.state,
|
||||
output: [...this.output],
|
||||
...(this.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private publish(output: ProviderInstallationUpdateOutput[]): void {
|
||||
this.options.publish({ provider: this.options.provider, operation: this.state, output });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user