mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
feat: sidebar header and footer plugin items, Usage in the sidebar footer (#5685)
* refactor(usage): remove agent-to-usage wiring
* feat(plugins): screens, sidebar header and footer items
Plugins register screens with addScreen and contribute component-based
sidebar items with addSidebarHeaderItem and addSidebarFooterItem. Items get
currentScreen, openScreen and openPopover, and render the SidebarRow and
SidebarButton kit. The footer shares the header's ordering model through the
new sidebarFooterItems preference, and Settings > Sidebar gains a Footer card.
addSurface, openSurface and addSidebarItem stay as aliases with their
settings keys and routes.
* refactor(plugins): share resolvePluginPlatform and keep header remembered-host key
Consolidate the five resolvePlatform copies into plugins/platform.ts.
Header sidebar items keep the <pluginId>/sidebar/<id> remembered-host key
so existing users keep their remembered host; footer items use
<pluginId>/sidebar-footer/<id>.
* feat(usage): global usage summary, pins and used/remaining display
- Device-local usage preferences (displayAs, pinned windows) in app settings.
- Sidebar footer usage summary: pinned windows in pin order, one per account;
opens the expanded view as a popover on desktop and a sheet on compact.
- Pin checkboxes on window rows and a used/remaining toggle shared by the
summary, popover, Usage screen and Settings usage section.
- Usage screen shows one host with a selector when several are connected.
- Extract HostSwitcher and the sidebar popover host for reuse.
* fix(usage): render each usage report as its own card
* feat(usage): row pinning, one title row for usage controls, even sidebar footer
- Window rows are the pin toggle (checkbox role, checked state); pinned rows
get a surface2 highlight, unpinned rows an interactionHighlight hover.
- Usage screen, Settings section, popover and sheet put the title left and
the host selector, used/remaining toggle and Refresh right (UsageControls).
PageLayout gains an actions slot; the menu sheet header a trailing slot.
- Sidebar footer rows share 28px height, spacing[1] gaps and one leading rail.
* fix(usage): keep row hover lighter than the pinned highlight
Hovering an unpinned window row shows the pinned surface2 at half opacity on
a separate layer, so it never reads stronger than a pinned row. Pinned rows
do not react to hover.
* fix(app): defer sidebar styles and preserve layout exports in timeline test
* fix(usage): open the Usage screen on desktop, drop the Usage footer icon, match git panel refresh
- Desktop presses on the usage summary open the Usage screen; only compact opens the sheet.
- The Usage footer icon is gone; with nothing pinned the summary renders a plain "Usage" row
in the footer row style. A stored `usage` footer preference is ignored.
- Refresh controls use the Changes panel's icon-only ToolbarButton with RotateCw.
* fix(sidebar): keep the footer icon row fixed; footer items are rows only
The footer's icon row (Hosts, Import session, Help and support, Settings) is back to main's
fixed implementation: not in Settings, not reorderable, and plugins can't add to it.
- Footer items are full-width rows between Add project and the icon row: the usage summary
and plugin `addSidebarFooterItem` rows. The Footer card in Settings lists only those rows.
- Stored `sidebarFooterItems` keys for hosts, import, help and usage are ignored.
- `SidebarButton` is removed from the plugin UI kit; a footer item renders `SidebarRow`.
* refactor(usage): call the sidebar footer item Usage
One concept, one word: the footer item is the Usage item, with key `usage`.
- Settings > Sidebar > Footer lists it as "Usage"; its preference key is `usage`.
- `usage/summary-item.tsx` becomes `usage/sidebar-item.tsx` (`UsageSidebarItem`); both states
use testID `sidebar-usage`.
- `usage/summary.ts` becomes `usage/pinned.ts` (`resolvePinnedUsage`, `PinnedUsageWindow`):
it resolves the pinned windows the item shows.
* feat(plugins): screen params, several rows per sidebar item, SidebarSeparator
openScreen takes { screenId, params }; params live in the screen route's
query so reload, history and links keep them. currentScreen carries the
params. A sidebar item may render a fragment or array of rows; a row press
anchors the popover to that row. SidebarSeparator joins the plugin UI kit.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(plugins): any screen param key; trailing presses anchor the row's popover
Screen params go in the query under a param. prefix, so keys like serverId
no longer collide with the route segments Expo Router merges into search
params. A press anywhere inside a SidebarRow, trailing content included,
makes that row the popover anchor.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(plugins): plugins title their screens
addScreen takes { id, title, Component }. title is a string or a function
of the screen's params; the screen header shows it and follows param
changes. A title function that throws or returns empty falls back to the
screen id. Legacy addSidebarItem screens keep their item's title and icon,
and the addSurface alias titles its screen with the id.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(usage): require discovery and remove unreleased agent resolver
* fix(plugins): restore legacy sidebar rows, share the item runtime, header hint presses its row
- Legacy addSidebarItem rows are their own group kind again: main's row, registered icon in
Settings, /sidebar/<id> route, host remembered per row.
- New-API items follow the host last used on any of the plugin's screens.
- /sidebar/<id> falls back to a same-id screen so pre-migration links resolve.
- params moves to PluginScreenProps; settings screens no longer receive it.
- Sidebar items, buttons and popovers share one runtime per installation, so no empty first frame.
- A header row's hint slot presses the row; labels wrap as on main.
- Skill and docs corrected.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(usage): one host resolver, sheet reuses the screen's controls, name the host
- The Usage row and screen default to the same host.
- The compact sheet carries the screen's controls, host selector included.
- The host selector shows with one host, and unavailable messages name the host.
- The row, sheet title and pinned label use the sidebar.footer.usage translation.
- Window rows read their percent and reset after the pin label.
- Drop the unread per-report cache writes and headline fixture fields.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(plugins): popovers, header buttons and composer pills own their runtime again
Only sidebar item rows use the installation's shared runtime. Popover content
disposes its runtime on unmount, so observations close with the popover.
* fix(plugins): a sidebar item releases its subscriptions when it unmounts
Each mounted sidebar item gets its own API scope over the host's client, created
during render so its first frame has content. The scope closes a microtask after
unmount (StrictMode's remount keeps it), when the item moves to another host, or
when the installation stops. Replaces the installation-wide shared runtime.
* refactor(plugins): one Paseo client per plugin; authors own their subscriptions
usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.
* refactor(plugins): one Paseo client per plugin; authors own their subscriptions
usePaseo() in every plugin surface, and client.paseo in setup, is the
installation's client, created with the installation in the registry. Authors
release their subscriptions in their own cleanup; plugin teardown disposes the
client and ends the rest. Nothing is created or disposed per mounted surface:
the per-surface runtimes, their boundaries, and the per-run Command Center and
slash command APIs are gone.
* fix(usage): show usage from released hosts
Normalize released-host usage responses at the client boundary so existing usage and pins remain available across app and daemon versions. Include account labels in legacy responses for older apps.
* fix(usage): keep the usage capability check out of the public SDK
* test(server): retry transient locks during runtime test cleanup
* test(usage): model hosts without either usage capability
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
@@ -34,6 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+72
-8
@@ -259,12 +259,23 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="MessageCircle"
|
||||
active={currentScreen?.screenId === "main"}
|
||||
onPress={() => openScreen({ screenId: "main" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Greeting);
|
||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
|
||||
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -277,15 +288,33 @@ errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
|
||||
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
|
||||
that id, so links saved before a plugin migrated keep working.
|
||||
|
||||
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
|
||||
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
|
||||
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
|
||||
keys are stored under a `param.` prefix; plugins never see it and may use any key.
|
||||
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
|
||||
params.
|
||||
|
||||
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
||||
Paseo gives each plugin installation its own query client.
|
||||
|
||||
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
|
||||
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
|
||||
`useRpc()` to that host. An offline selected host fails there and never falls through to another
|
||||
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
|
||||
|
||||
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
|
||||
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
|
||||
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
|
||||
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
|
||||
disposed per mounted surface. See the
|
||||
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
|
||||
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
@@ -321,7 +350,7 @@ existing agent-context instances, but it cannot create an agent panel without an
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
|
||||
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
@@ -392,6 +421,41 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
Sidebar header and footer items are plugin components, not descriptors. The
|
||||
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
|
||||
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
|
||||
one section's order from built-ins, plugin groups, and the section's preference
|
||||
(`sidebarNavItems` or `sidebarFooterItems`).
|
||||
|
||||
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
|
||||
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
|
||||
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
|
||||
button.
|
||||
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
|
||||
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
|
||||
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
|
||||
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
|
||||
the responder system, so the row's own press anchors too. Until a press, the first mounted row
|
||||
holds the anchor. An item that renders no kit component
|
||||
anchors to its section container.
|
||||
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
|
||||
the line runs edge to edge in both sections.
|
||||
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
|
||||
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
|
||||
row; a button inside it wins its own press because only the innermost pressable responds.
|
||||
- A new-API item renders from the current route's host, else the host remembered under
|
||||
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
|
||||
set. One key per plugin: switching host on any of its screens moves all its items.
|
||||
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
|
||||
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
|
||||
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
|
||||
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
@@ -461,7 +525,7 @@ be rendered intact. The daemon advertises this RPC through
|
||||
|
||||
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
## Usage sources
|
||||
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
|
||||
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
LEGACY_PLUGIN_ID,
|
||||
SHOWCASE_PLUGIN_ID,
|
||||
WIDE,
|
||||
closeScreen,
|
||||
expectRowActive,
|
||||
footerItem,
|
||||
headerRow,
|
||||
installSidebarPlugins,
|
||||
popoverBox,
|
||||
runCommand,
|
||||
visibleTestId,
|
||||
} from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area?: Locator) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let popover, sheet and drawer animations settle so the image shows the final state.
|
||||
await page.waitForTimeout(600);
|
||||
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (area) await area.screenshot({ path: file });
|
||||
else await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
|
||||
async function expectFooterRow(page: Page, row: Locator) {
|
||||
// Polled: on compact the drawer is still sliding in when the row first shows.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
|
||||
const hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
|
||||
const box = (await row.boundingBox())!;
|
||||
return {
|
||||
belowAddProject: box.y >= addProject.y + addProject.height,
|
||||
aboveIconRow: box.y + box.height <= hosts.y,
|
||||
alignedLeft: Math.abs(box.x - addProject.x) < 1,
|
||||
fullWidth: Math.abs(box.width - addProject.width) < 1,
|
||||
};
|
||||
})
|
||||
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
|
||||
}
|
||||
|
||||
function botRow(page: Page, rowId: string): Locator {
|
||||
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
|
||||
}
|
||||
|
||||
async function boxOf(locator: Locator) {
|
||||
const result = await locator.boundingBox();
|
||||
expect(result).not.toBeNull();
|
||||
return result!;
|
||||
}
|
||||
|
||||
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
|
||||
function screenTitle(page: Page): Locator {
|
||||
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
|
||||
}
|
||||
|
||||
async function expectBotScreen(page: Page, botId: string) {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
|
||||
);
|
||||
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
|
||||
// The plugin titles the screen from its params: the bot's name.
|
||||
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
|
||||
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
|
||||
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
|
||||
await expectRowActive(botRow(page, "status"), false);
|
||||
}
|
||||
|
||||
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
|
||||
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
|
||||
const popover = await popoverBox(page, text);
|
||||
const rowBox = await boxOf(row);
|
||||
const otherBox = await boxOf(elsewhere);
|
||||
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
|
||||
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
|
||||
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
|
||||
}
|
||||
|
||||
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
|
||||
function trackReleasedSubscriptions(page: Page): Set<string> {
|
||||
const released = new Set<string>();
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
|
||||
);
|
||||
return released;
|
||||
}
|
||||
|
||||
function collectRelease(released: Set<string>, payload: string | Buffer) {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.message?.type === "subscription.release.response")
|
||||
released.add(frame.message.payload.subscriptionId);
|
||||
}
|
||||
|
||||
async function expectReleased(released: Set<string>, subscriptionId: string) {
|
||||
await expect.poll(() => released.has(subscriptionId)).toBe(true);
|
||||
}
|
||||
|
||||
function readAlertsObservation(page: Page): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/** The id of the observation the Bot alerts item opened after `previous`. */
|
||||
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
|
||||
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
|
||||
return (await readAlertsObservation(page))!;
|
||||
}
|
||||
|
||||
function sidebarFooter(page: Page): Locator {
|
||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||
}
|
||||
|
||||
test.describe("Plugin sidebar items", () => {
|
||||
let workspaceId: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
|
||||
const plugins = await installSidebarPlugins();
|
||||
workspaceId = workspace.workspaceId;
|
||||
cleanup = async () => {
|
||||
await plugins.cleanup();
|
||||
await workspace.cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sync).toBeVisible();
|
||||
|
||||
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
|
||||
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
|
||||
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the header row highlights while its screen is open", async () => {
|
||||
await expectRowActive(row, false);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Deploys");
|
||||
await expectRowActive(row, true);
|
||||
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
|
||||
await closeScreen(page);
|
||||
await expectRowActive(row, false);
|
||||
});
|
||||
|
||||
await test.step("the trailing button presses on its own", async () => {
|
||||
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
|
||||
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
|
||||
await expect(sync).toHaveAccessibleName("Sync");
|
||||
await expectFooterRow(page, sync);
|
||||
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
|
||||
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
|
||||
});
|
||||
|
||||
await test.step("the footer row opens a popover anchored to it", async () => {
|
||||
await sync.click();
|
||||
const popover = await popoverBox(page);
|
||||
const button = (await sync.boundingBox())!;
|
||||
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
|
||||
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
|
||||
expect(button.y - popover.y).toBeLessThan(300);
|
||||
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
|
||||
await qaScreenshot(page, "phase2-desktop-popover");
|
||||
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("the command center item opens the screen", async () => {
|
||||
await runCommand(page, "Open deploys");
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(row, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("on a compact layout the popover is a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
|
||||
const sheet = await popoverBox(page);
|
||||
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
|
||||
await qaScreenshot(page, "phase2-compact-sheet");
|
||||
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
|
||||
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("a header row's shortcut hint presses the row", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
|
||||
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await newWorkspace.hover();
|
||||
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
|
||||
await expect(hint).toBeVisible();
|
||||
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
|
||||
await hint.click();
|
||||
await expect(page).toHaveURL(/\/new(\?|$)/);
|
||||
});
|
||||
|
||||
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.addInitScript(
|
||||
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
|
||||
{
|
||||
key: APP_SETTINGS_KEY,
|
||||
sidebarNavItems: [
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
let entryIcon = "";
|
||||
|
||||
await test.step("the saved order and visibility still apply", async () => {
|
||||
await expect(entry).toHaveAccessibleName("Legacy entry");
|
||||
await expect(entry.locator("svg")).toHaveCount(1);
|
||||
entryIcon = await entry.locator("svg").innerHTML();
|
||||
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
|
||||
const entryBox = (await entry.boundingBox())!;
|
||||
const newWorkspaceBox = (await visibleTestId(
|
||||
page,
|
||||
"sidebar-global-new-workspace",
|
||||
).boundingBox())!;
|
||||
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
|
||||
});
|
||||
|
||||
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
|
||||
await entry.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
|
||||
await expect(screenTitle(page)).toHaveText("Legacy entry");
|
||||
await expectRowActive(entry, true);
|
||||
await closeScreen(page);
|
||||
});
|
||||
|
||||
await test.step("remembered sidebar routes keep resolving", async () => {
|
||||
await page.goto(
|
||||
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
|
||||
);
|
||||
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
|
||||
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
|
||||
});
|
||||
|
||||
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
|
||||
const host = encodeURIComponent(getServerId());
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
|
||||
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(screenTitle(page)).toHaveText("Bot 2");
|
||||
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
|
||||
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
});
|
||||
|
||||
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const legacyIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const showcaseIcon = page
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const footerIcon = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
|
||||
.locator("svg")
|
||||
.first();
|
||||
const markup = await showcaseIcon.innerHTML();
|
||||
expect(await footerIcon.innerHTML()).toBe(markup);
|
||||
expect(await legacyIcon.innerHTML()).not.toBe(markup);
|
||||
// The same registered icon the sidebar row draws.
|
||||
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
|
||||
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
|
||||
await qaScreenshot(
|
||||
page,
|
||||
"phase5-settings-sidebar-footer",
|
||||
page.getByTestId("sidebar-nav-section-footer"),
|
||||
);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
|
||||
await qaScreenshot(page, "phase2-compact-settings-sidebar");
|
||||
});
|
||||
});
|
||||
test("one item renders a group of rows, and each row opens its own screen params", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const bot1 = botRow(page, "bot-1");
|
||||
const bot2 = botRow(page, "bot-2");
|
||||
const status = botRow(page, "status");
|
||||
await expect(bot1).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("the rows and the separator render in order in the header", async () => {
|
||||
await expect(bot1).toHaveAccessibleName("Bot 1");
|
||||
await expect(bot2).toHaveAccessibleName("Bot 2");
|
||||
await expect(status).toHaveAccessibleName("Bot status");
|
||||
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
|
||||
expect(second.y).toBeGreaterThan(first.y);
|
||||
// The separator adds its line and margins between Bot 2 and the status row.
|
||||
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
|
||||
expect(second.y - (first.y + first.height)).toBeLessThan(4);
|
||||
});
|
||||
|
||||
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
|
||||
await bot2.click();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the screen and its params", async () => {
|
||||
await page.reload();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
await qaScreenshot(page, "phase6-bot-screen");
|
||||
});
|
||||
|
||||
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
|
||||
await bot1.click();
|
||||
await expectBotScreen(page, "bot-1");
|
||||
await page.goBack();
|
||||
await expectBotScreen(page, "bot-2");
|
||||
});
|
||||
|
||||
await test.step("a popover opened from a later row anchors to that row", async () => {
|
||||
await status.click();
|
||||
await expectPopoverBeside(page, "Bot status details", status, bot1);
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
|
||||
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
|
||||
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
|
||||
await qaScreenshot(page, "phase6-desktop-popover-row");
|
||||
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
|
||||
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("a param named like a route segment reaches the screen", async () => {
|
||||
await runCommand(page, "Open bot 3 on server x");
|
||||
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
// No bot named bot-3, so the title function returns its own fallback.
|
||||
await expect(screenTitle(page)).toHaveText("Bot");
|
||||
await page.reload();
|
||||
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Settings lists the group as one item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const header = page.getByTestId("sidebar-nav-section-header");
|
||||
await expect(
|
||||
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
|
||||
).toHaveCount(1);
|
||||
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
|
||||
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("the compact sidebar shows the same group", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "bot-1")).toBeInViewport();
|
||||
await expect(botRow(page, "status")).toBeInViewport();
|
||||
await qaScreenshot(page, "phase6-compact-sidebar-bots");
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
await expect(alerts).toHaveAccessibleName("Bot alerts");
|
||||
await expectFooterRow(page, alerts);
|
||||
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const released = trackReleasedSubscriptions(page);
|
||||
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runCommand(page, "Add bot alerts");
|
||||
await expect(alerts).toBeVisible();
|
||||
const first = await nextAlertsObservation(page, null);
|
||||
expect(released.has(first)).toBe(false);
|
||||
|
||||
await test.step("hiding the item in Settings closes its observation", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
|
||||
await expectReleased(released, first);
|
||||
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
|
||||
await leaveSettings(page);
|
||||
});
|
||||
|
||||
await test.step("removing the item through its remover closes its observation", async () => {
|
||||
await expect(alerts).toBeVisible();
|
||||
const second = await nextAlertsObservation(page, first);
|
||||
expect(released.has(second)).toBe(false);
|
||||
await runCommand(page, "Remove bot alerts");
|
||||
await expect(alerts).toHaveCount(0);
|
||||
await expectReleased(released, second);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
expectFooterIconRow,
|
||||
expectFooterItemHidden,
|
||||
expectFooterOrder,
|
||||
expectFooterSettingsKeys,
|
||||
expectSidebarItemHidden,
|
||||
expectSidebarNavSettingsOrder,
|
||||
expectSidebarNavSettingsRow,
|
||||
expectSidebarOrder,
|
||||
expectStoredSidebarNav,
|
||||
leaveSettings,
|
||||
moveFooterItemUp,
|
||||
moveSidebarNavItemUp,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarFooterPreferences,
|
||||
seedSidebarNavPreferences,
|
||||
setFooterItemVisible,
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
@@ -24,8 +32,8 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await test.step("the Sidebar section lists every item in the same order", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
|
||||
"About Header",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
@@ -106,3 +114,63 @@ test.describe("Sidebar items in Appearance settings", () => {
|
||||
await expectSidebarItemHidden(page, "schedules");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
|
||||
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ cleanup } = await installSidebarPlugins());
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "help", visible: false },
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the Footer card lists only the footer rows", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
|
||||
"About Footer",
|
||||
);
|
||||
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
|
||||
await leaveSettings(page);
|
||||
await expectFooterOrder(page, ["usage", syncKey]);
|
||||
await expectFooterIconRow(page);
|
||||
});
|
||||
|
||||
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await moveFooterItemUp(page, syncKey);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
await leaveSettings(page);
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expect(
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).toBeVisible();
|
||||
await expectFooterIconRow(page);
|
||||
});
|
||||
|
||||
await test.step("the rows keep that shape across a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expectFooterIconRow(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
|
||||
return {
|
||||
id: REPORT_ID,
|
||||
account: { label: "Fixture account" },
|
||||
fetchedAt,
|
||||
sourceId: "fixture",
|
||||
sourceLabel: "Fixture plan",
|
||||
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 3 },
|
||||
{ id: "weekly", label: "Weekly", usedPct, headline: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "usage-composer-pill-",
|
||||
title: "Usage composer pill e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
|
||||
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
|
||||
await expect(popover.getByText("Test plan")).toBeVisible();
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes only its report from the popover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: [REPORT_ID] },
|
||||
{ forceRefresh: true, reportIds: [REPORT_ID] },
|
||||
]);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(pill).toContainText("64%");
|
||||
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
|
||||
|
||||
// Pressing closes the tooltip; it reopens on the next hover.
|
||||
await popover.getByText("Fixture plan", { exact: true }).hover();
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
expect(usage.listRequests()).toHaveLength(2);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [
|
||||
[agentEntry(42, twoHoursAgo())],
|
||||
{ error: "Usage source timed out" },
|
||||
() => [agentEntry(64, new Date().toISOString())],
|
||||
],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
const refresh = popover.getByTestId("usage-refresh");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
|
||||
"Unable to refresh usage",
|
||||
);
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
await expect(pill).toContainText("42%");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(3);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("prints the freshness on the card where there is no hover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
const requestsBeforeTurn = usage.agentRequests().length;
|
||||
const listRequestsBeforeTurn = usage.listRequests().length;
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
|
||||
|
||||
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
|
||||
expect(usage.agentRequests().at(-1)).toEqual({
|
||||
agentId: session.agentId,
|
||||
});
|
||||
await expect(pill).toContainText("42%");
|
||||
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden when the agent has no usage report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await usage.waitForAgentRequests(1);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("is hidden on a host without usage sources", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSources: false,
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
|
||||
expect(usage.agentRequests()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
@@ -9,8 +11,24 @@ function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
/** The host selector names the host shown, even when it is the only one. */
|
||||
async function shownHostLabel(page: Page): Promise<string> {
|
||||
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
|
||||
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
|
||||
return (await selector.innerText()).trim();
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
|
||||
test("opens from the sidebar on the host's reports, naming the one host", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
@@ -24,7 +42,7 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -46,6 +64,11 @@ test.describe("usage screen", () => {
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
|
||||
`Usage host: ${hostLabel}`,
|
||||
);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
@@ -66,7 +89,7 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
@@ -77,7 +100,7 @@ test.describe("usage screen", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
@@ -138,19 +161,21 @@ test.describe("usage screen", () => {
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
});
|
||||
|
||||
test("tells the user to update a host without usage sources", async ({ page }) => {
|
||||
test("tells the user to update a host without usage support", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe("usage settings", () => {
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
planLabel: "Max",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
@@ -82,6 +82,10 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await card.getByTestId("usage-display-remaining").click();
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh forces a fresh report", async ({ page }) => {
|
||||
@@ -119,17 +123,18 @@ test.describe("usage settings", () => {
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSources: false });
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
|
||||
// Names the host: "Update Laptop to see usage".
|
||||
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectNoPinnedUsage,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
pinRow,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let sheet and drawer animations settle so the image shows the final state.
|
||||
await page.waitForTimeout(600);
|
||||
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
const file = path.join(directory, `phase4-${name}.png`);
|
||||
if (area.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** The sidebar footer, from Add project down to the icon row, with some margin. */
|
||||
async function footerClip(page: Page) {
|
||||
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
|
||||
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
|
||||
const margin = 16;
|
||||
const x = Math.max(0, top.x - margin);
|
||||
const y = Math.max(0, top.y - margin);
|
||||
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: the selector still names it.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["31%", "12%"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
|
||||
// The row reads its window, percent and reset; the checkbox state says it is pinned.
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the Usage screen's controls, host selector included.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoPinnedUsage(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const footer = page.getByTestId("sidebar-nav-section-footer");
|
||||
await expect(
|
||||
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
|
||||
).toBeVisible();
|
||||
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
|
||||
await leaveSettings(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ function Details({ workspaceId, theme, layout, close }) {
|
||||
React.useEffect(() => {
|
||||
const owner = paseo.observeEvents(["project.update"]);
|
||||
owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } });
|
||||
// Deliberately leave this observation to the mounted host scope.
|
||||
return () => { void owner.release(); };
|
||||
}, [paseo]);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
export const SHOWCASE_PLUGIN_ID = "sidebar-showcase";
|
||||
export const LEGACY_PLUGIN_ID = "legacy-sidebar";
|
||||
export const BOTS_PLUGIN_ID = "sidebar-bots";
|
||||
export const WIDE = { width: 1440, height: 900 };
|
||||
export const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
/** A plugin written against the current API: a screen, header and footer items, a command. */
|
||||
const SHOWCASE_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function DeploysScreen({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Deploys screen body</Text></View>;
|
||||
}
|
||||
|
||||
function RefreshButton({ theme, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Refresh deploys" onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Refresh</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploysItem({ theme, currentScreen, openScreen }) {
|
||||
const [refreshes, setRefreshes] = React.useState(0);
|
||||
return (
|
||||
<SidebarRow
|
||||
icon="Rocket"
|
||||
label={refreshes === 0 ? undefined : "Deploys (refreshed " + refreshes + ")"}
|
||||
active={currentScreen?.screenId === "deploys"}
|
||||
onPress={() => openScreen({ screenId: "deploys" })}
|
||||
trailing={<RefreshButton theme={theme} onPress={() => setRefreshes((count) => count + 1)} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncDetails({ theme, layout, close, openScreen }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Sync details</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Open deploys from popover" onPress={() => openScreen({ screenId: "deploys" })}><Text style={{ color: theme.colors.foreground }}>Open deploys</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close sync details" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncItem({ openPopover }) {
|
||||
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("Sidebar item exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addScreen({ id: "deploys", title: "Deploys", Component: DeploysScreen });
|
||||
client.addSidebarHeaderItem({ id: "deploys", title: "Deploys", Component: DeploysItem });
|
||||
client.addSidebarHeaderItem({ id: "broken", title: "Broken header", Component: Broken });
|
||||
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
|
||||
client.addSidebarFooterItem({ id: "broken", title: "Broken footer", Component: Broken });
|
||||
client.addCommandCenterItem({ id: "open-deploys", title: "Open deploys", icon: "Rocket", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "deploys" }) });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* One header item that renders a group: a row per bot, a separator and a status row. Each bot
|
||||
* row opens the bot screen with the bot's id as a param, and its trailing More button opens that
|
||||
* bot's popover. Commands open a bot with a `serverId` param, and add and remove a footer item
|
||||
* after setup; that item opens an event observation, releases it in its cleanup, and publishes its
|
||||
* id on `globalThis.__botAlertsObservation`.
|
||||
*/
|
||||
const BOTS_SOURCE = `import React from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
const BOTS = [
|
||||
{ id: "bot-1", name: "Bot 1" },
|
||||
{ id: "bot-2", name: "Bot 2" },
|
||||
];
|
||||
|
||||
function BotScreen({ theme, params }) {
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 24, gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{"Bot screen: " + params.botId}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{"serverId param: " + (params.serverId ?? "none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function botTitle(params) {
|
||||
return BOTS.find((bot) => bot.id === params.botId)?.name ?? "Bot";
|
||||
}
|
||||
|
||||
const botMenus = {};
|
||||
|
||||
function botMenu(bot) {
|
||||
botMenus[bot.id] ??= function BotMenu({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{bot.name + " options"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"Close " + bot.name + " options"} onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return botMenus[bot.id];
|
||||
}
|
||||
|
||||
function MoreButton({ theme, bot, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={"More for " + bot.name} onPress={onPress} style={{ paddingHorizontal: 6 }}>
|
||||
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>More</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function BotStatus({ theme, close }) {
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: theme.colors.foreground }}>Bot status details</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close bot status" onPress={close}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BotsItem({ theme, currentScreen, openScreen, openPopover }) {
|
||||
return (
|
||||
<>
|
||||
{BOTS.map((bot) => (
|
||||
<SidebarRow
|
||||
key={bot.id}
|
||||
id={bot.id}
|
||||
icon="Bot"
|
||||
label={bot.name}
|
||||
active={currentScreen?.screenId === "bot" && currentScreen.params.botId === bot.id}
|
||||
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
|
||||
trailing={<MoreButton theme={theme} bot={bot} onPress={() => openPopover(botMenu(bot))} />}
|
||||
/>
|
||||
))}
|
||||
<SidebarSeparator />
|
||||
<SidebarRow id="status" icon="Activity" label="Bot status" onPress={() => openPopover(BotStatus)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsItem() {
|
||||
const paseo = usePaseo();
|
||||
React.useEffect(() => {
|
||||
const observation = paseo.observeEvents(["project.update"]);
|
||||
observation.subscribe({ snapshot(value) { globalThis.__botAlertsObservation = value.subscriptionId; }, update() {} });
|
||||
return () => { void observation.release(); };
|
||||
}, [paseo]);
|
||||
return <SidebarRow icon="Bell" onPress={() => {}} />;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
let removeAlerts = null;
|
||||
client.addScreen({ id: "bot", title: botTitle, Component: BotScreen });
|
||||
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
|
||||
client.addCommandCenterItem({ id: "open-bot-3", title: "Open bot 3 on server x", icon: "Bot", context: "global", onSelect: (ctx) => ctx.openScreen({ screenId: "bot", params: { botId: "bot-3", serverId: "x" } }) });
|
||||
client.addCommandCenterItem({ id: "add-alerts", title: "Add bot alerts", icon: "Bell", context: "global", onSelect: () => {
|
||||
if (!removeAlerts) removeAlerts = client.addSidebarFooterItem({ id: "alerts", title: "Bot alerts", Component: AlertsItem });
|
||||
} });
|
||||
client.addCommandCenterItem({ id: "remove-alerts", title: "Remove bot alerts", icon: "BellOff", context: "global", onSelect: () => {
|
||||
removeAlerts?.();
|
||||
removeAlerts = null;
|
||||
} });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
/** A plugin that still uses only the old descriptor calls. */
|
||||
const LEGACY_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
function Main({ theme }) {
|
||||
return <View style={{ flex: 1, padding: 24 }}><Text style={{ color: theme.colors.foreground }}>Legacy screen body</Text></View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Main);
|
||||
plugin.addSidebarItem({ id: "entry", title: "Legacy entry", icon: "Server", surface: "main" });
|
||||
plugin.addSidebarItem({ id: "hidden", title: "Legacy hidden", icon: "Blocks", surface: "main" });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
export async function installSidebarPlugins() {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directories: string[] = [];
|
||||
for (const [id, source] of [
|
||||
[SHOWCASE_PLUGIN_ID, SHOWCASE_SOURCE],
|
||||
[LEGACY_PLUGIN_ID, LEGACY_SOURCE],
|
||||
[BOTS_PLUGIN_ID, BOTS_SOURCE],
|
||||
] as const) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${id}-`));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), source);
|
||||
}
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
for (const directory of directories) await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
async cleanup() {
|
||||
await client.removePlugin(SHOWCASE_PLUGIN_ID);
|
||||
await client.removePlugin(LEGACY_PLUGIN_ID);
|
||||
await client.removePlugin(BOTS_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await Promise.all(
|
||||
directories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
export function visibleTestId(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
export function headerRow(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export function footerItem(page: Page, pluginId: string, itemId: string): Locator {
|
||||
return visibleTestId(page, `plugin-sidebar-footer-${pluginId}-${itemId}`);
|
||||
}
|
||||
|
||||
export async function expectRowActive(row: Locator, active: boolean): Promise<void> {
|
||||
if (active) await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
else await expect(row).not.toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeScreen(page: Page): Promise<void> {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
await expect(page).not.toHaveURL(/\/plugin\//);
|
||||
}
|
||||
|
||||
/** Box of the popover body, located from the text it renders. */
|
||||
export async function popoverBox(page: Page, text = "Sync details") {
|
||||
const details = page.getByText(text, { exact: true });
|
||||
await expect(details).toBeInViewport();
|
||||
const box = await details.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!;
|
||||
}
|
||||
@@ -55,10 +55,25 @@ export async function seedSidebarNavPreferences(
|
||||
);
|
||||
}
|
||||
|
||||
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
|
||||
export async function seedSidebarFooterPreferences(
|
||||
page: Page,
|
||||
preferences: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function leaveSettings(page: Page): Promise<void> {
|
||||
@@ -125,9 +140,9 @@ export async function expectStoredSidebarNav(
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectVerticalOrder(
|
||||
keys: SidebarNavKey[],
|
||||
locate: (key: SidebarNavKey) => Locator,
|
||||
async function expectVerticalOrder<Key extends string>(
|
||||
keys: Key[],
|
||||
locate: (key: Key) => Locator,
|
||||
subject: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
@@ -136,11 +151,7 @@ async function expectVerticalOrder(
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
@@ -148,3 +159,83 @@ async function expectVerticalOrder(
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
// With nothing pinned the Usage item is the plain Usage row.
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
}
|
||||
|
||||
function shellFooterRow(page: Page, key: string): Locator {
|
||||
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
|
||||
}
|
||||
|
||||
function footerSettingsRows(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.locator('[data-testid^="sidebar-nav-item-"]');
|
||||
}
|
||||
|
||||
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
await footerSettingsRows(page).evaluateAll((rows) =>
|
||||
rows.map((row) => row.getAttribute("data-testid")),
|
||||
)
|
||||
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
|
||||
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-move-up-${key}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
|
||||
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
|
||||
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-import-session",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
);
|
||||
const [first, ...rest] = boxes.map((box) => box!);
|
||||
let previous = first;
|
||||
for (const box of rest) {
|
||||
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
|
||||
expect(box.x).toBeGreaterThan(previous.x);
|
||||
previous = box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
|
||||
agentRequests(): Array<{ agentId: string }>;
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
waitForAgentRequests(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
@@ -15,10 +13,8 @@ interface UsageReportsFixtureOptions {
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
|
||||
usageSources?: boolean;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
@@ -42,7 +38,7 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; payload?: Record<string, unknown> };
|
||||
@@ -61,7 +57,10 @@ function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): s
|
||||
...envelope,
|
||||
message: {
|
||||
...envelope.message,
|
||||
payload: { ...payload, features: { ...features, usageSources: enabled } },
|
||||
payload: {
|
||||
...payload,
|
||||
features: { ...features, usageSources: enabled, providerUsageList: enabled },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -95,10 +94,8 @@ export async function installUsageReportsFixture(
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const agentRequests: Array<{ agentId: string }> = [];
|
||||
const listCounter = createCounter();
|
||||
const agentCounter = createCounter();
|
||||
const usageSources = options.usageSources ?? true;
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
@@ -142,37 +139,18 @@ export async function installUsageReportsFixture(
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
|
||||
agentRequests.push({
|
||||
agentId: String(request.agentId),
|
||||
});
|
||||
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent.resolve_usage_report.response",
|
||||
payload: { requestId, reportId },
|
||||
},
|
||||
}),
|
||||
);
|
||||
agentCounter.increment();
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
|
||||
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
agentRequests: () => [...agentRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
waitForAgentRequests: (count) => agentCounter.waitFor(count),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
|
||||
|
||||
function sourceIcon(plugin: string): string {
|
||||
return readFileSync(path.join(PLUGINS_DIR, plugin, "icon.svg"), "utf8");
|
||||
}
|
||||
|
||||
function inOneDay(): string {
|
||||
return new Date(Date.now() + 24 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Claude and Codex reports shaped like the built-in sources report them. */
|
||||
export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: "claude:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
icon: sourceIcon("claude-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex:default",
|
||||
account: { label: "dev@example.com" },
|
||||
fetchedAt,
|
||||
sourceId: "codex",
|
||||
sourceLabel: "Codex",
|
||||
icon: sourceIcon("codex-usage-source"),
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The first visible match: the shell keeps a compact copy of the sidebar mounted. */
|
||||
function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
|
||||
/** The compact usage sheet the Usage item opens. */
|
||||
export function usageSheet(page: Page): Locator {
|
||||
return visible(page, "usage-expanded");
|
||||
}
|
||||
|
||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
|
||||
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(windows).toHaveText(percents);
|
||||
}
|
||||
|
||||
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
|
||||
export async function expectNoPinnedUsage(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
|
||||
await expect(usageItem(page)).toHaveText("Usage");
|
||||
}
|
||||
|
||||
/** A window row, which is itself the pin toggle: "Claude", "Session". */
|
||||
export function pinRow(scope: Locator, source: string, window: string): Locator {
|
||||
// The row's label goes on with its percent and reset: "Pin Claude Session, 31% · resets in 2h".
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
await row.click();
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
|
||||
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
|
||||
}
|
||||
|
||||
/** Opens the compact sidebar drawer. */
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
|
||||
export async function leaveUsageScreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
}
|
||||
@@ -62,6 +62,8 @@ function installProbe(
|
||||
clientBundle,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { HostPicker } from "./host-picker";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface HostSwitcherHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** A compact trigger showing the chosen host, opening the host picker to change it. */
|
||||
export function HostSwitcher({
|
||||
hosts,
|
||||
value,
|
||||
onSelect,
|
||||
title,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
hosts: HostSwitcherHost[];
|
||||
value: string;
|
||||
onSelect: (serverId: string) => void;
|
||||
/** The picker's title. */
|
||||
title: string;
|
||||
/** Prefixes the selected host's label: "Plugin host: Laptop". */
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<View | null>(null);
|
||||
const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value;
|
||||
const openPicker = useCallback(() => setOpen(true), []);
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
title={title}
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${accessibilityLabel}: ${selectedLabel}`}
|
||||
testID={testID}
|
||||
onPress={openPicker}
|
||||
style={styles.trigger}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.label}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
maxWidth: 180,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -43,16 +43,15 @@ import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
buildUsageRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -430,25 +429,6 @@ function SidebarHostPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const handlePress = useCallback(() => {
|
||||
if (isCompactLayout) showMobileAgent();
|
||||
router.push(buildUsageRoute());
|
||||
}, [isCompactLayout, showMobileAgent]);
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={handlePress}
|
||||
testID="sidebar-usage"
|
||||
label={t("settings.hostSections.usage")}
|
||||
icon={Gauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
@@ -472,6 +452,7 @@ function SidebarFooter({
|
||||
labels,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
@@ -486,6 +467,7 @@ function SidebarFooter({
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
@@ -498,6 +480,7 @@ function SidebarFooter({
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
@@ -512,7 +495,6 @@ function SidebarFooter({
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageButton theme={theme} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
@@ -527,6 +509,31 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
/** The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. */
|
||||
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const { items } = useSidebarNavItems("footer");
|
||||
const rowsRef = useRef<View | null>(null);
|
||||
const visibleItems = items.filter((item) => item.visible);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
|
||||
{visibleItems.map((item) =>
|
||||
item.kind === "plugin" ? (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="footer"
|
||||
fallbackAnchorRef={rowsRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
) : (
|
||||
<UsageSidebarItem key={item.key} />
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebar({
|
||||
active,
|
||||
theme,
|
||||
@@ -634,6 +641,7 @@ function MobileSidebar({
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
</View>
|
||||
</MobilePanelOverlay>
|
||||
@@ -954,29 +962,31 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// Every footer row is 28px tall with `spacing[1]` between rows, the same as the gap above.
|
||||
footerRows: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
// The leading glyph sits on the footer icons' rail: a 16px icon centered in a 28px button
|
||||
// starts 6px in, `spacing[1.5]`.
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 32,
|
||||
flex: 1,
|
||||
minHeight: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
|
||||
@@ -12,6 +12,8 @@ interface PageLayoutProps {
|
||||
onBack?: () => void;
|
||||
testID?: string;
|
||||
titleTestID?: string;
|
||||
/** Controls for the whole page: right of the title on desktop, in the back header on compact. */
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -20,7 +22,14 @@ interface PageLayoutProps {
|
||||
* document heading inside the page and the header only keeps the titlebar drag
|
||||
* region and window controls; on compact the title moves into a back header.
|
||||
*/
|
||||
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
|
||||
export function PageLayout({
|
||||
title,
|
||||
onBack,
|
||||
testID,
|
||||
titleTestID,
|
||||
actions,
|
||||
children,
|
||||
}: PageLayoutProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
@@ -28,14 +37,19 @@ export function PageLayout({ title, onBack, testID, titleTestID, children }: Pag
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
|
||||
{isCompact ? (
|
||||
<BackHeader title={title} onBack={onBack} rightContent={actions} />
|
||||
) : (
|
||||
<MenuHeader borderless />
|
||||
)}
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
|
||||
<View style={styles.content}>
|
||||
{showTitle ? (
|
||||
<View testID={titleTestID}>
|
||||
<View style={styles.titleRow} testID={titleTestID}>
|
||||
<Text style={styles.title} testID="page-title">
|
||||
{title}
|
||||
</Text>
|
||||
{actions}
|
||||
</View>
|
||||
) : null}
|
||||
{children}
|
||||
@@ -60,10 +74,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[4],
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
title: {
|
||||
// Line up with the section titles, which sit inset from their cards.
|
||||
marginLeft: theme.spacing[1],
|
||||
marginBottom: theme.spacing[6],
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -11,10 +17,12 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact";
|
||||
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||
|
||||
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
|
||||
|
||||
interface SidebarHeaderRowProps {
|
||||
icon: LucideIcon;
|
||||
icon: SidebarRowIcon | null;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
isActive?: boolean;
|
||||
@@ -26,9 +34,18 @@ interface SidebarHeaderRowProps {
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
* "inline": a full-width row with no inset, for a row inside a padded container such as the
|
||||
* sidebar footer.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
/** Shown in the right slot while the row is hovered, when `trailing` is not set. */
|
||||
shortcutKeys?: ShortcutKey[][] | null;
|
||||
/**
|
||||
* The right slot. A sibling of the row's button, never inside it (web cannot nest buttons). A
|
||||
* press on the slot presses the row; a button inside it presses on its own.
|
||||
*/
|
||||
trailing?: ReactNode;
|
||||
rowRef?: Ref<View>;
|
||||
}
|
||||
|
||||
export function SidebarHeaderRow({
|
||||
@@ -41,71 +58,60 @@ export function SidebarHeaderRow({
|
||||
accessibilityLabel,
|
||||
variant = "header",
|
||||
shortcutKeys = null,
|
||||
trailing,
|
||||
rowRef,
|
||||
}: SidebarHeaderRowProps) {
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
|
||||
const isHighlighted = isHovered || isActive;
|
||||
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => (variant === "compact" ? styles.containerCompact : styles.container),
|
||||
[variant],
|
||||
);
|
||||
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.button,
|
||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||
],
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
const isHighlighted = Boolean(state.hovered) || isActive;
|
||||
return (
|
||||
<>
|
||||
<ThemedIcon
|
||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
{shortcutKeys && Boolean(state.hovered) ? (
|
||||
<Shortcut chord={shortcutKeys} style={styles.shortcut} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
||||
);
|
||||
let right = trailing ?? null;
|
||||
if (right === null && shortcutKeys && isHovered) {
|
||||
right = <Shortcut chord={shortcutKeys} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
style={buttonStyle}
|
||||
<View ref={rowRef} collapsable={false} style={getContainerStyle(variant)}>
|
||||
<View
|
||||
style={[styles.row, isHighlighted && styles.rowHighlighted]}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{renderChildren}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
accessibilityState={isActive ? SELECTED_STATE : undefined}
|
||||
aria-selected={isActive}
|
||||
style={variant === "inline" ? styles.buttonInline : styles.button}
|
||||
>
|
||||
{ThemedIcon ? (
|
||||
<ThemedIcon
|
||||
size={iconSize}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
|
||||
)}
|
||||
<Text style={[styles.label, isHighlighted && styles.labelHighlighted]}>{label}</Text>
|
||||
</Pressable>
|
||||
{right === null ? null : (
|
||||
<Pressable onPress={onPress} accessible={false} focusable={false} style={styles.trailing}>
|
||||
{right}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeaderRowLabel({
|
||||
label,
|
||||
isHighlighted,
|
||||
}: {
|
||||
label: string;
|
||||
isHighlighted: boolean;
|
||||
}) {
|
||||
const labelStyle = useMemo(
|
||||
() => [styles.label, isHighlighted && styles.labelHighlighted],
|
||||
[isHighlighted],
|
||||
);
|
||||
return <Text style={labelStyle}>{label}</Text>;
|
||||
}
|
||||
const SELECTED_STATE = { selected: true } as const;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -124,23 +130,49 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
},
|
||||
containerInline: {
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
rowHighlighted: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
// Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button.
|
||||
buttonInline: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
},
|
||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -148,7 +180,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
labelHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shortcut: {
|
||||
marginLeft: "auto",
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingRight: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
function getContainerStyle(variant: SidebarHeaderRowVariant) {
|
||||
switch (variant) {
|
||||
case "header":
|
||||
return styles.container;
|
||||
case "compact":
|
||||
return styles.containerCompact;
|
||||
case "inline":
|
||||
return styles.containerInline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
|
||||
import { memo, useCallback, useMemo, type ComponentType } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import {
|
||||
@@ -38,19 +38,22 @@ interface SidebarNavRowsProps extends SidebarNavRowProps {
|
||||
* wrapper — when every item is hidden.
|
||||
*/
|
||||
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
|
||||
const { items } = useSidebarNavItems();
|
||||
const { items } = useSidebarNavItems("header");
|
||||
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
|
||||
const groupRef = useRef<View | null>(null);
|
||||
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View ref={groupRef} collapsable={false} style={style}>
|
||||
{visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItemRow
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, type ReactNode, type RefObject } from "react";
|
||||
import type { View } from "react-native";
|
||||
import { MenuRoot, useMenuContext } from "@/components/ui/menu";
|
||||
import { PluginPopoverSurface } from "@/plugins/popover";
|
||||
import type { SidebarSection } from "@/sidebar-nav/model";
|
||||
|
||||
/**
|
||||
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
|
||||
* compact ones. Plugin items and the built-in Usage item open it the same way:
|
||||
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
|
||||
* and `SidebarPopoverSurface` for the body.
|
||||
*/
|
||||
export function SidebarPopoverRoot({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MenuRoot compactMode="sheet" open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function assignRef(ref: RefObject<View | null>, node: View | null) {
|
||||
Object.assign(ref, { current: node });
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the popover anchors. `anchorTo` moves it to an element, such as the row just pressed;
|
||||
* `offerAnchor` sets it only when nothing is anchored; `releaseAnchor` drops an element that is
|
||||
* going away; `anchorToFallback` covers an item that anchored nothing.
|
||||
*/
|
||||
export function useSidebarPopoverAnchor(name: string): {
|
||||
anchorTo: (node: View | null) => void;
|
||||
offerAnchor: (node: View | null) => void;
|
||||
releaseAnchor: (node: View | null) => void;
|
||||
anchorToFallback: (fallback: RefObject<View | null>) => void;
|
||||
} {
|
||||
const { triggerRef } = useMenuContext(name);
|
||||
const anchorTo = useCallback((node: View | null) => assignRef(triggerRef, node), [triggerRef]);
|
||||
const offerAnchor = useCallback(
|
||||
(node: View | null) => {
|
||||
if (!triggerRef.current) assignRef(triggerRef, node);
|
||||
},
|
||||
[triggerRef],
|
||||
);
|
||||
const releaseAnchor = useCallback(
|
||||
(node: View | null) => {
|
||||
if (triggerRef.current === node) assignRef(triggerRef, null);
|
||||
},
|
||||
[triggerRef],
|
||||
);
|
||||
const anchorToFallback = useCallback(
|
||||
(fallback: RefObject<View | null>) => offerAnchor(fallback.current),
|
||||
[offerAnchor],
|
||||
);
|
||||
return { anchorTo, offerAnchor, releaseAnchor, anchorToFallback };
|
||||
}
|
||||
|
||||
const PLACEMENT = {
|
||||
header: { side: "right", align: "start" },
|
||||
footer: { side: "top", align: "start" },
|
||||
} as const satisfies Record<SidebarSection, { side: string; align: string }>;
|
||||
|
||||
export function SidebarPopoverSurface({
|
||||
section,
|
||||
title,
|
||||
sheetTrailing,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
section: SidebarSection;
|
||||
/** The bottom sheet's title. */
|
||||
title: string;
|
||||
/** Controls on the right of the bottom sheet's title. */
|
||||
sheetTrailing?: ReactNode;
|
||||
testID: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const placement = PLACEMENT[section];
|
||||
return (
|
||||
<PluginPopoverSurface
|
||||
sheetTitle={title}
|
||||
sheetTrailing={sheetTrailing}
|
||||
side={placement.side}
|
||||
align={placement.align}
|
||||
offset={8}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</PluginPopoverSurface>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,8 @@ export interface MenuSurfaceProps {
|
||||
pages?: readonly MenuPageDefinition[];
|
||||
/** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */
|
||||
sheetTitle?: string;
|
||||
/** Controls on the right of the sheet's root title. */
|
||||
sheetTrailing?: ReactNode;
|
||||
side?: Placement;
|
||||
align?: Alignment;
|
||||
offset?: number;
|
||||
@@ -339,6 +341,7 @@ function MenuSheetSurface({
|
||||
children,
|
||||
pages = [],
|
||||
sheetTitle,
|
||||
sheetTrailing,
|
||||
testID,
|
||||
keyboardFocusScope,
|
||||
}: MenuSurfaceProps): ReactElement | null {
|
||||
@@ -428,7 +431,9 @@ function MenuSheetSurface({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
{sheetTitle ? (
|
||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||
) : null}
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
@@ -440,9 +445,11 @@ function MenuSheetSurface({
|
||||
function MenuSheetHeader({
|
||||
title,
|
||||
onBack,
|
||||
trailing = null,
|
||||
}: {
|
||||
title: string;
|
||||
onBack: (() => void) | null;
|
||||
trailing?: ReactNode;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -462,6 +469,7 @@ function MenuSheetHeader({
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{trailing ? <View style={styles.sheetTrailing}>{trailing}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -475,6 +483,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
sheetTrailing: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
sheetBackButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -8,17 +8,20 @@ interface StatusBadgeProps {
|
||||
label: string;
|
||||
variant?: StatusBadgeVariant;
|
||||
leading?: ReactNode;
|
||||
/** `xs` fits beside a line of `sm` text: the same label on tighter padding. */
|
||||
size?: "sm" | "xs";
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
export function StatusBadge({ label, variant = "muted", leading, size = "sm" }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
size === "xs" && styles.pillXs,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "warning" && styles.pillWarning,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
[size, variant],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
@@ -50,6 +53,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillXs: {
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
paddingVertical: 1,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.statusSuccessTint,
|
||||
borderColor: "transparent",
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
type DraftAgentControlsProps,
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { UsageComposerPill } from "@/usage";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -2093,13 +2092,8 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
const beforeVoiceContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
|
||||
{hasAgent ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
|
||||
</>
|
||||
),
|
||||
[agentId, contextWindowMeter, hasAgent, serverId],
|
||||
() => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}</>,
|
||||
[contextWindowMeter, hasAgent],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
|
||||
@@ -238,6 +238,75 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.sidebarNavItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads stored sidebar footer items in order and defaults them to empty", async () => {
|
||||
expect((await loadAppSettingsFromStorage(makeDeps())).sidebarFooterItems).toEqual([]);
|
||||
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
sidebarFooterItems: [
|
||||
// A footer button that was configurable once; the sidebar model skips it.
|
||||
{ key: "help", visible: false },
|
||||
{ key: "plugin:sync:status", visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.sidebarFooterItems).toEqual([
|
||||
{ key: "help", visible: false },
|
||||
{ key: "plugin:sync:status", visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads usage preferences with pins in order and defaults a fresh device to none", async () => {
|
||||
expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({
|
||||
displayAs: "used",
|
||||
pinned: [],
|
||||
});
|
||||
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
usage: {
|
||||
displayAs: "remaining",
|
||||
pinned: [
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
|
||||
displayAs: "remaining",
|
||||
pinned: [
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps valid usage preferences when one field is malformed", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
usage: { displayAs: "percent", pinned: [{ sourceId: "claude", windowId: "weekly" }] },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({
|
||||
displayAs: "used",
|
||||
pinned: [{ sourceId: "claude", windowId: "weekly" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses legacy diff destinations into the former Explorer choice", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { AppLanguage } from "@/i18n/locales";
|
||||
import type { SidebarNavPreference } from "@/sidebar-nav/model";
|
||||
import {
|
||||
DEFAULT_USAGE_PREFERENCES,
|
||||
UsagePreferencesSchema,
|
||||
type UsagePreferences,
|
||||
} from "@/usage/preferences";
|
||||
import {
|
||||
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
type SidebarChecksDisplay,
|
||||
@@ -91,6 +96,10 @@ export interface AppSettings {
|
||||
sidebarChecksDisplay: SidebarChecksDisplay;
|
||||
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
|
||||
sidebarNavItems: SidebarNavPreference[];
|
||||
/** Sidebar footer items in display order; empty means the default order, all visible. */
|
||||
sidebarFooterItems: SidebarNavPreference[];
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
@@ -145,6 +154,8 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||
sidebarNavItems: [],
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
@@ -238,6 +249,8 @@ const StoredAppSettingsSchema = z
|
||||
.optional()
|
||||
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
|
||||
@@ -1147,6 +1147,9 @@ export const ar: TranslationResources = {
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
},
|
||||
footer: {
|
||||
usage: "الاستخدام",
|
||||
},
|
||||
help: {
|
||||
trigger: "المساعدة والدعم",
|
||||
sectionHelp: "المساعدة",
|
||||
@@ -2168,8 +2171,15 @@ export const ar: TranslationResources = {
|
||||
description: "عرض مخطط للتنقل بين المطالبات",
|
||||
},
|
||||
sidebar: {
|
||||
title: "الشريط الجانبي",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
header: {
|
||||
title: "الرأس",
|
||||
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
|
||||
},
|
||||
footer: {
|
||||
title: "التذييل",
|
||||
description:
|
||||
"اختر الصفوف التي تظهر أسفل الشريط الجانبي وترتيبها. يظهر «إضافة مشروع» وصف الأيقونات دائمًا",
|
||||
},
|
||||
moveUp: "نقل لأعلى",
|
||||
moveDown: "نقل لأسفل",
|
||||
},
|
||||
|
||||
@@ -1155,6 +1155,9 @@ export const en = {
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
},
|
||||
footer: {
|
||||
usage: "Usage",
|
||||
},
|
||||
help: {
|
||||
trigger: "Help and support",
|
||||
sectionHelp: "Help",
|
||||
@@ -2292,8 +2295,15 @@ export const en = {
|
||||
description: "Show an outline for jumping between prompts",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Sidebar",
|
||||
description: "Choose which items appear at the top of the sidebar and in what order",
|
||||
header: {
|
||||
title: "Header",
|
||||
description: "Choose which items appear at the top of the sidebar and in what order",
|
||||
},
|
||||
footer: {
|
||||
title: "Footer",
|
||||
description:
|
||||
"Choose which rows appear at the bottom of the sidebar and in what order. Add project and the icon row always show",
|
||||
},
|
||||
moveUp: "Move up",
|
||||
moveDown: "Move down",
|
||||
},
|
||||
|
||||
@@ -1183,6 +1183,9 @@ export const es: TranslationResources = {
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
},
|
||||
help: {
|
||||
trigger: "Ayuda y soporte",
|
||||
sectionHelp: "Ayuda",
|
||||
@@ -2220,9 +2223,16 @@ export const es: TranslationResources = {
|
||||
description: "Muestra un esquema para saltar entre instrucciones",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barra lateral",
|
||||
description:
|
||||
"Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden",
|
||||
header: {
|
||||
title: "Encabezado",
|
||||
description:
|
||||
"Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden",
|
||||
},
|
||||
footer: {
|
||||
title: "Pie",
|
||||
description:
|
||||
"Elige qué filas aparecen en la parte inferior de la barra lateral y en qué orden. Añadir proyecto y la fila de iconos siempre se muestran",
|
||||
},
|
||||
moveUp: "Mover hacia arriba",
|
||||
moveDown: "Mover hacia abajo",
|
||||
},
|
||||
|
||||
@@ -1183,6 +1183,9 @@ export const fr: TranslationResources = {
|
||||
settings: "Paramètres",
|
||||
closeSidebar: "Fermer la barre latérale",
|
||||
},
|
||||
footer: {
|
||||
usage: "Utilisation",
|
||||
},
|
||||
help: {
|
||||
trigger: "Aide et assistance",
|
||||
sectionHelp: "Aide",
|
||||
@@ -2224,8 +2227,16 @@ export const fr: TranslationResources = {
|
||||
description: "Afficher un plan pour passer d’une requête à l’autre",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barre latérale",
|
||||
description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre",
|
||||
header: {
|
||||
title: "En-tête",
|
||||
description:
|
||||
"Choisissez les éléments affichés en haut de la barre latérale et leur ordre",
|
||||
},
|
||||
footer: {
|
||||
title: "Pied",
|
||||
description:
|
||||
"Choisissez les lignes affichées en bas de la barre latérale et leur ordre. Ajouter un projet et la rangée d’icônes restent toujours visibles",
|
||||
},
|
||||
moveUp: "Déplacer vers le haut",
|
||||
moveDown: "Déplacer vers le bas",
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,9 @@ export const ja: TranslationResources = {
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用状況",
|
||||
},
|
||||
help: {
|
||||
trigger: "ヘルプとサポート",
|
||||
sectionHelp: "ヘルプ",
|
||||
@@ -2187,8 +2190,15 @@ export const ja: TranslationResources = {
|
||||
description: "プロンプト間を移動するためのアウトラインを表示します",
|
||||
},
|
||||
sidebar: {
|
||||
title: "サイドバー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
header: {
|
||||
title: "ヘッダー",
|
||||
description: "サイドバー上部に表示する項目とその順序を選択します",
|
||||
},
|
||||
footer: {
|
||||
title: "フッター",
|
||||
description:
|
||||
"サイドバー下部に表示する行とその順序を選択します。プロジェクトを追加とアイコンの行は常に表示されます",
|
||||
},
|
||||
moveUp: "上に移動",
|
||||
moveDown: "下に移動",
|
||||
},
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const ko: TranslationResources = {
|
||||
settings: "설정",
|
||||
closeSidebar: "사이드바 닫기",
|
||||
},
|
||||
footer: {
|
||||
usage: "사용량",
|
||||
},
|
||||
help: {
|
||||
trigger: "도움말 및 지원",
|
||||
sectionHelp: "도움말",
|
||||
@@ -2179,8 +2182,15 @@ export const ko: TranslationResources = {
|
||||
description: "프롬프트 사이를 이동하기 위한 개요 표시",
|
||||
},
|
||||
sidebar: {
|
||||
title: "사이드바",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
header: {
|
||||
title: "헤더",
|
||||
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
|
||||
},
|
||||
footer: {
|
||||
title: "푸터",
|
||||
description:
|
||||
"사이드바 하단에 표시할 행과 순서를 선택하세요. 프로젝트 추가와 아이콘 행은 항상 표시됩니다",
|
||||
},
|
||||
moveUp: "위로 이동",
|
||||
moveDown: "아래로 이동",
|
||||
},
|
||||
|
||||
@@ -1173,6 +1173,9 @@ export const ptBR: TranslationResources = {
|
||||
settings: "Configurações",
|
||||
closeSidebar: "Fechar barra lateral",
|
||||
},
|
||||
footer: {
|
||||
usage: "Uso",
|
||||
},
|
||||
help: {
|
||||
trigger: "Ajuda e suporte",
|
||||
sectionHelp: "Ajuda",
|
||||
@@ -2203,8 +2206,15 @@ export const ptBR: TranslationResources = {
|
||||
description: "Mostrar uma estrutura para navegar entre prompts",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Barra lateral",
|
||||
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
|
||||
header: {
|
||||
title: "Cabeçalho",
|
||||
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
|
||||
},
|
||||
footer: {
|
||||
title: "Rodapé",
|
||||
description:
|
||||
"Escolha quais linhas aparecem na parte inferior da barra lateral e em que ordem. Adicionar projeto e a linha de ícones sempre aparecem",
|
||||
},
|
||||
moveUp: "Mover para cima",
|
||||
moveDown: "Mover para baixo",
|
||||
},
|
||||
|
||||
@@ -1165,6 +1165,9 @@ export const ru: TranslationResources = {
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
},
|
||||
footer: {
|
||||
usage: "Использование",
|
||||
},
|
||||
help: {
|
||||
trigger: "Помощь и поддержка",
|
||||
sectionHelp: "Помощь",
|
||||
@@ -2206,9 +2209,16 @@ export const ru: TranslationResources = {
|
||||
description: "Показывать оглавление для перехода между запросами",
|
||||
},
|
||||
sidebar: {
|
||||
title: "Боковая панель",
|
||||
description:
|
||||
"Выберите, какие элементы отображаются вверху боковой панели и в каком порядке",
|
||||
header: {
|
||||
title: "Верх",
|
||||
description:
|
||||
"Выберите, какие элементы отображаются вверху боковой панели и в каком порядке",
|
||||
},
|
||||
footer: {
|
||||
title: "Низ",
|
||||
description:
|
||||
"Выберите, какие строки отображаются внизу боковой панели и в каком порядке. «Добавить проект» и ряд значков видны всегда",
|
||||
},
|
||||
moveUp: "Переместить вверх",
|
||||
moveDown: "Переместить вниз",
|
||||
},
|
||||
|
||||
@@ -1139,6 +1139,9 @@ export const zhCN: TranslationResources = {
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
},
|
||||
footer: {
|
||||
usage: "使用情况",
|
||||
},
|
||||
help: {
|
||||
trigger: "帮助与支持",
|
||||
sectionHelp: "帮助",
|
||||
@@ -2144,8 +2147,14 @@ export const zhCN: TranslationResources = {
|
||||
description: "显示用于在提示词之间跳转的大纲",
|
||||
},
|
||||
sidebar: {
|
||||
title: "侧边栏",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
header: {
|
||||
title: "顶部",
|
||||
description: "选择侧边栏顶部显示的项目及其顺序",
|
||||
},
|
||||
footer: {
|
||||
title: "底部",
|
||||
description: "选择侧边栏底部显示的行及其顺序。添加项目和图标行始终显示",
|
||||
},
|
||||
moveUp: "上移",
|
||||
moveDown: "下移",
|
||||
},
|
||||
|
||||
@@ -3,16 +3,17 @@ import type {
|
||||
PluginAgentCommandContext,
|
||||
PluginCommandCapabilities,
|
||||
PluginPanelLocation,
|
||||
PluginScreenParams,
|
||||
PluginWorkspaceCommandContext,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
|
||||
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "./surface-runtime";
|
||||
import { parsePluginOpenScreenInput } from "./surface-contribution";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export interface PluginNavigation {
|
||||
openSettings(pluginId: string, screenId: string): void;
|
||||
openSurface(pluginId: string, surfaceId: string): void;
|
||||
openSurface(pluginId: string, surfaceId: string, params?: PluginScreenParams): void;
|
||||
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
|
||||
openAgentPanel(
|
||||
pluginId: string,
|
||||
@@ -24,41 +25,40 @@ export interface PluginNavigation {
|
||||
|
||||
export function createPluginCapabilities(
|
||||
plugin: InstalledPlugin,
|
||||
runtime: PluginSurfaceRuntime,
|
||||
navigation: PluginNavigation,
|
||||
): PluginCommandCapabilities {
|
||||
function openScreen(input: unknown) {
|
||||
const { screenId, params } = parsePluginOpenScreenInput(plugin, input);
|
||||
navigation.openSurface(plugin.id, screenId, params);
|
||||
}
|
||||
return {
|
||||
paseo: runtime.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
|
||||
paseo: plugin.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
|
||||
openSettings(screenId) {
|
||||
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
|
||||
throw new Error(`Plugin settings screen is unavailable: ${screenId}`);
|
||||
navigation.openSettings(plugin.id, screenId);
|
||||
},
|
||||
openSurface(surfaceId) {
|
||||
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
|
||||
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
|
||||
}
|
||||
navigation.openSurface(plugin.id, surfaceId);
|
||||
},
|
||||
openScreen,
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
openSurface: (screenId) => openScreen({ screenId }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginAgentActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}): PluginAgentCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId, agentId } = input;
|
||||
const { plugin, navigation, state, workspaceId, agentId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
const agent = state.getAgent(agentId);
|
||||
if (!workspace || !agent || agent.workspaceId !== workspace.id) return null;
|
||||
return {
|
||||
context: "agent",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
...createPluginCapabilities(plugin, navigation),
|
||||
workspace,
|
||||
agent,
|
||||
openPanel(panelId, options) {
|
||||
@@ -76,17 +76,16 @@ export function createPluginAgentActionContext(input: {
|
||||
|
||||
export function createPluginWorkspaceActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
}): PluginWorkspaceCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId } = input;
|
||||
const { plugin, navigation, state, workspaceId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
context: "workspace",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
...createPluginCapabilities(plugin, navigation),
|
||||
workspace,
|
||||
openPanel(panelId, options) {
|
||||
const panel = plugin.workspacePanels.find(
|
||||
|
||||
@@ -11,10 +11,13 @@ function installation(): InstalledPlugin {
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type {
|
||||
PluginButtonBehavior,
|
||||
PluginButtonIcon,
|
||||
@@ -7,14 +6,13 @@ import type {
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuSurface,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
@@ -30,22 +28,27 @@ import {
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { Icon } from "../icons";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import {
|
||||
PluginEnvironmentProvider,
|
||||
PluginPopoverContent,
|
||||
PluginPopoverSurface,
|
||||
type PluginEnvironment,
|
||||
} from "../popover";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { buttonMatches, type RegisteredPluginButton } from "./model";
|
||||
import { pluginButtonStore } from "./store";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
|
||||
interface ButtonView {
|
||||
entry: RegisteredPluginButton;
|
||||
props: PluginHostProps & RegisteredPluginButton["context"];
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
environment: PluginEnvironment;
|
||||
toast: ReturnType<typeof useToast>;
|
||||
}
|
||||
|
||||
@@ -67,21 +70,9 @@ function headerButtonStyle(compact: boolean, state: IconButtonChromeState, disab
|
||||
];
|
||||
}
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
// These providers live inside the surface content as well as around its trigger. Native sheets
|
||||
// teleport their children, so providers around MenuRoot alone cannot reach the plugin body.
|
||||
function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) {
|
||||
return (
|
||||
<ToastApiProvider api={view.toast}>
|
||||
<PluginRuntimeBoundary plugin={view.entry.installation} client={view.client}>
|
||||
<PluginClientStateProvider source={view.state}>{children}</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</ToastApiProvider>
|
||||
<PluginEnvironmentProvider environment={view.environment}>{children}</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,9 +175,9 @@ function ButtonBody({
|
||||
if (behavior.kind === "popover") {
|
||||
const Content = behavior.Content;
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<PluginPopoverContent>
|
||||
<Content {...view.props} close={close} />
|
||||
</View>
|
||||
</PluginPopoverContent>
|
||||
);
|
||||
}
|
||||
if (behavior.kind !== "menu") return null;
|
||||
@@ -336,19 +327,15 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{expanded ? (
|
||||
<MenuSurface
|
||||
<PluginPopoverSurface
|
||||
sheetTitle={button.title}
|
||||
side={composer ? "top" : "bottom"}
|
||||
align={composer ? "start" : "end"}
|
||||
offset={composer ? 12 : 4}
|
||||
minWidth={280}
|
||||
maxWidth={420}
|
||||
maxHeight={440}
|
||||
scrollable
|
||||
pages={pages}
|
||||
>
|
||||
<ButtonSurfaceBody view={view} behavior={button.behavior} path={ROOT_PATH} />
|
||||
</MenuSurface>
|
||||
</PluginPopoverSurface>
|
||||
) : null}
|
||||
</MenuRoot>
|
||||
);
|
||||
@@ -398,14 +385,17 @@ function createButtonView({
|
||||
if (!client) return null;
|
||||
return {
|
||||
entry,
|
||||
client,
|
||||
toast,
|
||||
state: createPluginClientStateSource(entry.installation.serverId),
|
||||
environment: {
|
||||
installation: entry.installation,
|
||||
toast,
|
||||
state: createPluginClientStateSource(entry.installation.serverId),
|
||||
},
|
||||
props: {
|
||||
...entry.context,
|
||||
theme,
|
||||
host: { id: entry.installation.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePlatform() },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -511,17 +501,13 @@ function OverflowPages({
|
||||
}, [entries, client, toast, theme, hostLabel, compact]);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuSurface
|
||||
<PluginPopoverSurface
|
||||
sheetTitle={t("workspace.git.actions.moreActions")}
|
||||
align="end"
|
||||
minWidth={280}
|
||||
maxWidth={420}
|
||||
maxHeight={440}
|
||||
scrollable
|
||||
pages={menuContent.pages}
|
||||
>
|
||||
{menuContent.rows}
|
||||
</MenuSurface>
|
||||
</PluginPopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -665,5 +651,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
createPluginAgentActionContext,
|
||||
@@ -11,19 +10,12 @@ import { createPluginClientStateSource } from "./client-state/source";
|
||||
import type { PluginClientRuntime } from "./evaluate";
|
||||
import { createPluginNavigation } from "./navigation";
|
||||
import { pluginButtonStore } from "./buttons";
|
||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export function createPluginClientRuntime(
|
||||
installation: InstalledPlugin,
|
||||
daemonClient: DaemonClient,
|
||||
): PluginClientRuntime {
|
||||
const runtime = createPluginSurfaceRuntime(daemonClient, installation);
|
||||
if (!runtime) throw new Error("Plugin host is offline");
|
||||
export function createPluginClientRuntime(installation: InstalledPlugin): PluginClientRuntime {
|
||||
const state = createPluginClientStateSource(installation.serverId);
|
||||
const capabilities = createPluginCapabilities(
|
||||
installation,
|
||||
runtime,
|
||||
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
|
||||
);
|
||||
return {
|
||||
@@ -36,26 +28,24 @@ export function createPluginClientRuntime(
|
||||
return pluginButtonStore.addHeaderButton(installation, contribution);
|
||||
},
|
||||
openPanel(panelId, options) {
|
||||
openClientPanel({ installation, runtime, state, panelId, options });
|
||||
openClientPanel({ installation, state, panelId, options });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function openClientPanel(input: {
|
||||
installation: InstalledPlugin;
|
||||
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
panelId: string;
|
||||
options: PluginClientOpenPanelOptions;
|
||||
}): void {
|
||||
const { installation, runtime, state, panelId, options } = input;
|
||||
const { installation, state, panelId, options } = input;
|
||||
const workspaceId = options.workspaceId.trim();
|
||||
const agentId = options.agentId?.trim();
|
||||
const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId });
|
||||
const action = agentId
|
||||
? createPluginAgentActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
@@ -63,7 +53,6 @@ function openClientPanel(input: {
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
|
||||
export interface PluginClientSlashCommand {
|
||||
pluginId: string;
|
||||
@@ -19,10 +17,9 @@ export function usePluginClientSlashCommands(input: {
|
||||
workspaceId: string | null | undefined;
|
||||
agentId: string;
|
||||
}): PluginClientSlashCommand[] {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(() => {
|
||||
if (!client || !input.workspaceId) return [];
|
||||
if (!input.workspaceId) return [];
|
||||
const workspaceId = input.workspaceId;
|
||||
const state = createPluginClientStateSource(input.serverId);
|
||||
const navigation = createPluginNavigation({
|
||||
@@ -42,38 +39,30 @@ export function usePluginClientSlashCommands(input: {
|
||||
description: contribution.description,
|
||||
argumentHint: contribution.argumentHint,
|
||||
async run(args: string) {
|
||||
const runtime = createPluginSurfaceRuntime(client, plugin);
|
||||
if (!runtime) return;
|
||||
try {
|
||||
const context =
|
||||
contribution.context === "agent"
|
||||
? createPluginAgentActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (contribution.context === "agent" && context?.context === "agent")
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
else if (contribution.context === "workspace" && context?.context === "workspace")
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
} finally {
|
||||
await runtime.paseo.dispose();
|
||||
}
|
||||
const context =
|
||||
contribution.context === "agent"
|
||||
? createPluginAgentActionContext({
|
||||
plugin,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (contribution.context === "agent" && context?.context === "agent")
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
else if (contribution.context === "workspace" && context?.context === "workspace")
|
||||
await contribution.onSubmit({ ...context, args });
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
return commands;
|
||||
}, [client, input.agentId, input.serverId, input.workspaceId, installed]);
|
||||
}, [input.agentId, input.serverId, input.workspaceId, installed]);
|
||||
}
|
||||
|
||||
@@ -61,11 +61,23 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
paseo: createPaseoApi(
|
||||
new DaemonClient({
|
||||
url: "ws://127.0.0.1:1",
|
||||
clientId: "plugin-command-test",
|
||||
clientType: "cli",
|
||||
}),
|
||||
),
|
||||
invoke: async (method: string, input: unknown) => {
|
||||
expect(method).toBe("review.inspect");
|
||||
return { value: inspect.input.parse(input).value + 1 };
|
||||
},
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [{ id: "main", Component: () => null }],
|
||||
sidebarItems: [],
|
||||
surfaces: [{ id: "main", title: "Main", Component: () => null }],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
@@ -121,28 +133,11 @@ function stateSource() {
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(installed: InstalledPlugin) {
|
||||
const client = new DaemonClient({
|
||||
url: "ws://127.0.0.1:1",
|
||||
clientId: "plugin-command-test",
|
||||
clientType: "cli",
|
||||
});
|
||||
return {
|
||||
paseo: createPaseoApi(client),
|
||||
invoke: async (method: string, input: unknown) => {
|
||||
expect(installed.id).toBe("review");
|
||||
expect(method).toBe("review.inspect");
|
||||
return { value: inspect.input.parse(input).value + 1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin Command Center contributions", () => {
|
||||
it("shows only contributions whose synchronous context exists", () => {
|
||||
const installed = plugin(() => undefined);
|
||||
const common = {
|
||||
plugins: [installed],
|
||||
runtime: createRuntime,
|
||||
state: stateSource(),
|
||||
navigation: {
|
||||
openSettings() {},
|
||||
@@ -188,10 +183,8 @@ describe("plugin Command Center contributions", () => {
|
||||
context.openSurface("main");
|
||||
context.openPanel("details", { location: "explorer" });
|
||||
});
|
||||
const runtime = createRuntime(installed);
|
||||
const actions = buildPluginCommandCenterContributions({
|
||||
plugins: [installed],
|
||||
runtime: () => runtime,
|
||||
state: stateSource(),
|
||||
workspaceId: workspace.id,
|
||||
agentId: agent.id,
|
||||
@@ -215,7 +208,8 @@ describe("plugin Command Center contributions", () => {
|
||||
await actions.find((action) => action.id === "review:agent")?.run();
|
||||
|
||||
expect(rpcValue).toBe(5);
|
||||
expect(receivedPaseo).toBe(runtime.paseo);
|
||||
// Commands use the plugin's one client.
|
||||
expect(receivedPaseo).toBe(installed.paseo);
|
||||
expect(opened).toEqual(["review/surface/main", "review/agent/details/agent-1/explorer"]);
|
||||
});
|
||||
|
||||
@@ -223,7 +217,6 @@ describe("plugin Command Center contributions", () => {
|
||||
expect(
|
||||
buildPluginCommandCenterContributions({
|
||||
plugins: [],
|
||||
runtime: createRuntime,
|
||||
state: stateSource(),
|
||||
workspaceId: workspace.id,
|
||||
agentId: agent.id,
|
||||
|
||||
@@ -3,13 +3,11 @@ import type { CommandCenterContribution } from "@/command-center/contributions";
|
||||
import { getCommandCenterIcon } from "@/command-center/icon";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "../surface-runtime";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginCapabilities, type PluginNavigation } from "../actions";
|
||||
|
||||
export interface PluginCommandCenterSource {
|
||||
plugins: readonly InstalledPlugin[];
|
||||
runtime(plugin: InstalledPlugin): PluginSurfaceRuntime;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string | null;
|
||||
agentId: string | null;
|
||||
@@ -26,8 +24,7 @@ export function buildPluginCommandCenterContributions(
|
||||
if (item.context === "workspace" && !source.workspaceId) continue;
|
||||
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
|
||||
const run = async () => {
|
||||
const runtime = source.runtime(plugin);
|
||||
const common = createPluginCapabilities(plugin, runtime, source.navigation);
|
||||
const common = createPluginCapabilities(plugin, source.navigation);
|
||||
try {
|
||||
if (item.context === "global") {
|
||||
await item.onSelect({ context: "global", ...common });
|
||||
@@ -73,8 +70,6 @@ export function buildPluginCommandCenterContributions(
|
||||
});
|
||||
} catch (error) {
|
||||
source.reportError(error);
|
||||
} finally {
|
||||
await runtime.paseo.dispose().catch(source.reportError);
|
||||
}
|
||||
};
|
||||
contributions.push({
|
||||
|
||||
@@ -11,7 +11,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { hostIdFromPathname } from "../routes";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { buildPluginCommandCenterContributions } from "./contributions";
|
||||
import { getFocusedAgentId } from "./context";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
@@ -53,11 +52,6 @@ export function PluginCommandCenterActions() {
|
||||
if (!client || !serverId || !stateSource) return [];
|
||||
return buildPluginCommandCenterContributions({
|
||||
plugins,
|
||||
runtime(plugin) {
|
||||
const runtime = createPluginSurfaceRuntime(client, plugin);
|
||||
if (!runtime) throw new Error("Plugin host is offline");
|
||||
return runtime;
|
||||
},
|
||||
state: stateSource,
|
||||
workspaceId: workspaceExists ? workspaceId : null,
|
||||
agentId: agentExists ? focusedAgentId : null,
|
||||
|
||||
@@ -7,3 +7,17 @@ export function getPreferredPluginContributionHost(contributionId: string): stri
|
||||
export function rememberPluginContributionHost(contributionId: string, serverId: string): void {
|
||||
preferredHosts.set(contributionId, serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a plugin's screens and sidebar items share: the host last picked on one of its screens
|
||||
* or last opened from one of its items.
|
||||
*/
|
||||
export function pluginScreensHostKey(pluginId: string): string {
|
||||
return `${pluginId}/screens`;
|
||||
}
|
||||
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** The host an `addSidebarItem` row last opened, which its screen's host switcher also sets. */
|
||||
export function legacySidebarItemHostKey(pluginId: string, itemId: string): string {
|
||||
return `${pluginId}/sidebar/${itemId}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const runtime = {
|
||||
paseo: {},
|
||||
async rpc() {},
|
||||
openSettings() {},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
openPanel() {},
|
||||
addHeaderButton() {
|
||||
@@ -72,9 +73,10 @@ describe("evaluatePluginClientBundle", () => {
|
||||
function Component() { return null; }
|
||||
const schema = { safeParse(value) { return { success: true, data: value }; } };
|
||||
globalThis.__pluginRemovals = [
|
||||
plugin.addSurface("main", Component),
|
||||
plugin.addScreen({ id: "main", title: "Main", Component }),
|
||||
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
|
||||
plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }),
|
||||
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
|
||||
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
|
||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
||||
@@ -104,7 +106,8 @@ describe("evaluatePluginClientBundle", () => {
|
||||
[
|
||||
plugin.surfaces,
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -123,7 +126,8 @@ describe("evaluatePluginClientBundle", () => {
|
||||
[
|
||||
plugin.surfaces,
|
||||
plugin.settingsScreens,
|
||||
plugin.sidebarItems,
|
||||
plugin.sidebarItems.header,
|
||||
plugin.sidebarItems.footer,
|
||||
plugin.workspacePanels,
|
||||
plugin.commandCenterItems,
|
||||
plugin.clientSlashCommands,
|
||||
@@ -180,21 +184,125 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Timeline transformer bad-query has invalid item type: settled");
|
||||
});
|
||||
|
||||
it("collects a surface and its sidebar placement", () => {
|
||||
it("collects screens and sidebar header and footer items", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Screen() { return null; }
|
||||
const Item = require("react").memo(function Item() { return null; });
|
||||
plugin.addScreen({ id: "main", title: " Main ", Component: Screen });
|
||||
plugin.addScreen({ id: "bot", title: (params) => params.botId, Component: Screen });
|
||||
plugin.addSidebarHeaderItem({ id: "main", title: " Example ", Component: Item });
|
||||
plugin.addSidebarFooterItem({ id: "main", title: "Status", Component: Item });
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.id).toBe("example");
|
||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main", "bot"]);
|
||||
expect(plugin.surfaces[0]?.title).toBe("Main");
|
||||
expect(plugin.surfaces[1]?.title).toBeTypeOf("function");
|
||||
expect(plugin.sidebarItems.header.map(({ id, title }) => ({ id, title }))).toEqual([
|
||||
{ id: "main", title: "Example" },
|
||||
]);
|
||||
expect(plugin.sidebarItems.footer.map(({ id, title }) => ({ id, title }))).toEqual([
|
||||
{ id: "main", title: "Status" },
|
||||
]);
|
||||
expect(plugin.legacySidebarItems).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[`plugin.addScreen("main", Screen);`, "addScreen takes { id, title, Component }"],
|
||||
[`plugin.addScreen({ id: "main", Component: Screen });`, "Screen main needs a title"],
|
||||
[
|
||||
`plugin.addScreen({ id: "main", title: " ", Component: Screen });`,
|
||||
"Screen main needs a title",
|
||||
],
|
||||
[
|
||||
`plugin.addScreen({ id: "main", title: 42, Component: Screen });`,
|
||||
"Screen main needs a title",
|
||||
],
|
||||
[
|
||||
`plugin.addScreen({ id: "main", title: "Main", Component: "Main" });`,
|
||||
"Screen main is not a component",
|
||||
],
|
||||
])("rejects a malformed screen: %s", (call, message) => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle("example", bundle(`function Screen() { return null; } ${call}`)),
|
||||
).toThrow(message);
|
||||
});
|
||||
|
||||
it("rejects duplicate and malformed sidebar items within a section", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addSidebarFooterItem({ id: "main", title: "One", Component: Item });
|
||||
plugin.addSidebarFooterItem({ id: "main", title: "Two", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate sidebar footer item: main");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component: "Main" });`),
|
||||
),
|
||||
).toThrow("Sidebar item main is not a component");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(
|
||||
`plugin.addSidebarHeaderItem({ id: "main", title: " ", Component() { return null; } });`,
|
||||
),
|
||||
),
|
||||
).toThrow("Sidebar item main has no title");
|
||||
});
|
||||
|
||||
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
function Surface() { return null; }
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
|
||||
plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.id).toBe("example");
|
||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
||||
expect(plugin.sidebarItems).toEqual([
|
||||
{ id: "main", title: "Example", icon: "Blocks", surface: "main" },
|
||||
expect(plugin.surfaces.map(({ id, title }) => ({ id, title }))).toEqual([
|
||||
{ id: "main", title: "main" },
|
||||
]);
|
||||
expect(plugin.sidebarItems).toEqual({ header: [], footer: [] });
|
||||
expect(plugin.legacySidebarItems).toEqual([
|
||||
{ id: "entry", title: "Example", icon: "Blocks", surface: "main" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases an addSidebarItem alias's header id", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } });
|
||||
const remove = plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
||||
remove();
|
||||
plugin.addSidebarHeaderItem({ id: "entry", title: "Replacement", Component() { return null; } });
|
||||
`),
|
||||
);
|
||||
|
||||
expect(plugin.sidebarItems.header.map((item) => item.title)).toEqual(["Replacement"]);
|
||||
expect(plugin.legacySidebarItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a header item that reuses an addSidebarItem id", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
bundle(`
|
||||
plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } });
|
||||
plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
||||
plugin.addSidebarHeaderItem({ id: "entry", title: "Other", Component() { return null; } });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate sidebar header item: entry");
|
||||
});
|
||||
|
||||
it("collects a declarative attachment source", () => {
|
||||
@@ -479,9 +587,9 @@ describe("evaluatePluginClientBundle", () => {
|
||||
const { Icon } = require("@getpaseo/plugin/client/react-native");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addSurface("main", function Surface() {
|
||||
plugin.addScreen({ id: "main", title: "Main", Component: function Surface() {
|
||||
return Icon({ name: "Settings", size: 18, color: "#123456" });
|
||||
});
|
||||
} });
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
type PluginClientContext,
|
||||
type PluginClientSlashCommandContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginScreenContribution,
|
||||
type PluginScreenProps,
|
||||
type PluginScreenTitle,
|
||||
type PluginSidebarItemContribution,
|
||||
type PluginSurfaceProps,
|
||||
type PluginTimelineRendererContribution,
|
||||
type PluginTimelineTransformerContribution,
|
||||
type PluginWorkspacePanelContribution,
|
||||
type PluginButtonRegistration,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
@@ -65,17 +69,32 @@ function normalizePanelLocations(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isComponentType(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "function" ||
|
||||
(typeof value === "object" && value !== null && "$$typeof" in value)
|
||||
);
|
||||
}
|
||||
|
||||
function requireId(value: string, label: string): string {
|
||||
const id = value.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A non-empty string, or a function of the screen's params resolved when the header renders. */
|
||||
function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle {
|
||||
if (typeof title === "function") return title as PluginScreenTitle;
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`);
|
||||
}
|
||||
|
||||
export type PluginClientRuntime = Pick<
|
||||
PluginClientContext,
|
||||
| "paseo"
|
||||
| "rpc"
|
||||
| "openSettings"
|
||||
| "openScreen"
|
||||
| "openSurface"
|
||||
| "openPanel"
|
||||
| "addComposerPill"
|
||||
@@ -91,7 +110,8 @@ export function runPluginClientBundle(
|
||||
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -102,7 +122,10 @@ export function runPluginClientBundle(
|
||||
};
|
||||
const surfaceIds = new Set<string>();
|
||||
const settingsScreenIds = new Set<string>();
|
||||
const sidebarItemIds = new Set<string>();
|
||||
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
|
||||
header: new Set(),
|
||||
footer: new Set(),
|
||||
};
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -148,6 +171,26 @@ export function runPluginClientBundle(
|
||||
removals.add(remove);
|
||||
return remove;
|
||||
}
|
||||
function addSidebarItem(
|
||||
section: PluginSidebarSection,
|
||||
contribution: PluginSidebarItemContribution,
|
||||
): PluginCleanup {
|
||||
const ids = sidebarItemIds[section];
|
||||
const normalizedId = requireId(contribution.id, `sidebar ${section} item id`);
|
||||
if (ids.has(normalizedId))
|
||||
throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`);
|
||||
const title = contribution.title.trim();
|
||||
if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Sidebar item ${normalizedId} is not a component`);
|
||||
}
|
||||
ids.add(normalizedId);
|
||||
return register(
|
||||
collector.sidebarItems[section],
|
||||
{ id: normalizedId, title, Component: contribution.Component },
|
||||
() => ids.delete(normalizedId),
|
||||
);
|
||||
}
|
||||
const pluginContext: PluginClientContext = {
|
||||
...runtime,
|
||||
addSettingsScreen(contribution) {
|
||||
@@ -161,34 +204,56 @@ export function runPluginClientBundle(
|
||||
settingsScreenIds.delete(screenId),
|
||||
);
|
||||
},
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
const normalizedId = requireId(surfaceId, "surface id");
|
||||
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
|
||||
if (typeof Component !== "function")
|
||||
throw new Error(`Surface ${normalizedId} is not a component`);
|
||||
addScreen(contribution: PluginScreenContribution) {
|
||||
if (typeof contribution !== "object" || contribution === null) {
|
||||
throw new Error("addScreen takes { id, title, Component }");
|
||||
}
|
||||
const normalizedId = requireId(contribution.id, "screen id");
|
||||
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate screen: ${normalizedId}`);
|
||||
const title = requireScreenTitle(normalizedId, contribution.title);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Screen ${normalizedId} is not a component`);
|
||||
}
|
||||
surfaceIds.add(normalizedId);
|
||||
return register(collector.surfaces, { id: normalizedId, Component }, () =>
|
||||
surfaceIds.delete(normalizedId),
|
||||
return register(
|
||||
collector.surfaces,
|
||||
{ id: normalizedId, title, Component: contribution.Component },
|
||||
() => surfaceIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("header", contribution);
|
||||
},
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("footer", contribution);
|
||||
},
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
|
||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||
if (!isComponentType(Component)) throw new Error(`Screen ${surfaceId} is not a component`);
|
||||
return pluginContext.addScreen({
|
||||
id: surfaceId,
|
||||
title: surfaceId,
|
||||
Component: (props: PluginScreenProps) => React.createElement(Component, props),
|
||||
});
|
||||
},
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
if (sidebarItemIds.has(normalizedId))
|
||||
throw new Error(`Duplicate sidebar item: ${normalizedId}`);
|
||||
if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`);
|
||||
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
||||
resolvePluginIcon(contribution.icon.trim());
|
||||
sidebarItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.sidebarItems,
|
||||
{
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
icon: contribution.icon.trim(),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
},
|
||||
() => sidebarItemIds.delete(normalizedId),
|
||||
);
|
||||
const legacy = {
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
icon: contribution.icon.trim(),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
};
|
||||
// Shares the header's ids: the sidebar orders both kinds of item under one key.
|
||||
const ids = sidebarItemIds.header;
|
||||
if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar header item: ${normalizedId}`);
|
||||
if (!legacy.title) throw new Error(`Sidebar item ${normalizedId} has no title`);
|
||||
ids.add(normalizedId);
|
||||
return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId));
|
||||
},
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
|
||||
const normalizedId = requireId(contribution.id, "workspace panel id");
|
||||
@@ -411,7 +476,7 @@ export function runPluginClientBundle(
|
||||
entryCleanup = setup(pluginContext);
|
||||
if (typeof entryCleanup !== "function")
|
||||
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
||||
for (const item of collector.sidebarItems) {
|
||||
for (const item of collector.legacySidebarItems) {
|
||||
if (!surfaceIds.has(item.surface)) {
|
||||
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
|
||||
}
|
||||
@@ -448,6 +513,7 @@ export function runPluginClientBundle(
|
||||
surfaces: collector.surfaces,
|
||||
settingsScreens: collector.settingsScreens,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { PluginInstallationProvider } from "./installation-provider";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
const status = defineRpc({
|
||||
name: "deploys.status",
|
||||
input: z.object({}),
|
||||
output: z.object({ state: z.string() }),
|
||||
});
|
||||
|
||||
function installation(invoked: string[]): InstalledPlugin {
|
||||
return {
|
||||
id: "deploys",
|
||||
serverId: "host",
|
||||
clientBundle: "",
|
||||
lifetime: new AbortController(),
|
||||
paseo: { dispose: async () => undefined } as PaseoApi,
|
||||
invoke: async (method) => {
|
||||
invoked.push(method);
|
||||
return { state: "green" };
|
||||
},
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("gives every surface the installation's client and RPCs on its first frame", async () => {
|
||||
const invoked: string[] = [];
|
||||
const plugin = installation(invoked);
|
||||
const received: PaseoApi[] = [];
|
||||
let rpc: ((input: Record<string, never>) => Promise<unknown>) | null = null;
|
||||
function Surface({ name }: { name: string }) {
|
||||
received.push(usePaseo());
|
||||
rpc = useRpc(status);
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
const markup = ["Screen", "Sidebar item", "Popover"]
|
||||
.map((name) =>
|
||||
renderToStaticMarkup(
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<Surface name={name} />
|
||||
</PluginInstallationProvider>,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
|
||||
expect(markup).toBe("<span>Screen</span><span>Sidebar item</span><span>Popover</span>");
|
||||
expect(received).toEqual([plugin.paseo, plugin.paseo, plugin.paseo]);
|
||||
expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true);
|
||||
await expect(rpc!({})).resolves.toEqual({ state: "green" });
|
||||
expect(invoked).toEqual(["deploys.status"]);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host";
|
||||
import React, { type ReactNode } from "react";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */
|
||||
export function PluginInstallationProvider({
|
||||
plugin,
|
||||
children,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={plugin.paseo}>
|
||||
<PluginRpcProvider invoke={plugin.invoke}>{children}</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,10 @@ export function createPluginNavigation(input: {
|
||||
openSettings(pluginId, screenId) {
|
||||
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
||||
},
|
||||
openSurface(pluginId, surfaceId) {
|
||||
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
|
||||
openSurface(pluginId, surfaceId, params) {
|
||||
router.push(
|
||||
buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }, params),
|
||||
);
|
||||
},
|
||||
openWorkspacePanel(pluginId, panelId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/** The platform plugins see in `layout.platform`. */
|
||||
export function resolvePluginPlatform(): PluginHostProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MenuSurface, type MenuSurfaceProps } from "@/components/ui/menu";
|
||||
import { ToastApiProvider, type useToast } from "@/contexts/toast-context";
|
||||
import type { createPluginClientStateSource } from "./client-state/source";
|
||||
import { PluginInstallationProvider } from "./installation-provider";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export interface PluginEnvironment {
|
||||
installation: InstalledPlugin;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
toast: ReturnType<typeof useToast>;
|
||||
}
|
||||
|
||||
interface PluginEnvironmentProviderProps {
|
||||
environment: PluginEnvironment;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The providers plugin UI runs under. Render it around a contribution and again inside every
|
||||
* surface it opens: native sheets teleport their children, so providers around the trigger alone
|
||||
* cannot reach a popover body.
|
||||
*/
|
||||
export function PluginEnvironmentProvider({
|
||||
environment,
|
||||
children,
|
||||
}: PluginEnvironmentProviderProps) {
|
||||
return (
|
||||
<ToastApiProvider api={environment.toast}>
|
||||
<PluginInstallationProvider plugin={environment.installation}>
|
||||
<PluginClientStateProvider source={environment.state}>{children}</PluginClientStateProvider>
|
||||
</PluginInstallationProvider>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type PluginPopoverSurfaceProps = Pick<
|
||||
MenuSurfaceProps,
|
||||
"children" | "pages" | "sheetTitle" | "sheetTrailing" | "side" | "align" | "offset" | "testID"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The surface every plugin popover opens in: anchored to its trigger on wide layouts, a bottom
|
||||
* sheet on compact ones when the enclosing `MenuRoot` uses `compactMode="sheet"`.
|
||||
*/
|
||||
export function PluginPopoverSurface(props: PluginPopoverSurfaceProps) {
|
||||
return <MenuSurface {...props} minWidth={280} maxWidth={420} maxHeight={440} scrollable />;
|
||||
}
|
||||
|
||||
export function PluginPopoverContent({ children }: { children: ReactNode }) {
|
||||
return <View style={styles.content}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
}));
|
||||
@@ -10,3 +10,5 @@ export {
|
||||
} from "@/components/settings";
|
||||
|
||||
export { ExternalLink } from "@/components/ui/external-link";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { expect, test } from "vitest";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginRegistry } from "./registry";
|
||||
|
||||
/** A host client that records open event observations by their first event name. */
|
||||
function observingClient() {
|
||||
const open = new Set<string>();
|
||||
const client = {
|
||||
observeEvents(events: string[]) {
|
||||
const [name] = events;
|
||||
open.add(name);
|
||||
return {
|
||||
ready: Promise.resolve({ subscriptionId: name }),
|
||||
subscribe: () => () => undefined,
|
||||
release: async () => {
|
||||
open.delete(name);
|
||||
},
|
||||
};
|
||||
},
|
||||
invokePluginRpc: async () => null,
|
||||
} as unknown as DaemonClient;
|
||||
return { client, open };
|
||||
}
|
||||
|
||||
function registry() {
|
||||
const client = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "plugin-lifetime-test" });
|
||||
const released: string[] = [];
|
||||
const setupClients = new Map<string, PaseoApi>();
|
||||
const plugins = new PluginRegistry({
|
||||
version: "0.8.0",
|
||||
createRuntime: (installation) => {
|
||||
const api = createPaseoApi(client);
|
||||
setupClients.set(installation.id, installation.paseo);
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
@@ -21,16 +40,11 @@ function registry() {
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: {
|
||||
...api,
|
||||
dispose: async () => {
|
||||
released.push(installation.id);
|
||||
await api.dispose();
|
||||
},
|
||||
},
|
||||
paseo: installation.paseo,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected plugin RPC");
|
||||
},
|
||||
openScreen: () => {},
|
||||
openSurface: () => {},
|
||||
openSettings: () => {},
|
||||
openPanel: () => {},
|
||||
@@ -39,36 +53,77 @@ function registry() {
|
||||
};
|
||||
},
|
||||
});
|
||||
return { client, released, plugins };
|
||||
return { ...observingClient(), plugins, setupClients };
|
||||
}
|
||||
|
||||
function catalog(id: string, body: string) {
|
||||
/** A plugin whose setup opens an observation named after it and never releases it. */
|
||||
function catalog(id: string, body = "return function() {};") {
|
||||
return {
|
||||
id,
|
||||
requirements: { paseo: ">=0.8.0" },
|
||||
clientBundle: `(function() { return { default: function(plugin) { ${body} } }; })`,
|
||||
clientBundle: `(function() { return { default: function(plugin) { plugin.paseo.observeEvents(["${id}"]); ${body} } }; })`,
|
||||
};
|
||||
}
|
||||
|
||||
test("failed plugin initialization disposes its API scope", async () => {
|
||||
test("setup and every surface share the installation's one Paseo client", () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client });
|
||||
|
||||
const [installation] = h.plugins.getSnapshot();
|
||||
expect(h.setupClients.get("deploys")).toBe(installation.paseo);
|
||||
expect(h.open).toEqual(new Set(["deploys"]));
|
||||
});
|
||||
|
||||
test("teardown ends every subscription the plugin still holds", async () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { client: h.client });
|
||||
expect(h.open).toEqual(new Set(["disabled", "kept"]));
|
||||
|
||||
// Disabling one plugin removes it from the catalog.
|
||||
h.plugins.installCatalog("host", [catalog("kept")], { client: h.client });
|
||||
await expect.poll(() => [...h.open]).toEqual(["kept"]);
|
||||
|
||||
h.plugins.removeHost("host");
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
});
|
||||
|
||||
test("reloading a plugin ends the old installation's subscriptions", async () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client });
|
||||
const [first] = h.plugins.getSnapshot();
|
||||
|
||||
h.plugins.installCatalog("host", [catalog("reloaded")], {
|
||||
client: h.client,
|
||||
replacePluginId: "reloaded",
|
||||
});
|
||||
|
||||
const [second] = h.plugins.getSnapshot();
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.paseo).not.toBe(first.paseo);
|
||||
await expect(first.paseo.dispose()).resolves.toBeUndefined();
|
||||
expect(() => first.paseo.observeEvents(["project.update"])).toThrow("Paseo API is disposed");
|
||||
expect(h.open).toEqual(new Set(["reloaded"]));
|
||||
});
|
||||
|
||||
test("failed plugin initialization ends the subscriptions it opened", async () => {
|
||||
const h = registry();
|
||||
h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], {
|
||||
client: h.client,
|
||||
});
|
||||
await expect.poll(() => h.released).toEqual(["failed"]);
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
expect(h.plugins.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
test("unloading releases the API even when plugin cleanup throws, preserving another plugin", async () => {
|
||||
test("unloading ends the subscriptions even when plugin cleanup throws, preserving another plugin", async () => {
|
||||
const h = registry();
|
||||
const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };');
|
||||
const surviving = catalog("surviving", "return function() {};");
|
||||
h.plugins.installCatalog("host", [failing, surviving], { client: h.client });
|
||||
h.plugins.installCatalog("host", [surviving], { client: h.client });
|
||||
await expect.poll(() => h.released).toEqual(["failing"]);
|
||||
await expect.poll(() => [...h.open]).toEqual(["surviving"]);
|
||||
expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]);
|
||||
h.plugins.removeHost("host");
|
||||
await expect.poll(() => h.released).toEqual(["failing", "surviving"]);
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
});
|
||||
|
||||
test("an invalid async client entry is disposed and its rejected continuation is observed", async () => {
|
||||
@@ -78,7 +133,7 @@ test("an invalid async client entry is disposed and its rejected continuation is
|
||||
[catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')],
|
||||
{ client: h.client },
|
||||
);
|
||||
await expect.poll(() => h.released).toEqual(["async-entry"]);
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.plugins.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginRegistry } from "./registry";
|
||||
|
||||
@@ -23,13 +22,14 @@ function registry(version: string) {
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: createPaseoApi(client),
|
||||
paseo: installation.paseo,
|
||||
rpc: async () => {
|
||||
throw new Error("No RPC in this plugin");
|
||||
},
|
||||
openSettings() {
|
||||
cleanups++;
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { createPluginClientRuntime } from "./client-runtime";
|
||||
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
|
||||
import { runPluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
|
||||
@@ -64,8 +65,7 @@ export class PluginRegistry {
|
||||
}
|
||||
const installed = catalog.flatMap((entry) => {
|
||||
const key = `${serverId}/${entry.id}`;
|
||||
let runtime: PluginClientRuntime | undefined;
|
||||
let lifetime: AbortController | undefined;
|
||||
let installation: InstalledPlugin | undefined;
|
||||
try {
|
||||
if (!entry.clientBundle) return [];
|
||||
assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" });
|
||||
@@ -76,9 +76,11 @@ export class PluginRegistry {
|
||||
this.evaluationErrors.delete(key);
|
||||
return [existing];
|
||||
}
|
||||
lifetime = new AbortController();
|
||||
const installation: InstalledPlugin = {
|
||||
lifetime,
|
||||
const client = options.client;
|
||||
installation = {
|
||||
lifetime: new AbortController(),
|
||||
paseo: createPaseoApi(client),
|
||||
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
|
||||
id: entry.id,
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
@@ -87,7 +89,8 @@ export class PluginRegistry {
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
settingsScreens: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -96,12 +99,12 @@ export class PluginRegistry {
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
runtime = this.dependencies.createRuntime(installation, options.client);
|
||||
const runtime = this.dependencies.createRuntime(installation);
|
||||
const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () =>
|
||||
this.publish(),
|
||||
);
|
||||
Object.assign(installation, evaluated);
|
||||
const paseo = runtime.paseo;
|
||||
const paseo = installation.paseo;
|
||||
installation.cleanup = async () => {
|
||||
const results = await Promise.allSettled([paseo.dispose(), evaluated.cleanup()]);
|
||||
const failures = results.filter((result) => result.status === "rejected");
|
||||
@@ -114,8 +117,8 @@ export class PluginRegistry {
|
||||
this.evaluationErrors.delete(key);
|
||||
return [installation];
|
||||
} catch (error) {
|
||||
lifetime?.abort();
|
||||
void runtime?.paseo
|
||||
installation?.lifetime.abort();
|
||||
void installation?.paseo
|
||||
.dispose()
|
||||
.catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure));
|
||||
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLegacyPluginSurfaceRedirectRoute, buildPluginSurfaceRoute } from "./routes";
|
||||
import {
|
||||
buildLegacyPluginSurfaceRedirectRoute,
|
||||
buildPluginSurfaceRoute,
|
||||
parsePluginSurfaceRoute,
|
||||
pluginScreenParamsFromRoute,
|
||||
} from "./routes";
|
||||
|
||||
describe("buildPluginSurfaceRoute", () => {
|
||||
it("keeps direct surfaces and sidebar contributions in separate route namespaces", () => {
|
||||
@@ -11,9 +16,79 @@ describe("buildPluginSurfaceRoute", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts screen params in the query under their own prefix, encoded", () => {
|
||||
expect(
|
||||
buildPluginSurfaceRoute(
|
||||
"local",
|
||||
"bots",
|
||||
{ kind: "surface", id: "bot" },
|
||||
{ botId: "bot 2", "a&b": "x=y" },
|
||||
),
|
||||
).toBe("/h/local/plugin/bots/surface/bot?param.botId=bot%202¶m.a%26b=x%3Dy");
|
||||
expect(buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, {})).toBe(
|
||||
"/h/local/plugin/bots/surface/bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects legacy plugin surface URLs to their sidebar contribution identity", () => {
|
||||
expect(buildLegacyPluginSurfaceRedirectRoute("host/one", "review", "overview/item")).toBe(
|
||||
"/h/host%2Fone/plugin/review/sidebar/overview%2Fitem",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginSurfaceRoute", () => {
|
||||
it("reads both route namespaces back from the paths buildPluginSurfaceRoute produces", () => {
|
||||
expect(
|
||||
parsePluginSurfaceRoute(
|
||||
buildPluginSurfaceRoute("host/one", "review", { kind: "surface", id: "overview" }),
|
||||
),
|
||||
).toEqual({
|
||||
serverId: "host/one",
|
||||
pluginId: "review",
|
||||
identity: { kind: "surface", id: "overview" },
|
||||
});
|
||||
expect(parsePluginSurfaceRoute("/h/local/plugin/review/sidebar/item")).toEqual({
|
||||
serverId: "local",
|
||||
pluginId: "review",
|
||||
identity: { kind: "sidebar", id: "item" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores routes that are not plugin screens", () => {
|
||||
expect(parsePluginSurfaceRoute("/h/local/workspace/abc")).toBeNull();
|
||||
expect(parsePluginSurfaceRoute("/h/local/plugin/review/settings/general")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginScreenParamsFromRoute", () => {
|
||||
function searchParams(route: string): Record<string, string | string[]> {
|
||||
// Expo Router's search params: the route's segments merged with its query.
|
||||
const url = new URL(route, "http://paseo.test");
|
||||
return {
|
||||
serverId: "local",
|
||||
pluginId: "bots",
|
||||
contributionKind: "surface",
|
||||
contributionId: "bot",
|
||||
...Object.fromEntries(url.searchParams),
|
||||
};
|
||||
}
|
||||
|
||||
it("round-trips params whose keys match the route's own segments", () => {
|
||||
const params = { serverId: "x", pluginId: "y", botId: "bot-2" };
|
||||
const route = buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, params);
|
||||
expect(pluginScreenParamsFromRoute(searchParams(route))).toEqual(params);
|
||||
});
|
||||
|
||||
it("ignores the route's segments and non-string values", () => {
|
||||
expect(
|
||||
pluginScreenParamsFromRoute({
|
||||
serverId: "local",
|
||||
contributionId: "bot",
|
||||
"param.botId": "bot-2",
|
||||
"param.tags": ["a", "b"],
|
||||
"param.missing": undefined,
|
||||
}),
|
||||
).toEqual({ botId: "bot-2" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
import type { PluginScreenParams } from "@getpaseo/plugin/client";
|
||||
import type { PluginSurfaceContributionIdentity } from "./surface-contribution";
|
||||
|
||||
type PluginSurfaceRoute<Kind extends PluginSurfaceContributionIdentity["kind"]> =
|
||||
`/h/${string}/plugin/${string}/${Kind}/${string}`;
|
||||
|
||||
/**
|
||||
* Expo Router merges the route's segments (`serverId`, `pluginId`, ...) into the same search params
|
||||
* as the query. Prefixing screen param keys keeps any plugin key clear of them.
|
||||
*/
|
||||
const SCREEN_PARAM_PREFIX = "param.";
|
||||
|
||||
/** Screen params ride in the route's query, so reload, history and deep links keep them. */
|
||||
export function buildPluginSurfaceRoute<Identity extends PluginSurfaceContributionIdentity>(
|
||||
serverId: string,
|
||||
pluginId: string,
|
||||
identity: Identity,
|
||||
params: PluginScreenParams = {},
|
||||
): PluginSurfaceRoute<Identity["kind"]> {
|
||||
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}` as PluginSurfaceRoute<
|
||||
Identity["kind"]
|
||||
>;
|
||||
const path = `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}`;
|
||||
const query = Object.entries(params)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${encodeURIComponent(SCREEN_PARAM_PREFIX + key)}=${encodeURIComponent(value)}`,
|
||||
)
|
||||
.join("&");
|
||||
return (query ? `${path}?${query}` : path) as PluginSurfaceRoute<Identity["kind"]>;
|
||||
}
|
||||
|
||||
/** Checks params a plugin passed to `openScreen`: an object of string values. Throws otherwise. */
|
||||
export function parsePluginScreenParams(value: unknown): PluginScreenParams {
|
||||
if (value === undefined) return {};
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("Plugin screen params must be an object of strings");
|
||||
}
|
||||
const params: PluginScreenParams = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry !== "string") {
|
||||
throw new Error(`Plugin screen param ${key} must be a string`);
|
||||
}
|
||||
params[key] = entry;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The screen params in a plugin screen route's search params (`useLocalSearchParams`). */
|
||||
export function pluginScreenParamsFromRoute(
|
||||
routeParams: Readonly<Record<string, string | string[] | undefined>>,
|
||||
): PluginScreenParams {
|
||||
const params: PluginScreenParams = {};
|
||||
for (const [key, value] of Object.entries(routeParams)) {
|
||||
if (!key.startsWith(SCREEN_PARAM_PREFIX) || typeof value !== "string") continue;
|
||||
params[key.slice(SCREEN_PARAM_PREFIX.length)] = value;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildLegacyPluginSurfaceRedirectRoute(
|
||||
@@ -33,3 +75,21 @@ export function hostIdFromPathname(pathname: string): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePluginSurfaceRoute(pathname: string): {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
identity: PluginSurfaceContributionIdentity;
|
||||
} | null {
|
||||
const match = /^\/h\/([^/]+)\/plugin\/([^/]+)\/(sidebar|surface)\/([^/]+)\/?$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return {
|
||||
serverId: decodeURIComponent(match[1]),
|
||||
pluginId: decodeURIComponent(match[2]),
|
||||
identity: { kind: match[3] as "sidebar" | "surface", id: decodeURIComponent(match[4]) },
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { ReactNode } from "react";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import { usePluginSurfaceRuntime } from "./surface-runtime";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export function PluginRuntimeBoundary({
|
||||
plugin,
|
||||
client,
|
||||
children,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
client: DaemonClient;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const runtime = usePluginSurfaceRuntime(client, plugin);
|
||||
if (!runtime) return null;
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={runtime.paseo}>
|
||||
<PluginRpcProvider invoke={runtime.invoke}>{children}</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { buildPluginSettingsRoute } from "./routes";
|
||||
@@ -119,14 +119,15 @@ function SettingsContent({
|
||||
resetKey={attempt}
|
||||
renderError={renderError}
|
||||
>
|
||||
<PluginRuntimeBoundary plugin={plugin} client={client}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<Component theme={theme} layout={layout} host={host} />
|
||||
</PluginRuntimeBoundary>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
// Settings routes carry no screen params.
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginSettingsContent({
|
||||
onBackToPlugins,
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import { groupPluginSidebarContributions } from "./sidebar-groups";
|
||||
import {
|
||||
legacySidebarItemHostKey,
|
||||
pluginScreensHostKey,
|
||||
rememberPluginContributionHost,
|
||||
} from "./contribution-host";
|
||||
import { groupPluginSidebarItems, selectPluginSidebarTarget } from "./sidebar-groups";
|
||||
|
||||
function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
function installed(
|
||||
serverId: string,
|
||||
contributionId = "main",
|
||||
legacySidebarItems: InstalledPlugin["legacySidebarItems"] = [],
|
||||
pluginId = "example",
|
||||
): InstalledPlugin {
|
||||
return {
|
||||
id: "example",
|
||||
id: pluginId,
|
||||
cleanup: () => undefined,
|
||||
serverId,
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [{ id: "surface", Component: () => null }],
|
||||
sidebarItems: [
|
||||
{
|
||||
id: contributionId,
|
||||
title: "Example",
|
||||
icon: "Blocks",
|
||||
surface: "surface",
|
||||
},
|
||||
],
|
||||
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
|
||||
sidebarItems: {
|
||||
header: [{ id: contributionId, title: "Example", Component: () => null }],
|
||||
footer: [{ id: "status", title: "Status", Component: () => null }],
|
||||
},
|
||||
legacySidebarItems,
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -31,9 +40,9 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupPluginSidebarContributions", () => {
|
||||
describe("groupPluginSidebarItems", () => {
|
||||
it("coalesces the same plugin contribution across hosts", () => {
|
||||
const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]);
|
||||
const groups = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "header");
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([
|
||||
@@ -43,14 +52,66 @@ describe("groupPluginSidebarContributions", () => {
|
||||
});
|
||||
|
||||
it("keeps different contribution ids separate", () => {
|
||||
const groups = groupPluginSidebarContributions([
|
||||
installed("host-a", "main"),
|
||||
installed("host-b", "settings"),
|
||||
]);
|
||||
const groups = groupPluginSidebarItems(
|
||||
[installed("host-a", "main"), installed("host-b", "settings")],
|
||||
"header",
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual([
|
||||
"example/sidebar/main",
|
||||
"example/sidebar/settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a legacy addSidebarItem as its own kind, with its icon, in the header only", () => {
|
||||
const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }];
|
||||
const plugins = [installed("host-a", "main", legacy), installed("host-b", "main", legacy)];
|
||||
const [legacyGroup, itemGroup] = groupPluginSidebarItems(plugins, "header");
|
||||
|
||||
expect(legacyGroup).toMatchObject({ kind: "legacy", title: "Legacy", icon: "Blocks" });
|
||||
expect(legacyGroup?.targets.map((target) => target.plugin.serverId)).toEqual([
|
||||
"host-a",
|
||||
"host-b",
|
||||
]);
|
||||
expect(itemGroup?.kind).toBe("item");
|
||||
expect(groupPluginSidebarItems(plugins, "footer").map((group) => group.kind)).toEqual(["item"]);
|
||||
});
|
||||
|
||||
it("groups only the requested section", () => {
|
||||
const groups = groupPluginSidebarItems([installed("host-a")], "footer");
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual(["example/sidebar-footer/status"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPluginSidebarTarget", () => {
|
||||
const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }];
|
||||
|
||||
function hostsOf(section: "header" | "footer", pluginId: string) {
|
||||
const plugins = ["host-a", "host-b"].map((serverId) =>
|
||||
installed(serverId, "main", legacy, pluginId),
|
||||
);
|
||||
return groupPluginSidebarItems(plugins, section).map(
|
||||
(group) => selectPluginSidebarTarget(group, null).plugin.serverId,
|
||||
);
|
||||
}
|
||||
|
||||
it("prefers the current route's host", () => {
|
||||
const [group] = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "footer");
|
||||
expect(selectPluginSidebarTarget(group!, "host-b").plugin.serverId).toBe("host-b");
|
||||
});
|
||||
|
||||
it("carries the host picked on any of a plugin's screens to all its items, but not legacy rows", () => {
|
||||
expect(hostsOf("header", "screens")).toEqual(["host-a", "host-a"]);
|
||||
rememberPluginContributionHost(pluginScreensHostKey("screens"), "host-b");
|
||||
// Header: the legacy row keeps its own host; the item follows the screens' host.
|
||||
expect(hostsOf("header", "screens")).toEqual(["host-a", "host-b"]);
|
||||
expect(hostsOf("footer", "screens")).toEqual(["host-b"]);
|
||||
expect(hostsOf("footer", "other")).toEqual(["host-a"]);
|
||||
});
|
||||
|
||||
it("keeps a legacy row's host under its own key", () => {
|
||||
rememberPluginContributionHost(legacySidebarItemHostKey("legacy-row", "entry"), "host-b");
|
||||
expect(hostsOf("header", "legacy-row")).toEqual(["host-b", "host-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,65 @@
|
||||
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
|
||||
import {
|
||||
getPreferredPluginContributionHost,
|
||||
legacySidebarItemHostKey,
|
||||
pluginScreensHostKey,
|
||||
} from "./contribution-host";
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginSidebarSection,
|
||||
} from "./types";
|
||||
|
||||
export interface PluginSidebarTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginSidebarItemContribution;
|
||||
}
|
||||
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
export interface PluginLegacySidebarTarget {
|
||||
plugin: InstalledPlugin;
|
||||
item: PluginSidebarContribution;
|
||||
}
|
||||
|
||||
export interface PluginSidebarGroup {
|
||||
interface PluginSidebarGroupBase {
|
||||
key: string;
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
targets: PluginSidebarTarget[];
|
||||
}
|
||||
|
||||
export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] {
|
||||
/**
|
||||
* One sidebar item, coalesced across every host that contributes it. A `legacy` group is an
|
||||
* `addSidebarItem` row: the app draws it with its registered icon and it opens its sidebar route.
|
||||
*/
|
||||
export type PluginSidebarGroup =
|
||||
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
|
||||
| (PluginSidebarGroupBase & {
|
||||
kind: "legacy";
|
||||
icon: string;
|
||||
targets: PluginLegacySidebarTarget[];
|
||||
});
|
||||
|
||||
function groupKey(pluginId: string, section: PluginSidebarSection, itemId: string) {
|
||||
return section === "header"
|
||||
? legacySidebarItemHostKey(pluginId, itemId)
|
||||
: `${pluginId}/sidebar-${section}/${itemId}`;
|
||||
}
|
||||
|
||||
export function groupPluginSidebarItems(
|
||||
plugins: InstalledPlugin[],
|
||||
section: PluginSidebarSection,
|
||||
): PluginSidebarGroup[] {
|
||||
const groups = new Map<string, PluginSidebarGroup>();
|
||||
for (const plugin of plugins) {
|
||||
for (const item of plugin.sidebarItems) {
|
||||
const key = `${plugin.id}/sidebar/${item.id}`;
|
||||
const legacyItems = section === "header" ? plugin.legacySidebarItems : [];
|
||||
for (const item of legacyItems) {
|
||||
const key = groupKey(plugin.id, section, item.id);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.targets.push({ plugin, item });
|
||||
} else {
|
||||
if (existing?.kind === "legacy") existing.targets.push({ plugin, item });
|
||||
else if (!existing) {
|
||||
groups.set(key, {
|
||||
kind: "legacy",
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
contributionId: item.id,
|
||||
@@ -33,6 +69,41 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of plugin.sidebarItems[section]) {
|
||||
const key = groupKey(plugin.id, section, item.id);
|
||||
const existing = groups.get(key);
|
||||
if (existing?.kind === "item") existing.targets.push({ plugin, item });
|
||||
else if (!existing) {
|
||||
groups.set(key, {
|
||||
kind: "item",
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
contributionId: item.id,
|
||||
title: item.title,
|
||||
targets: [{ plugin, item }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a group's row uses: the current route's host, else the remembered one, else the first.
|
||||
* A legacy row remembers its own host; other items share the host last used with the plugin's
|
||||
* screens.
|
||||
*/
|
||||
export function selectPluginSidebarTarget<Group extends PluginSidebarGroup>(
|
||||
group: Group,
|
||||
currentHostId: string | null,
|
||||
): Group["targets"][number] {
|
||||
const rememberedHostId = getPreferredPluginContributionHost(
|
||||
group.kind === "legacy" ? group.key : pluginScreensHostKey(group.pluginId),
|
||||
);
|
||||
const targets: Group["targets"] = group.targets;
|
||||
return (
|
||||
targets.find((target) => target.plugin.serverId === currentHostId) ??
|
||||
targets.find((target) => target.plugin.serverId === rememberedHostId) ??
|
||||
targets[0]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
|
||||
import {
|
||||
getPreferredPluginContributionHost,
|
||||
rememberPluginContributionHost,
|
||||
} from "./contribution-host";
|
||||
import { type PluginSidebarGroup, type PluginSidebarTarget } from "./sidebar-groups";
|
||||
|
||||
function selectTarget(
|
||||
group: PluginSidebarGroup,
|
||||
currentHostId: string | null,
|
||||
): PluginSidebarTarget {
|
||||
const current = group.targets.find((target) => target.plugin.serverId === currentHostId);
|
||||
if (current) return current;
|
||||
const rememberedHostId = getPreferredPluginContributionHost(group.key);
|
||||
const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId);
|
||||
return remembered ?? group.targets[0];
|
||||
}
|
||||
|
||||
export function PluginSidebarItemRow({
|
||||
group,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
group: PluginSidebarGroup;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const target = selectTarget(group, hostIdFromPathname(pathname));
|
||||
const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, {
|
||||
kind: "sidebar",
|
||||
id: group.contributionId,
|
||||
});
|
||||
const isActive = group.targets.some(
|
||||
(candidate) =>
|
||||
pathname ===
|
||||
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, {
|
||||
kind: "sidebar",
|
||||
id: group.contributionId,
|
||||
}),
|
||||
);
|
||||
const navigate = useCallback(() => {
|
||||
rememberPluginContributionHost(group.key, target.plugin.serverId);
|
||||
onBeforeNavigate?.();
|
||||
router.push(route);
|
||||
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { PluginSidebarSection } from "../types";
|
||||
|
||||
/** What the host tells the kit components about the sidebar item they render in. */
|
||||
export interface SidebarItemFrame {
|
||||
section: PluginSidebarSection;
|
||||
title: string;
|
||||
testID: string;
|
||||
/** A row calls this before its `onPress`, so a popover that press opens anchors to it. */
|
||||
anchorTo: (node: View | null) => void;
|
||||
/** Rows offer themselves on mount, so a popover opened without a press anchors to a row. */
|
||||
offerAnchor: (node: View | null) => void;
|
||||
releaseAnchor: (node: View | null) => void;
|
||||
}
|
||||
|
||||
export const SidebarItemFrameContext = createContext<SidebarItemFrame | null>(null);
|
||||
|
||||
export function useSidebarItemFrame(componentName: string): SidebarItemFrame {
|
||||
const frame = useContext(SidebarItemFrameContext);
|
||||
if (!frame) throw new Error(`${componentName} must render inside a sidebar item`);
|
||||
return frame;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type {
|
||||
PluginHostProps,
|
||||
PluginOpenScreenInput,
|
||||
PluginPopoverProps,
|
||||
PluginScreenLocation,
|
||||
PluginSidebarItemProps,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { router, useGlobalSearchParams, usePathname } from "expo-router";
|
||||
import { useCallback, useMemo, useState, type ComponentType, type RefObject } from "react";
|
||||
import { type View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
SidebarPopoverRoot,
|
||||
SidebarPopoverSurface,
|
||||
useSidebarPopoverAnchor,
|
||||
} from "@/components/sidebar/sidebar-popover";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { pluginScreensHostKey, rememberPluginContributionHost } from "../contribution-host";
|
||||
import {
|
||||
PluginEnvironmentProvider,
|
||||
PluginPopoverContent,
|
||||
type PluginEnvironment,
|
||||
} from "../popover";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import {
|
||||
selectPluginSidebarTarget,
|
||||
type PluginSidebarGroup,
|
||||
type PluginSidebarTarget,
|
||||
} from "../sidebar-groups";
|
||||
import { currentPluginScreen, parsePluginOpenScreenInput } from "../surface-contribution";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { PluginSidebarSection } from "../types";
|
||||
import { SidebarItemFrameContext, type SidebarItemFrame } from "./frame";
|
||||
import { LegacyPluginSidebarRow } from "./legacy";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "./kit";
|
||||
|
||||
type PopoverContent = ComponentType<PluginPopoverProps>;
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
type PluginItemGroup = Extract<PluginSidebarGroup, { kind: "item" }>;
|
||||
|
||||
function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PluginSidebarItemEntryProps {
|
||||
group: PluginSidebarGroup;
|
||||
section: PluginSidebarSection;
|
||||
/** Anchors a popover opened by an item that renders no kit component. */
|
||||
fallbackAnchorRef: RefObject<View | null>;
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
type PluginSidebarItemHostProps = Omit<PluginSidebarItemEntryProps, "group"> & {
|
||||
group: PluginItemGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* One plugin sidebar item. Renders the item's `Component` from the host `selectPluginSidebarTarget` picks,
|
||||
* inside the plugin's runtime and its own error boundary: a throwing item renders nothing. A legacy
|
||||
* `addSidebarItem` group renders the app's own row.
|
||||
*/
|
||||
export function PluginSidebarItem({ group, ...props }: PluginSidebarItemEntryProps) {
|
||||
if (group.kind === "legacy") {
|
||||
return <LegacyPluginSidebarRow group={group} onBeforeNavigate={props.onBeforeNavigate} />;
|
||||
}
|
||||
return <ThemedPluginSidebarItem {...props} group={group} uniProps={pluginThemeMapping} />;
|
||||
}
|
||||
|
||||
function PluginSidebarItemHost({
|
||||
group,
|
||||
section,
|
||||
fallbackAnchorRef,
|
||||
onBeforeNavigate,
|
||||
theme,
|
||||
}: PluginSidebarItemHostProps & { theme: PluginTheme }) {
|
||||
const pathname = usePathname();
|
||||
const routeParams = useGlobalSearchParams();
|
||||
const { plugin, item } = selectPluginSidebarTarget(group, hostIdFromPathname(pathname));
|
||||
const client = useHostRuntimeClient(plugin.serverId);
|
||||
const toast = useToast();
|
||||
// The content outlives `open` so the surface can play its exit and native sheet teardown.
|
||||
const [popover, setPopover] = useState<PopoverContent | null>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const environment = useMemo<PluginEnvironment | null>(
|
||||
() =>
|
||||
client
|
||||
? {
|
||||
installation: plugin,
|
||||
toast,
|
||||
state: createPluginClientStateSource(plugin.serverId),
|
||||
}
|
||||
: null,
|
||||
[client, plugin, toast],
|
||||
);
|
||||
const showPopover = useCallback((content: PopoverContent | null) => {
|
||||
// A function passed to a state setter is an updater, so wrap the component.
|
||||
if (content) setPopover(() => content);
|
||||
setPopoverOpen(content !== null);
|
||||
}, []);
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) setPopoverOpen(false);
|
||||
}, []);
|
||||
if (!environment) return null;
|
||||
return (
|
||||
<SurfaceErrorBoundary
|
||||
installation={plugin}
|
||||
Surface={item.Component}
|
||||
renderError={renderNothing}
|
||||
>
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<SidebarPopoverRoot open={popoverOpen} onOpenChange={handleOpenChange}>
|
||||
<SidebarItemContent
|
||||
plugin={plugin}
|
||||
item={item}
|
||||
section={section}
|
||||
theme={theme}
|
||||
currentScreen={currentPluginScreen(plugin, pathname, routeParams)}
|
||||
environment={environment}
|
||||
popover={popover}
|
||||
showPopover={showPopover}
|
||||
fallbackAnchorRef={fallbackAnchorRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
</SidebarPopoverRoot>
|
||||
</PluginEnvironmentProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginSidebarItem = withUnistyles(PluginSidebarItemHost);
|
||||
|
||||
function SidebarItemContent({
|
||||
plugin,
|
||||
item,
|
||||
section,
|
||||
theme,
|
||||
currentScreen,
|
||||
environment,
|
||||
popover,
|
||||
showPopover,
|
||||
fallbackAnchorRef,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
plugin: PluginSidebarTarget["plugin"];
|
||||
item: PluginSidebarTarget["item"];
|
||||
section: PluginSidebarSection;
|
||||
theme: PluginTheme;
|
||||
currentScreen: PluginScreenLocation | null;
|
||||
environment: PluginEnvironment;
|
||||
popover: PopoverContent | null;
|
||||
showPopover: (content: PopoverContent | null) => void;
|
||||
fallbackAnchorRef: RefObject<View | null>;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const { anchorTo, offerAnchor, releaseAnchor, anchorToFallback } =
|
||||
useSidebarPopoverAnchor("PluginSidebarItem");
|
||||
const hosts = useHosts();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hostLabel =
|
||||
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
|
||||
|
||||
const hostProps = useMemo<PluginHostProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: plugin.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
}),
|
||||
[compact, hostLabel, plugin.serverId, theme],
|
||||
);
|
||||
const openScreen = useCallback(
|
||||
(input: PluginOpenScreenInput) => {
|
||||
const { screenId, params } = parsePluginOpenScreenInput(plugin, input);
|
||||
rememberPluginContributionHost(pluginScreensHostKey(plugin.id), plugin.serverId);
|
||||
showPopover(null);
|
||||
onBeforeNavigate?.();
|
||||
router.push(
|
||||
buildPluginSurfaceRoute(
|
||||
plugin.serverId,
|
||||
plugin.id,
|
||||
{ kind: "surface", id: screenId },
|
||||
params,
|
||||
),
|
||||
);
|
||||
},
|
||||
[onBeforeNavigate, plugin, showPopover],
|
||||
);
|
||||
const openPopover = useCallback(
|
||||
(Content: PopoverContent) => {
|
||||
anchorToFallback(fallbackAnchorRef);
|
||||
showPopover(Content);
|
||||
},
|
||||
[anchorToFallback, fallbackAnchorRef, showPopover],
|
||||
);
|
||||
const close = useCallback(() => showPopover(null), [showPopover]);
|
||||
const frame = useMemo<SidebarItemFrame>(
|
||||
() => ({
|
||||
section,
|
||||
title: item.title,
|
||||
testID:
|
||||
section === "header"
|
||||
? `plugin-sidebar-${plugin.id}-${item.id}`
|
||||
: `plugin-sidebar-${section}-${plugin.id}-${item.id}`,
|
||||
anchorTo,
|
||||
offerAnchor,
|
||||
releaseAnchor,
|
||||
}),
|
||||
[anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section],
|
||||
);
|
||||
const itemProps = useMemo<PluginSidebarItemProps>(
|
||||
() => ({ ...hostProps, currentScreen, openScreen, openPopover }),
|
||||
[currentScreen, hostProps, openPopover, openScreen],
|
||||
);
|
||||
const Item = item.Component;
|
||||
const Popover = popover;
|
||||
return (
|
||||
<SidebarItemFrameContext.Provider value={frame}>
|
||||
<Item {...itemProps} />
|
||||
{Popover ? (
|
||||
<SidebarPopoverSurface
|
||||
section={section}
|
||||
title={item.title}
|
||||
testID={`${frame.testID}-popover`}
|
||||
>
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Popover}>
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginPopoverContent>
|
||||
<Popover {...hostProps} close={close} openScreen={openScreen} />
|
||||
</PluginPopoverContent>
|
||||
</PluginEnvironmentProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
</SidebarPopoverSurface>
|
||||
) : null}
|
||||
</SidebarItemFrameContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { useSidebarItemFrame } from "./frame";
|
||||
|
||||
function resolveIcon(icon: SidebarIcon) {
|
||||
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
|
||||
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
|
||||
* child claims the touch (native and web), and returns false so the child still handles it.
|
||||
*/
|
||||
export function SidebarRow({ id, icon, label, onPress, active, trailing }: SidebarRowProps) {
|
||||
const frame = useSidebarItemFrame("SidebarRow");
|
||||
const { anchorTo, offerAnchor, releaseAnchor } = frame;
|
||||
const rowRef = useRef<View | null>(null);
|
||||
useEffect(() => {
|
||||
const node = rowRef.current;
|
||||
offerAnchor(node);
|
||||
return () => releaseAnchor(node);
|
||||
}, [offerAnchor, releaseAnchor]);
|
||||
const anchorHere = useCallback(() => {
|
||||
anchorTo(rowRef.current);
|
||||
return false;
|
||||
}, [anchorTo]);
|
||||
// Keyboard activation skips the responder system, so the row's own press anchors as well.
|
||||
const handlePress = useCallback(() => {
|
||||
anchorTo(rowRef.current);
|
||||
onPress();
|
||||
}, [anchorTo, onPress]);
|
||||
return (
|
||||
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
|
||||
<SidebarHeaderRow
|
||||
icon={icon ? resolveIcon(icon) : null}
|
||||
label={label ?? frame.title}
|
||||
onPress={handlePress}
|
||||
isActive={active}
|
||||
trailing={trailing}
|
||||
testID={id ? `${frame.testID}-${id}` : frame.testID}
|
||||
variant={frame.section === "footer" ? "inline" : "compact"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The sidebar's separator line, run edge to edge across the section like the app's own. */
|
||||
export function SidebarSeparator() {
|
||||
const frame = useSidebarItemFrame("SidebarSeparator");
|
||||
return (
|
||||
<View style={frame.section === "footer" ? styles.footer : styles.header}>
|
||||
<AppSidebarSeparator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Header rows fill the sidebar width; the gap between them is 2px.
|
||||
header: {
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
// The footer pads its rows by `spacing[2]` and spaces them `spacing[1]` apart.
|
||||
footer: {
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
marginVertical: theme.spacing[0.5],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,44 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { rememberPluginContributionHost } from "../contribution-host";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
|
||||
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/**
|
||||
* An `addSidebarItem({ id, title, icon, surface })` row. It opens `/sidebar/<id>`, so the screen's
|
||||
* header and host switcher belong to this item.
|
||||
*/
|
||||
export function LegacyPluginSidebarRow({
|
||||
group,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
group: Extract<PluginSidebarGroup, { kind: "legacy" }>;
|
||||
onBeforeNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const identity = { kind: "sidebar", id: group.contributionId } as const;
|
||||
const target = selectPluginSidebarTarget(group, hostIdFromPathname(pathname));
|
||||
const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, identity);
|
||||
const isActive = group.targets.some(
|
||||
(candidate) =>
|
||||
pathname === buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, identity),
|
||||
);
|
||||
const navigate = useCallback(() => {
|
||||
rememberPluginContributionHost(group.key, target.plugin.serverId);
|
||||
onBeforeNavigate?.();
|
||||
router.push(route);
|
||||
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
|
||||
return (
|
||||
<SidebarHeaderRow
|
||||
icon={resolvePluginIcon(group.icon)}
|
||||
label={group.title}
|
||||
onPress={navigate}
|
||||
isActive={isActive}
|
||||
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
|
||||
variant="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import {
|
||||
currentPluginScreen,
|
||||
getPluginSurfaceContributionServerIds,
|
||||
parsePluginOpenScreenInput,
|
||||
resolvePluginScreenTitle,
|
||||
resolvePluginSurfaceContribution,
|
||||
} from "./surface-contribution";
|
||||
|
||||
@@ -18,10 +21,13 @@ function installation(
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: surfaces.map((id) => ({ id, Component: () => null })),
|
||||
sidebarItems: sidebarItems.map((item) => ({
|
||||
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: sidebarItems.map((item) => ({
|
||||
...item,
|
||||
title: item.id,
|
||||
icon: "Blocks",
|
||||
@@ -67,6 +73,7 @@ describe("plugin surface contribution identity", () => {
|
||||
expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([
|
||||
"host-v1",
|
||||
"host-v2",
|
||||
"same-surface",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -82,4 +89,129 @@ describe("plugin surface contribution identity", () => {
|
||||
"same-surface",
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a direct screen without borrowing a legacy item that points at it", () => {
|
||||
const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, {
|
||||
kind: "surface",
|
||||
id: "surface-v1",
|
||||
});
|
||||
|
||||
expect(resolved.sidebarItem).toBeNull();
|
||||
expect(resolved.surface?.id).toBe("surface-v1");
|
||||
});
|
||||
|
||||
it("resolves an old sidebar link to the same-id screen once the plugin has migrated", () => {
|
||||
const resolved = resolvePluginSurfaceContribution(installations[2] ?? null, {
|
||||
kind: "sidebar",
|
||||
id: "overview",
|
||||
});
|
||||
|
||||
expect(resolved.sidebarItem).toBeNull();
|
||||
expect(resolved.surface?.id).toBe("overview");
|
||||
expect(
|
||||
resolvePluginSurfaceContribution(installations[2] ?? null, { kind: "sidebar", id: "missing" })
|
||||
.surface,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentPluginScreen", () => {
|
||||
const plugin = installation(
|
||||
"local",
|
||||
["overview", "details"],
|
||||
[{ id: "legacy", surface: "details" }],
|
||||
);
|
||||
|
||||
it("reads the open screen and its params from direct and legacy routes on the installation's host", () => {
|
||||
expect(
|
||||
currentPluginScreen(plugin, "/h/local/plugin/review/surface/overview", {
|
||||
serverId: "local",
|
||||
pluginId: "review",
|
||||
contributionKind: "surface",
|
||||
contributionId: "overview",
|
||||
"param.botId": "bot-2",
|
||||
}),
|
||||
).toEqual({ screenId: "overview", params: { botId: "bot-2" } });
|
||||
expect(currentPluginScreen(plugin, "/h/local/plugin/review/sidebar/legacy", {})).toEqual({
|
||||
screenId: "details",
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("is null on another host, another plugin, or a missing screen", () => {
|
||||
expect(currentPluginScreen(plugin, "/h/remote/plugin/review/surface/overview", {})).toBeNull();
|
||||
expect(currentPluginScreen(plugin, "/h/local/plugin/other/surface/overview", {})).toBeNull();
|
||||
expect(currentPluginScreen(plugin, "/h/local/plugin/review/surface/missing", {})).toBeNull();
|
||||
expect(currentPluginScreen(plugin, "/h/local/workspace/abc", {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginOpenScreenInput", () => {
|
||||
const plugin = installation("local", ["bot"]);
|
||||
|
||||
it("accepts a registered screen with or without string params, under any key", () => {
|
||||
expect(parsePluginOpenScreenInput(plugin, { screenId: "bot" })).toEqual({
|
||||
screenId: "bot",
|
||||
params: {},
|
||||
});
|
||||
expect(parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: "2" } })).toEqual(
|
||||
{ screenId: "bot", params: { botId: "2" } },
|
||||
);
|
||||
expect(
|
||||
parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { serverId: "x" } }),
|
||||
).toEqual({ screenId: "bot", params: { serverId: "x" } });
|
||||
});
|
||||
|
||||
it("throws on a bare id, an unknown screen, or params that are not strings", () => {
|
||||
expect(() => parsePluginOpenScreenInput(plugin, "bot")).toThrow("{ screenId, params? }");
|
||||
expect(() => parsePluginOpenScreenInput(plugin, { screenId: "missing" })).toThrow(
|
||||
"unavailable: missing",
|
||||
);
|
||||
expect(() =>
|
||||
parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: 2 } }),
|
||||
).toThrow("botId must be a string");
|
||||
expect(() => parsePluginOpenScreenInput(plugin, { screenId: "bot", params: ["2"] })).toThrow(
|
||||
"must be an object of strings",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginScreenTitle", () => {
|
||||
const Component = () => null;
|
||||
const botNames: Record<string, string> = { "bot-1": "Bot 1", "bot-2": "Bot 2" };
|
||||
const bot = {
|
||||
id: "bot",
|
||||
title: (params: Record<string, string>) => botNames[params.botId] ?? "",
|
||||
Component,
|
||||
};
|
||||
|
||||
it("uses a string title as is", () => {
|
||||
expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe(
|
||||
"Deploys",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives a function title from the params it is given", () => {
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2");
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1");
|
||||
});
|
||||
|
||||
it("falls back to the screen id when the function returns empty or throws", () => {
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot");
|
||||
const throwing = {
|
||||
id: "bot",
|
||||
title: () => {
|
||||
throw new Error("no bots loaded");
|
||||
},
|
||||
Component,
|
||||
};
|
||||
expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot");
|
||||
});
|
||||
|
||||
it("uses the title of a legacy item that points at the screen", () => {
|
||||
const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" };
|
||||
expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe(
|
||||
"Legacy entry",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,100 @@
|
||||
import type { PluginScreenLocation, PluginScreenParams } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
parsePluginScreenParams,
|
||||
parsePluginSurfaceRoute,
|
||||
pluginScreenParamsFromRoute,
|
||||
} from "./routes";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export type PluginSurfaceContributionIdentity =
|
||||
| { kind: "sidebar"; id: string }
|
||||
| { kind: "surface"; id: string };
|
||||
|
||||
/**
|
||||
* A `sidebar` identity is a legacy `addSidebarItem` route: it resolves to that item and its screen,
|
||||
* or, once the plugin has moved that item to `addScreen` under the same id, to that screen.
|
||||
*/
|
||||
export function resolvePluginSurfaceContribution(
|
||||
plugin: InstalledPlugin | null,
|
||||
identity: PluginSurfaceContributionIdentity | null,
|
||||
): {
|
||||
sidebarItem: InstalledPlugin["sidebarItems"][number] | null;
|
||||
sidebarItem: InstalledPlugin["legacySidebarItems"][number] | null;
|
||||
surface: InstalledPlugin["surfaces"][number] | null;
|
||||
} {
|
||||
if (!identity) return { sidebarItem: null, surface: null };
|
||||
if (!identity || !plugin) return { sidebarItem: null, surface: null };
|
||||
const sidebarItem =
|
||||
identity.kind === "sidebar"
|
||||
? (plugin?.sidebarItems.find((contribution) => contribution.id === identity.id) ?? null)
|
||||
? (plugin.legacySidebarItems.find((contribution) => contribution.id === identity.id) ?? null)
|
||||
: null;
|
||||
const surfaceId = identity.kind === "sidebar" ? sidebarItem?.surface : identity.id;
|
||||
const surfaceId = sidebarItem ? sidebarItem.surface : identity.id;
|
||||
const surface = surfaceId
|
||||
? (plugin?.surfaces.find((contribution) => contribution.id === surfaceId) ?? null)
|
||||
? (plugin.surfaces.find((contribution) => contribution.id === surfaceId) ?? null)
|
||||
: null;
|
||||
return { sidebarItem, surface };
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen header's title. A legacy `addSidebarItem` pointing at the screen lends its title, as
|
||||
* before screens had one. A title function that throws or returns an empty string falls back to the
|
||||
* screen id, so a bad title never takes the screen down.
|
||||
*/
|
||||
export function resolvePluginScreenTitle(
|
||||
screen: InstalledPlugin["surfaces"][number],
|
||||
legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null,
|
||||
params: PluginScreenParams,
|
||||
): string {
|
||||
if (legacySidebarItem) return legacySidebarItem.title;
|
||||
if (typeof screen.title === "string") return screen.title;
|
||||
try {
|
||||
const title = screen.title(params);
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
} catch (error) {
|
||||
console.warn(`[Plugins] Screen ${screen.id} title failed`, error);
|
||||
}
|
||||
return screen.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installation's screen open at `pathname` with the route's search params, or null when the
|
||||
* route shows anything else.
|
||||
*/
|
||||
export function currentPluginScreen(
|
||||
plugin: InstalledPlugin,
|
||||
pathname: string,
|
||||
routeParams: Readonly<Record<string, string | string[] | undefined>>,
|
||||
): PluginScreenLocation | null {
|
||||
const route = parsePluginSurfaceRoute(pathname);
|
||||
if (!route || route.serverId !== plugin.serverId || route.pluginId !== plugin.id) return null;
|
||||
const screenId = resolvePluginSurfaceContribution(plugin, route.identity).surface?.id;
|
||||
return screenId ? { screenId, params: pluginScreenParamsFromRoute(routeParams) } : null;
|
||||
}
|
||||
|
||||
/** Checks an `openScreen` input against the installation's screens. Throws when it is invalid. */
|
||||
export function parsePluginOpenScreenInput(
|
||||
plugin: InstalledPlugin,
|
||||
input: unknown,
|
||||
): PluginScreenLocation {
|
||||
if (typeof input !== "object" || input === null || !("screenId" in input)) {
|
||||
throw new Error("openScreen takes { screenId, params? }");
|
||||
}
|
||||
const { screenId } = input;
|
||||
if (typeof screenId !== "string" || !plugin.surfaces.some((surface) => surface.id === screenId)) {
|
||||
throw new Error(`Plugin screen is unavailable: ${String(screenId)}`);
|
||||
}
|
||||
const params = "params" in input ? input.params : undefined;
|
||||
return { screenId, params: parsePluginScreenParams(params) };
|
||||
}
|
||||
|
||||
export function getPluginSurfaceContributionServerIds(
|
||||
installations: readonly InstalledPlugin[],
|
||||
pluginId: string,
|
||||
identity: PluginSurfaceContributionIdentity,
|
||||
): string[] {
|
||||
return installations
|
||||
.filter((installation) => {
|
||||
if (installation.id !== pluginId) return false;
|
||||
return identity.kind === "sidebar"
|
||||
? installation.sidebarItems.some((contribution) => contribution.id === identity.id)
|
||||
: installation.surfaces.some((contribution) => contribution.id === identity.id);
|
||||
})
|
||||
.filter(
|
||||
(installation) =>
|
||||
installation.id === pluginId &&
|
||||
resolvePluginSurfaceContribution(installation, identity).surface !== null,
|
||||
)
|
||||
.map((installation) => installation.serverId);
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { PaseoApiProvider } from "@getpaseo/plugin/client/host";
|
||||
import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||
|
||||
function clientWithWorkspace(id: string) {
|
||||
const createWorkspace = vi.fn(async () => ({
|
||||
error: null,
|
||||
workspace: {
|
||||
id,
|
||||
projectId: `project-${id}`,
|
||||
workspaceDirectory: `/tmp/${id}`,
|
||||
name: id,
|
||||
status: "active",
|
||||
},
|
||||
}));
|
||||
const createAgent = vi.fn(async () => ({
|
||||
id: `agent-${id}`,
|
||||
provider: "codex",
|
||||
cwd: `/tmp/${id}`,
|
||||
workspaceId: id,
|
||||
status: "idle",
|
||||
}));
|
||||
const invokePluginRpc = vi.fn(async () => id);
|
||||
return {
|
||||
client: { createWorkspace, createAgent, invokePluginRpc } as unknown as DaemonClient,
|
||||
createWorkspace,
|
||||
createAgent,
|
||||
invokePluginRpc,
|
||||
};
|
||||
}
|
||||
|
||||
function borrowFromAppProvider(paseo: PaseoApi): PaseoApi {
|
||||
let borrowed: PaseoApi | null = null;
|
||||
function PluginSurface() {
|
||||
borrowed = usePaseo();
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginSurface />
|
||||
</PaseoApiProvider>,
|
||||
);
|
||||
if (!borrowed) throw new Error("Plugin surface did not receive Paseo API");
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
describe("plugin surface host runtime", () => {
|
||||
it("creates a PR worktree and agent through usePaseo on the selected app host", async () => {
|
||||
const selected = clientWithWorkspace("workspace-a");
|
||||
const runtime = createPluginSurfaceRuntime(selected.client, {
|
||||
id: "workspace-plugin",
|
||||
lifetime: new AbortController(),
|
||||
});
|
||||
if (!runtime) throw new Error("Expected selected host runtime");
|
||||
|
||||
const paseo = borrowFromAppProvider(runtime.paseo);
|
||||
const workspace = await paseo.workspaces.create({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/repository",
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||||
},
|
||||
});
|
||||
const agent = await workspace.agents.create({
|
||||
config: { provider: "codex/gpt-5" },
|
||||
prompt: "Review PR #42",
|
||||
});
|
||||
|
||||
expect(workspace.id).toBe("workspace-a");
|
||||
expect(agent.id).toBe("agent-workspace-a");
|
||||
expect(selected.createWorkspace).toHaveBeenCalledOnce();
|
||||
expect(selected.createAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("switches all plugin calls when the selected host changes", async () => {
|
||||
const hostA = clientWithWorkspace("workspace-a");
|
||||
const hostB = clientWithWorkspace("workspace-b");
|
||||
const first = createPluginSurfaceRuntime(hostA.client, {
|
||||
id: "same-plugin",
|
||||
lifetime: new AbortController(),
|
||||
});
|
||||
const second = createPluginSurfaceRuntime(hostB.client, {
|
||||
id: "same-plugin",
|
||||
lifetime: new AbortController(),
|
||||
});
|
||||
if (!first || !second) throw new Error("Expected online host runtimes");
|
||||
|
||||
await first.invoke("host", {});
|
||||
await borrowFromAppProvider(second.paseo).workspaces.create({
|
||||
source: { kind: "directory", path: "/tmp/workspace-b" },
|
||||
});
|
||||
|
||||
expect(hostA.invokePluginRpc).toHaveBeenCalledWith("same-plugin", "host", {});
|
||||
expect(hostA.createWorkspace).not.toHaveBeenCalled();
|
||||
expect(hostB.createWorkspace).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an offline selected host unavailable instead of borrowing another host", () => {
|
||||
const otherHost = clientWithWorkspace("workspace-online");
|
||||
|
||||
expect(
|
||||
createPluginSurfaceRuntime(null, { id: "same-plugin", lifetime: new AbortController() }),
|
||||
).toBeNull();
|
||||
expect(otherHost.createWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export interface PluginSurfaceRuntime {
|
||||
paseo: PaseoApi;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export function createPluginSurfaceRuntime(
|
||||
client: DaemonClient | null,
|
||||
plugin: Pick<InstalledPlugin, "id" | "lifetime">,
|
||||
): PluginSurfaceRuntime | null {
|
||||
if (!client || plugin.lifetime.signal.aborted) return null;
|
||||
return {
|
||||
paseo: createPaseoApi(client, { signal: plugin.lifetime.signal }),
|
||||
invoke: (method, input) => client.invokePluginRpc(plugin.id, method, input),
|
||||
};
|
||||
}
|
||||
|
||||
/** A mounted surface owns its API; creating a React element creates no server demand. */
|
||||
export function usePluginSurfaceRuntime(
|
||||
client: DaemonClient | null,
|
||||
plugin: InstalledPlugin | null | undefined,
|
||||
): PluginSurfaceRuntime | null {
|
||||
const [mounted, setMounted] = useState<{
|
||||
client: DaemonClient;
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (!client || !plugin) return;
|
||||
const runtime = createPluginSurfaceRuntime(client, plugin);
|
||||
if (!runtime) return;
|
||||
setMounted({ client, plugin, runtime });
|
||||
return () => {
|
||||
void runtime.paseo
|
||||
.dispose()
|
||||
.catch((error) => console.warn(`[Plugins] Surface cleanup failed for ${plugin.id}`, error));
|
||||
};
|
||||
}, [client, plugin]);
|
||||
return mounted?.client === client && mounted.plugin === plugin ? mounted.runtime : null;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { ChevronDown, X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { Platform, Pressable, Text, View } from "react-native";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, type ComponentType } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { HostSwitcher } from "@/components/hosts/host-switcher";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -18,16 +18,21 @@ import { usePluginHostNavigation } from "./host-navigation";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import { rememberPluginContributionHost } from "./contribution-host";
|
||||
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
|
||||
import {
|
||||
legacySidebarItemHostKey,
|
||||
pluginScreensHostKey,
|
||||
rememberPluginContributionHost,
|
||||
} from "./contribution-host";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { PluginRuntimeBoundary } from "./runtime-boundary";
|
||||
import { PluginInstallationProvider } from "./installation-provider";
|
||||
import {
|
||||
getPluginSurfaceContributionServerIds,
|
||||
resolvePluginScreenTitle,
|
||||
resolvePluginSurfaceContribution,
|
||||
type PluginSurfaceContributionIdentity,
|
||||
} from "./surface-contribution";
|
||||
import { resolvePluginPlatform } from "./platform";
|
||||
|
||||
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -35,7 +40,6 @@ const pluginThemeMapping = (theme: Theme) => ({
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
function routeParam(value: string | string[] | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
@@ -55,44 +59,40 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
|
||||
function SurfaceRenderer({
|
||||
Surface,
|
||||
client,
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
params,
|
||||
theme,
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
client: DaemonClient;
|
||||
Surface: ComponentType<PluginScreenProps>;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
layout: PluginScreenProps["layout"];
|
||||
host: PluginScreenProps["host"];
|
||||
params: PluginScreenParams;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const navigation = usePluginHostNavigation(host.id);
|
||||
return (
|
||||
<PluginRuntimeBoundary plugin={plugin} client={client}>
|
||||
<Surface theme={theme} host={host} layout={layout} navigation={navigation} />
|
||||
</PluginRuntimeBoundary>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<Surface theme={theme} host={host} layout={layout} navigation={navigation} params={params} />
|
||||
</PluginInstallationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
|
||||
|
||||
function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginHostSwitcher({
|
||||
serverId,
|
||||
pluginId,
|
||||
identity,
|
||||
params,
|
||||
serverIds,
|
||||
}: {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
identity: PluginSurfaceContributionIdentity;
|
||||
params: PluginScreenParams;
|
||||
serverIds: string[];
|
||||
}) {
|
||||
const allHosts = useHosts();
|
||||
@@ -100,60 +100,48 @@ function PluginHostSwitcher({
|
||||
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
|
||||
[allHosts, serverIds],
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<View | null>(null);
|
||||
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const selectHost = useCallback(
|
||||
(nextServerId: string) => {
|
||||
rememberPluginContributionHost(`${pluginId}/${identity.kind}/${identity.id}`, nextServerId);
|
||||
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity));
|
||||
// A legacy row remembers its own host; a screen's host carries to all the plugin's items.
|
||||
rememberPluginContributionHost(
|
||||
identity.kind === "sidebar"
|
||||
? legacySidebarItemHostKey(pluginId, identity.id)
|
||||
: pluginScreensHostKey(pluginId),
|
||||
nextServerId,
|
||||
);
|
||||
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity, params));
|
||||
},
|
||||
[identity, pluginId],
|
||||
[identity, params, pluginId],
|
||||
);
|
||||
const openPicker = useCallback(() => setOpen(true), []);
|
||||
const show = serverIds.length > 1 && hosts.length > 1;
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
<HostSwitcher
|
||||
hosts={hosts}
|
||||
value={serverId}
|
||||
onSelect={selectHost}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
anchorRef={anchorRef}
|
||||
title="Choose plugin host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Plugin host: ${selectedLabel}`}
|
||||
testID="plugin-host-switcher"
|
||||
onPress={openPicker}
|
||||
style={styles.hostSwitcher}
|
||||
>
|
||||
<Text numberOfLines={1} style={styles.hostSwitcherText}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
accessibilityLabel="Plugin host"
|
||||
testID="plugin-host-switcher"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginSurfaceScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
const routeParams = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
pluginId?: string | string[];
|
||||
contributionKind?: string | string[];
|
||||
contributionId?: string | string[];
|
||||
}>();
|
||||
const serverId = routeParam(params.serverId);
|
||||
const pluginId = routeParam(params.pluginId);
|
||||
const contributionKind = routeParam(params.contributionKind);
|
||||
const contributionId = routeParam(params.contributionId);
|
||||
const serverId = routeParam(routeParams.serverId);
|
||||
const pluginId = routeParam(routeParams.pluginId);
|
||||
const contributionKind = routeParam(routeParams.contributionKind);
|
||||
const contributionId = routeParam(routeParams.contributionId);
|
||||
const paramsKey = JSON.stringify(pluginScreenParamsFromRoute(routeParams));
|
||||
// Keyed by content: the route hands back a new object on every render.
|
||||
const params = useMemo<PluginScreenParams>(() => JSON.parse(paramsKey), [paramsKey]);
|
||||
const identity = useMemo<PluginSurfaceContributionIdentity | null>(() => {
|
||||
if (contributionKind !== "sidebar" && contributionKind !== "surface") return null;
|
||||
return { kind: contributionKind, id: contributionId };
|
||||
@@ -173,13 +161,19 @@ export function PluginSurfaceScreen() {
|
||||
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
|
||||
[identity, installations, pluginId],
|
||||
);
|
||||
const title = sidebarItem?.title ?? surface?.id ?? (pluginId || "Plugin");
|
||||
const title = useMemo(
|
||||
() =>
|
||||
surface
|
||||
? resolvePluginScreenTitle(surface, sidebarItem, params)
|
||||
: (sidebarItem?.title ?? (pluginId || "Plugin")),
|
||||
[params, pluginId, sidebarItem, surface],
|
||||
);
|
||||
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace(`/h/${encodeURIComponent(serverId)}`);
|
||||
}, [serverId]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
|
||||
const headerLeft = useMemo(
|
||||
() => (
|
||||
@@ -189,7 +183,7 @@ export function PluginSurfaceScreen() {
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
</HeaderIconBadge>
|
||||
) : null}
|
||||
<ScreenTitle>{title}</ScreenTitle>
|
||||
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
|
||||
</>
|
||||
),
|
||||
[Icon, title],
|
||||
@@ -202,6 +196,7 @@ export function PluginSurfaceScreen() {
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
identity={identity}
|
||||
params={params}
|
||||
serverIds={contributionServerIds}
|
||||
/>
|
||||
) : null}
|
||||
@@ -217,7 +212,7 @@ export function PluginSurfaceScreen() {
|
||||
</HeaderToggleButton>
|
||||
</>
|
||||
),
|
||||
[close, contributionServerIds, identity, pluginId, serverId],
|
||||
[close, contributionServerIds, identity, params, pluginId, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -232,10 +227,10 @@ export function PluginSurfaceScreen() {
|
||||
>
|
||||
<ThemedSurfaceRenderer
|
||||
Surface={surface.Component}
|
||||
client={client}
|
||||
plugin={plugin}
|
||||
host={host}
|
||||
layout={layout}
|
||||
params={params}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
</SurfaceErrorBoundary>
|
||||
@@ -261,19 +256,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.statusDanger,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hostSwitcher: {
|
||||
maxWidth: 180,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostSwitcherText: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -51,9 +51,12 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -13,10 +13,13 @@ function plugin(input: {
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -13,7 +13,8 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
|
||||
useHosts: () => [{ serverId: "host-1", label: "Local" }],
|
||||
}));
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
vi.mock("@/constants/layout", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/constants/layout")>()),
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
vi.mock("../navigation", () => ({
|
||||
@@ -210,7 +211,7 @@ describe("PluginTimelineItemView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("releases a crashed renderer's observations and recovers a fresh scope in StrictMode", async () => {
|
||||
it("a renderer releases its observation on the plugin's client when it crashes, and remounts in StrictMode", async () => {
|
||||
let receive: Parameters<DaemonTransport["onMessage"]>[0] = () => {};
|
||||
let opened: () => void = () => {};
|
||||
let nextId = 0;
|
||||
@@ -285,11 +286,9 @@ it("releases a crashed renderer's observations and recovers a fresh scope in Str
|
||||
return { default(plugin) {
|
||||
function Card(props) {
|
||||
const paseo = usePaseo();
|
||||
const failed = React.useRef(false);
|
||||
failed.current = props.item.data.label === "explode";
|
||||
React.useEffect(() => {
|
||||
const owner = paseo.observeEvents(["project.update"]);
|
||||
return () => { if (failed.current) throw new Error("plugin cleanup failed"); void owner.release(); };
|
||||
return () => { void owner.release(); };
|
||||
}, [paseo]);
|
||||
if (props.item.data.label === "explode") throw new Error("owned renderer failed");
|
||||
return React.createElement("span", null, props.item.data.label);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { PluginHostProps, PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import React, { type ComponentType, useMemo } from "react";
|
||||
import { Platform, Text } from "react-native";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -10,18 +10,13 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { PluginTimelineStreamItem } from "@/types/stream";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function TimelineItemUnavailable() {
|
||||
return <Text style={styles.unavailable}>Plugin timeline item unavailable.</Text>;
|
||||
}
|
||||
@@ -63,7 +58,7 @@ function PluginTimelineItemBody({
|
||||
const hosts = useHosts();
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
|
||||
if (!plugin || !renderer || !parsed || !client) {
|
||||
@@ -86,11 +81,11 @@ function PluginTimelineItemBody({
|
||||
};
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} resetKey={item.data} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} client={client}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<Component {...props} />
|
||||
</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { PluginRequirements } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
@@ -10,7 +11,8 @@ import type {
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
PluginTimelineTransformerContribution,
|
||||
@@ -18,6 +20,8 @@ import type {
|
||||
PluginWorkspacePanelContribution,
|
||||
} from "@getpaseo/plugin/client";
|
||||
|
||||
export type PluginSidebarSection = "header" | "footer";
|
||||
|
||||
export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & {
|
||||
locations: readonly PluginPanelLocation[];
|
||||
};
|
||||
@@ -25,9 +29,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
|
||||
export interface EvaluatedPlugin {
|
||||
id: string;
|
||||
cleanup: PluginCleanup;
|
||||
surfaces: PluginSurfaceContribution[];
|
||||
surfaces: PluginScreenContribution[];
|
||||
settingsScreens: PluginSettingsScreenContribution[];
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
sidebarItems: Record<PluginSidebarSection, PluginSidebarItemContribution[]>;
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
@@ -39,6 +46,10 @@ export interface EvaluatedPlugin {
|
||||
|
||||
export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
lifetime: AbortController;
|
||||
/** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */
|
||||
paseo: PaseoApi;
|
||||
/** Calls one of the plugin's server RPC methods on its host. */
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
serverId: string;
|
||||
requirements?: PluginRequirements;
|
||||
clientBundle: string;
|
||||
@@ -51,7 +62,8 @@ export type {
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginThemeContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type {
|
||||
PluginAgentPanelProps,
|
||||
PluginHostProps,
|
||||
PluginWorkspacePanelProps,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import { CircleAlert } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -23,20 +19,15 @@ import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { resolvePluginWorkspacePanel } from "./resolution";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
invariant(target.kind === "plugin", "PluginPanel requires plugin target");
|
||||
@@ -57,7 +48,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
const hosts = useHosts();
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const navigation = usePluginHostNavigation(serverId);
|
||||
|
||||
@@ -105,9 +96,9 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
Surface={Surface}
|
||||
key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`}
|
||||
>
|
||||
<PluginRuntimeBoundary plugin={plugin} client={client}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<PluginClientStateProvider source={stateSource}>{panel}</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ function installed(): InstalledPlugin {
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { TFunction } from "i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Blocks,
|
||||
CalendarClock,
|
||||
Gauge,
|
||||
History,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -16,14 +18,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import {
|
||||
builtinSidebarNavLabelKey,
|
||||
builtinSidebarNavShortcutAction,
|
||||
type BuiltinSidebarNavId,
|
||||
type BuiltinSidebarItemId,
|
||||
type SidebarNavItem,
|
||||
type SidebarSection,
|
||||
} from "@/sidebar-nav/model";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -36,13 +39,17 @@ const ThemedArrowDown = withUnistyles(ArrowDown);
|
||||
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
|
||||
const BUILTIN_ICONS: Record<BuiltinSidebarNavId, LucideIcon> = {
|
||||
const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
|
||||
"new-workspace": Plus,
|
||||
history: History,
|
||||
search: Search,
|
||||
schedules: CalendarClock,
|
||||
usage: Gauge,
|
||||
};
|
||||
|
||||
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
|
||||
const PLUGIN_ICON = Blocks;
|
||||
|
||||
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={ICON_SIZE.md} color={color} />;
|
||||
}
|
||||
@@ -50,7 +57,8 @@ function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
const ThemedNavIcon = withUnistyles(NavIcon);
|
||||
|
||||
function navItemIcon(item: SidebarNavItem): LucideIcon {
|
||||
return item.kind === "builtin" ? BUILTIN_ICONS[item.id] : resolvePluginIcon(item.group.icon);
|
||||
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
|
||||
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
|
||||
}
|
||||
|
||||
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
|
||||
@@ -134,15 +142,26 @@ function SidebarNavRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNavSection(): ReactElement {
|
||||
const SECTION_COPY = {
|
||||
header: {
|
||||
title: "settings.appearance.sidebar.header.title",
|
||||
info: "settings.appearance.sidebar.header.description",
|
||||
},
|
||||
footer: {
|
||||
title: "settings.appearance.sidebar.footer.title",
|
||||
info: "settings.appearance.sidebar.footer.description",
|
||||
},
|
||||
} as const satisfies Record<SidebarSection, { title: string; info: string }>;
|
||||
|
||||
function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { items, setVisible, move } = useSidebarNavItems();
|
||||
const { items, setVisible, move } = useSidebarNavItems(section);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("settings.appearance.sidebar.title")}
|
||||
info={t("settings.appearance.sidebar.description")}
|
||||
testID="sidebar-nav-section"
|
||||
title={t(SECTION_COPY[section].title)}
|
||||
info={t(SECTION_COPY[section].info)}
|
||||
testID={`sidebar-nav-section-${section}`}
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
{items.map((item, index) => (
|
||||
@@ -160,6 +179,16 @@ export function SidebarNavSection(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings > Sidebar: one card per section. The footer's Add project row and icon row are fixed and not listed. */
|
||||
export function SidebarNavSection(): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<SidebarItemsCard section="header" />
|
||||
<SidebarItemsCard section="footer" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
gap: theme.spacing[2],
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
|
||||
function group(pluginId: string, contributionId: string): PluginSidebarGroup {
|
||||
return {
|
||||
kind: "item",
|
||||
key: `${pluginId}/sidebar/${contributionId}`,
|
||||
pluginId,
|
||||
contributionId,
|
||||
title: contributionId,
|
||||
icon: "puzzle",
|
||||
targets: [],
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,11 @@ function summarize(items: readonly SidebarNavItem[]): SidebarNavPreference[] {
|
||||
|
||||
describe("resolveSidebarNavItems", () => {
|
||||
it("yields builtins then plugins, all visible, when nothing is stored", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban, notes], preferences: [] });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [kanban, notes],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: "new-workspace", visible: true },
|
||||
@@ -53,6 +57,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
|
||||
it("keeps the stored order and appends newly available items as visible", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [notes, kanban],
|
||||
preferences: [
|
||||
{ key: kanbanKey, visible: false },
|
||||
@@ -73,6 +78,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
|
||||
it("skips keys that are unknown or not currently available", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: notesKey, visible: false },
|
||||
@@ -91,6 +97,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
|
||||
it("lets the first of duplicate keys win", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: "history", visible: false },
|
||||
@@ -109,7 +116,11 @@ describe("resolveSidebarNavItems", () => {
|
||||
|
||||
describe("setSidebarNavItemVisible", () => {
|
||||
it("toggles one item and writes the full resolved order", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "search", visible: false, previous: [] });
|
||||
|
||||
@@ -127,7 +138,11 @@ describe("setSidebarNavItemVisible", () => {
|
||||
{ key: notesKey, visible: false },
|
||||
{ key: "history", visible: true },
|
||||
];
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
|
||||
|
||||
@@ -148,7 +163,11 @@ describe("setSidebarNavItemVisible", () => {
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
];
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
|
||||
|
||||
@@ -159,13 +178,15 @@ describe("setSidebarNavItemVisible", () => {
|
||||
{ key: "search", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
]);
|
||||
expect(summarize(resolveSidebarNavItems({ pluginGroups: [notes], preferences: next }))).toEqual(
|
||||
next,
|
||||
);
|
||||
expect(
|
||||
summarize(
|
||||
resolveSidebarNavItems({ section: "header", pluginGroups: [notes], preferences: next }),
|
||||
),
|
||||
).toEqual(next);
|
||||
});
|
||||
|
||||
it("returns the normalized list unchanged for an unknown key", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: [] });
|
||||
const items = resolveSidebarNavItems({ section: "header", pluginGroups: [], preferences: [] });
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] });
|
||||
|
||||
@@ -174,7 +195,11 @@ describe("setSidebarNavItemVisible", () => {
|
||||
});
|
||||
|
||||
describe("moveSidebarNavItem", () => {
|
||||
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
it("moves an item up", () => {
|
||||
const next = moveSidebarNavItem({ items, key: "search", direction: "up", previous: [] });
|
||||
@@ -241,3 +266,78 @@ describe("builtinSidebarNavShortcutAction", () => {
|
||||
expect(builtinSidebarNavShortcutAction("schedules")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("footer section", () => {
|
||||
const sync = group("sync", "status");
|
||||
const syncKey = pluginSidebarNavKey(sync);
|
||||
|
||||
it("resolves the Usage item first, then plugin rows", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
pluginGroups: [sync],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: "usage", visible: true },
|
||||
{ key: syncKey, visible: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores header built-ins stored in footer preferences", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
pluginGroups: [],
|
||||
preferences: [{ key: "history", visible: false }],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([{ key: "usage", visible: true }]);
|
||||
});
|
||||
|
||||
it("ignores the footer icon buttons, which are fixed and not items", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
pluginGroups: [sync],
|
||||
preferences: [
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
{ key: "help", visible: false },
|
||||
{ key: syncKey, visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: syncKey, visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves and hides footer rows while keeping an unavailable plugin's entry", () => {
|
||||
const notesPreference = { key: notesKey, visible: false };
|
||||
const previous: SidebarNavPreference[] = [notesPreference];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
pluginGroups: [sync],
|
||||
preferences: previous,
|
||||
});
|
||||
|
||||
const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous });
|
||||
const hidden = setSidebarNavItemVisible({
|
||||
items: resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
pluginGroups: [sync],
|
||||
preferences: moved,
|
||||
}),
|
||||
key: "usage",
|
||||
visible: false,
|
||||
previous: moved,
|
||||
});
|
||||
|
||||
expect(hidden).toEqual([
|
||||
notesPreference,
|
||||
{ key: syncKey, visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { PluginSidebarGroup } from "@/plugins/sidebar-groups";
|
||||
import type { PluginSidebarSection } from "@/plugins/types";
|
||||
|
||||
export const BUILTIN_SIDEBAR_NAV_IDS = ["new-workspace", "history", "search", "schedules"] as const;
|
||||
export type BuiltinSidebarNavId = (typeof BUILTIN_SIDEBAR_NAV_IDS)[number];
|
||||
export type SidebarSection = PluginSidebarSection;
|
||||
|
||||
/**
|
||||
* Each section's built-in items in their default order. The footer's Add project row and its
|
||||
* icon row (Hosts, Import session, Help and support, Settings) are fixed and not items.
|
||||
*/
|
||||
export const BUILTIN_SIDEBAR_ITEM_IDS = {
|
||||
header: ["new-workspace", "history", "search", "schedules"],
|
||||
footer: ["usage"],
|
||||
} as const satisfies Record<SidebarSection, readonly string[]>;
|
||||
|
||||
export type BuiltinSidebarItemId<Section extends SidebarSection = SidebarSection> =
|
||||
(typeof BUILTIN_SIDEBAR_ITEM_IDS)[Section][number];
|
||||
export type BuiltinSidebarNavId = BuiltinSidebarItemId<"header">;
|
||||
|
||||
/** Persisted shape. Array order is the display order. */
|
||||
export interface SidebarNavPreference {
|
||||
@@ -9,10 +22,10 @@ export interface SidebarNavPreference {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface BuiltinSidebarNavItem {
|
||||
export interface BuiltinSidebarNavItem<Section extends SidebarSection = SidebarSection> {
|
||||
kind: "builtin";
|
||||
key: BuiltinSidebarNavId;
|
||||
id: BuiltinSidebarNavId;
|
||||
key: BuiltinSidebarItemId<Section>;
|
||||
id: BuiltinSidebarItemId<Section>;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@@ -23,16 +36,19 @@ export interface PluginSidebarNavItem {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem;
|
||||
export type SidebarNavItem<Section extends SidebarSection = SidebarSection> =
|
||||
| BuiltinSidebarNavItem<Section>
|
||||
| PluginSidebarNavItem;
|
||||
|
||||
const BUILTIN_LABEL_KEYS: Record<BuiltinSidebarNavId, string> = {
|
||||
const BUILTIN_LABEL_KEYS: Record<BuiltinSidebarItemId, string> = {
|
||||
"new-workspace": "sidebar.actions.newWorkspace",
|
||||
history: "sidebar.sections.sessions",
|
||||
search: "sidebar.sections.search",
|
||||
schedules: "sidebar.sections.schedules",
|
||||
usage: "sidebar.footer.usage",
|
||||
};
|
||||
|
||||
export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string {
|
||||
export function builtinSidebarNavLabelKey(id: BuiltinSidebarItemId): string {
|
||||
return BUILTIN_LABEL_KEYS[id];
|
||||
}
|
||||
|
||||
@@ -41,14 +57,15 @@ export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string {
|
||||
* Both the sidebar row and the Appearance settings row read the badge from here so the
|
||||
* two never disagree about which shortcut belongs to which item.
|
||||
*/
|
||||
const BUILTIN_SHORTCUT_ACTIONS: Record<BuiltinSidebarNavId, string | null> = {
|
||||
const BUILTIN_SHORTCUT_ACTIONS: Record<BuiltinSidebarItemId, string | null> = {
|
||||
"new-workspace": "new-workspace",
|
||||
history: null,
|
||||
search: "toggle-command-center",
|
||||
schedules: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
export function builtinSidebarNavShortcutAction(id: BuiltinSidebarNavId): string | null {
|
||||
export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): string | null {
|
||||
return BUILTIN_SHORTCUT_ACTIONS[id];
|
||||
}
|
||||
|
||||
@@ -58,18 +75,25 @@ export function pluginSidebarNavKey(
|
||||
return `plugin:${group.pluginId}:${group.contributionId}`;
|
||||
}
|
||||
|
||||
function isBuiltinSidebarNavId(key: string): key is BuiltinSidebarNavId {
|
||||
return (BUILTIN_SIDEBAR_NAV_IDS as readonly string[]).includes(key);
|
||||
function isBuiltinSidebarItemId<Section extends SidebarSection>(
|
||||
section: Section,
|
||||
key: string,
|
||||
): key is BuiltinSidebarItemId<Section> {
|
||||
const ids: readonly string[] = BUILTIN_SIDEBAR_ITEM_IDS[section];
|
||||
return ids.includes(key);
|
||||
}
|
||||
|
||||
export function resolveSidebarNavItems(input: {
|
||||
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
|
||||
section: Section;
|
||||
pluginGroups: readonly PluginSidebarGroup[];
|
||||
preferences: readonly SidebarNavPreference[];
|
||||
}): SidebarNavItem[] {
|
||||
}): SidebarNavItem<Section>[] {
|
||||
const builtinIds: readonly BuiltinSidebarItemId<Section>[] =
|
||||
BUILTIN_SIDEBAR_ITEM_IDS[input.section];
|
||||
const groupsByKey = new Map(
|
||||
input.pluginGroups.map((group) => [pluginSidebarNavKey(group), group] as const),
|
||||
);
|
||||
const items: SidebarNavItem[] = [];
|
||||
const items: SidebarNavItem<Section>[] = [];
|
||||
const placed = new Set<string>();
|
||||
|
||||
for (const preference of input.preferences) {
|
||||
@@ -78,7 +102,7 @@ export function resolveSidebarNavItems(input: {
|
||||
if (group) {
|
||||
placed.add(preference.key);
|
||||
items.push({ kind: "plugin", key: preference.key, group, visible: preference.visible });
|
||||
} else if (isBuiltinSidebarNavId(preference.key)) {
|
||||
} else if (isBuiltinSidebarItemId(input.section, preference.key)) {
|
||||
placed.add(preference.key);
|
||||
items.push({
|
||||
kind: "builtin",
|
||||
@@ -89,7 +113,7 @@ export function resolveSidebarNavItems(input: {
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of BUILTIN_SIDEBAR_NAV_IDS) {
|
||||
for (const id of builtinIds) {
|
||||
if (placed.has(id)) continue;
|
||||
items.push({ kind: "builtin", key: id, id, visible: true });
|
||||
}
|
||||
|
||||
@@ -1,70 +1,74 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { AppSettings } from "@/hooks/use-settings/storage";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { groupPluginSidebarContributions } from "@/plugins/sidebar-groups";
|
||||
import { groupPluginSidebarItems } from "@/plugins/sidebar-groups";
|
||||
import {
|
||||
moveSidebarNavItem,
|
||||
resolveSidebarNavItems,
|
||||
setSidebarNavItemVisible,
|
||||
type SidebarNavItem,
|
||||
type SidebarSection,
|
||||
} from "./model";
|
||||
|
||||
export interface UseSidebarNavItemsReturn {
|
||||
/** Every top-level item in display order, hidden ones included. */
|
||||
items: SidebarNavItem[];
|
||||
const PREFERENCE_FIELDS = {
|
||||
header: "sidebarNavItems",
|
||||
footer: "sidebarFooterItems",
|
||||
} as const satisfies Record<SidebarSection, keyof AppSettings>;
|
||||
|
||||
export interface UseSidebarNavItemsReturn<Section extends SidebarSection> {
|
||||
/** Every item in the section in display order, hidden ones included. */
|
||||
items: SidebarNavItem<Section>[];
|
||||
setVisible: (key: string, visible: boolean) => void;
|
||||
move: (key: string, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
export function useSidebarNavItems(): UseSidebarNavItemsReturn {
|
||||
export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
section: Section,
|
||||
): UseSidebarNavItemsReturn<Section> {
|
||||
const plugins = useInstalledPlugins();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const preferences = settings.sidebarNavItems;
|
||||
const pluginGroups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
|
||||
const field = PREFERENCE_FIELDS[section];
|
||||
const preferences = settings[field];
|
||||
const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
resolveSidebarNavItems({
|
||||
pluginGroups,
|
||||
preferences,
|
||||
}),
|
||||
[pluginGroups, preferences],
|
||||
() => resolveSidebarNavItems({ section, pluginGroups, preferences }),
|
||||
[pluginGroups, preferences, section],
|
||||
);
|
||||
|
||||
const setVisible = useCallback(
|
||||
(key: string, visible: boolean) => {
|
||||
void updateSettings((current) => {
|
||||
const previous = current.sidebarNavItems;
|
||||
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
return {
|
||||
sidebarNavItems: setSidebarNavItemVisible({
|
||||
items: currentItems,
|
||||
key,
|
||||
visible,
|
||||
previous,
|
||||
}),
|
||||
[field]: setSidebarNavItemVisible({ items: currentItems, key, visible, previous }),
|
||||
};
|
||||
});
|
||||
},
|
||||
[pluginGroups, updateSettings],
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
(key: string, direction: "up" | "down") => {
|
||||
void updateSettings((current) => {
|
||||
const previous = current.sidebarNavItems;
|
||||
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
return {
|
||||
sidebarNavItems: moveSidebarNavItem({
|
||||
items: currentItems,
|
||||
key,
|
||||
direction,
|
||||
previous,
|
||||
}),
|
||||
[field]: moveSidebarNavItem({ items: currentItems, key, direction, previous }),
|
||||
};
|
||||
});
|
||||
},
|
||||
[pluginGroups, updateSettings],
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
return { items, setVisible, move };
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
extraMutedIconColorMapping,
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
smallIconButtonChromeFrameSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { formatUsageFreshness, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import type { UsageReport, UsageReportEntry } from "./types";
|
||||
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
|
||||
import { UsageWindowBar } from "./window-bar";
|
||||
|
||||
function statusText(report: UsageReport): string | null {
|
||||
@@ -26,16 +27,18 @@ function statusText(report: UsageReport): string | null {
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
const ThemedRefreshIcon = withUnistyles(RefreshCw);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
export function UsageCard({
|
||||
serverId,
|
||||
entry,
|
||||
display,
|
||||
compact = false,
|
||||
}: {
|
||||
serverId: string;
|
||||
entry: UsageReportEntry;
|
||||
display: UsageDisplay;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -62,13 +65,13 @@ export function UsageCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={containerStyle} testID={`usage-report-${entry.id}`}>
|
||||
<View style={styles.header}>
|
||||
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" size="xs" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
@@ -94,7 +97,7 @@ export function UsageCard({
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<UsageWindowBar key={window.id} window={window} />
|
||||
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<UsageBalanceBar key={balance.id} balance={balance} />
|
||||
@@ -141,6 +144,33 @@ export function UsageCard({
|
||||
);
|
||||
}
|
||||
|
||||
function PinnableWindowBar({
|
||||
entry,
|
||||
window,
|
||||
display,
|
||||
}: {
|
||||
entry: UsageReportEntry;
|
||||
window: UsageWindow;
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
const pin = useMemo(
|
||||
() => ({ sourceId: entry.sourceId, windowId: window.id }),
|
||||
[entry.sourceId, window.id],
|
||||
);
|
||||
const { togglePin } = display;
|
||||
const toggle = useCallback(() => togglePin(pin), [pin, togglePin]);
|
||||
return (
|
||||
<UsageWindowBar
|
||||
window={window}
|
||||
displayAs={display.displayAs}
|
||||
pinned={display.isPinned(pin)}
|
||||
onTogglePin={toggle}
|
||||
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
|
||||
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
|
||||
function UsageRefreshButton({
|
||||
sourceLabel,
|
||||
@@ -156,7 +186,7 @@ function UsageRefreshButton({
|
||||
compact: boolean;
|
||||
}) {
|
||||
const isPending = refreshState === "pending";
|
||||
const iconSize = iconButtonChromeGlyphSize("small", compact);
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
const freshness = useMemo(
|
||||
() => (
|
||||
<UsageFreshness
|
||||
@@ -179,9 +209,9 @@ function UsageRefreshButton({
|
||||
testID="usage-refresh"
|
||||
>
|
||||
{isPending ? (
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
) : (
|
||||
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
);
|
||||
@@ -217,8 +247,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
// The gap is one step under the card padding: window rows add their own vertical padding.
|
||||
containerPadded: {
|
||||
gap: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
@@ -259,8 +290,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
// Window rows carry their own vertical padding, which already separates them.
|
||||
bars: {
|
||||
gap: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { MenuTriggerState } from "@/components/ui/menu";
|
||||
import {
|
||||
isToolbarLabelTriggerHighlighted,
|
||||
toolbarLabelTriggerStyle,
|
||||
toolbarLabelTriggerTextStyle,
|
||||
} from "@/components/ui/toolbar-label-trigger";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsagePill } from "./model";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
|
||||
function pillTriggerStyle(state: MenuTriggerState) {
|
||||
return [toolbarLabelTriggerStyle(state), styles.trigger];
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's plan usage beside the composer controls. Opens the cached usage card;
|
||||
* only the card's Refresh asks the source for a new report.
|
||||
*/
|
||||
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const { pill, entry } = useAgentUsage(serverId, agentId);
|
||||
|
||||
if (!pill || !entry) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={pillTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${pill.sourceLabel} ${usageCopy.planUsage}`}
|
||||
testID="usage-composer-pill"
|
||||
>
|
||||
{(state) => (
|
||||
<UsagePillContent pill={pill} highlighted={isToolbarLabelTriggerHighlighted(state)} />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
offset={8}
|
||||
width={300}
|
||||
testID="usage-composer-popover"
|
||||
sheetTitle={usageCopy.planUsage}
|
||||
>
|
||||
<View style={styles.popover}>
|
||||
<UsageCard serverId={serverId} entry={entry} compact />
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<UsageSourceIcon svg={pill.icon} size={14} />
|
||||
{pill.text ? (
|
||||
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
|
||||
{pill.text}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
},
|
||||
popover: {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HostSwitcher } from "@/components/hosts/host-switcher";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { UsageDisplayToggle } from "./display-toggle";
|
||||
import type { UsageHost } from "./model";
|
||||
import { useHostUsage } from "./queries";
|
||||
import type { UsageView } from "./types";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
/** The hosts to choose between, and which one is shown. */
|
||||
export interface UsageHostSelection {
|
||||
hosts: UsageHost[];
|
||||
serverId: string;
|
||||
onSelect: (serverId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls on the right of every usage title row: the host selector, which also names the
|
||||
* host shown, the used/remaining toggle and Refresh. A host that cannot report usage keeps only
|
||||
* the selector.
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
display,
|
||||
onRefresh,
|
||||
hostSelection,
|
||||
}: {
|
||||
view: UsageView;
|
||||
display: UsageDisplay;
|
||||
onRefresh: () => void;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
return (
|
||||
<View style={styles.controls}>
|
||||
{hostSelection && hostSelection.hosts.length > 0 ? (
|
||||
<HostSwitcher
|
||||
hosts={hostSelection.hosts}
|
||||
value={hostSelection.serverId}
|
||||
onSelect={hostSelection.onSelect}
|
||||
title={usageCopy.host}
|
||||
accessibilityLabel={usageCopy.host}
|
||||
testID="usage-host-switcher"
|
||||
/>
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<>
|
||||
<UsageDisplayToggle display={display} />
|
||||
{/* The Changes panel's refresh: an icon, with the label in its tooltip. */}
|
||||
<ToolbarButton
|
||||
label={busy ? usageCopy.refreshing : usageCopy.refresh}
|
||||
compact={compact}
|
||||
disabled={busy}
|
||||
onPress={onRefresh}
|
||||
testID="usage-refresh-all"
|
||||
>
|
||||
{busy ? (
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
) : (
|
||||
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
controls: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* One host's usage and the title-row controls that go with it, for the Usage screen and the
|
||||
* compact usage sheet.
|
||||
*/
|
||||
export function useHostUsageWithControls(
|
||||
hostSelection: UsageHostSelection,
|
||||
display: UsageDisplay,
|
||||
): { view: UsageView; refresh: () => void; controls: ReactElement } {
|
||||
const { view, refresh } = useHostUsage(hostSelection.serverId);
|
||||
const controls = useMemo(
|
||||
() => (
|
||||
<UsageControls
|
||||
view={view}
|
||||
display={display}
|
||||
onRefresh={refresh}
|
||||
hostSelection={hostSelection}
|
||||
/>
|
||||
),
|
||||
[display, hostSelection, refresh, view],
|
||||
);
|
||||
return { view, refresh, controls };
|
||||
}
|
||||
@@ -11,8 +11,12 @@ export const usageCopy = {
|
||||
empty: "No usage data",
|
||||
noHosts: "No connected hosts",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to this host to see usage",
|
||||
hostUpgradeRequired: "Update the host to see usage",
|
||||
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
|
||||
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
pin: "Pin",
|
||||
host: "Usage host",
|
||||
displayUsed: "Used",
|
||||
displayRemaining: "Remaining",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
|
||||
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
|
||||
];
|
||||
|
||||
/** Switches every usage surface between the share used and the share left. */
|
||||
export function UsageDisplayToggle({ display }: { display: UsageDisplay }) {
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
options={OPTIONS}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
testID="usage-display-toggle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import {
|
||||
isUsagePinned,
|
||||
setUsageDisplayAs,
|
||||
toggleUsagePin,
|
||||
type UsageDisplayAs,
|
||||
type UsagePin,
|
||||
type UsagePreferences,
|
||||
} from "./preferences";
|
||||
|
||||
/** What usage views need from the preferences: how to read percents, and the pins. */
|
||||
export interface UsageDisplay {
|
||||
displayAs: UsageDisplayAs;
|
||||
setDisplayAs: (displayAs: UsageDisplayAs) => void;
|
||||
isPinned: (pin: UsagePin) => boolean;
|
||||
togglePin: (pin: UsagePin) => void;
|
||||
}
|
||||
|
||||
/** The device's usage preferences, and the commands that change them. */
|
||||
export function useUsagePreferences(): { preferences: UsagePreferences; display: UsageDisplay } {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const preferences = settings.usage;
|
||||
const setDisplayAs = useCallback(
|
||||
(displayAs: UsageDisplayAs) => {
|
||||
void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) }));
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
const togglePin = useCallback(
|
||||
(pin: UsagePin) => {
|
||||
void updateSettings((current) => ({ usage: toggleUsagePin(current.usage, pin) }));
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
const display = useMemo<UsageDisplay>(
|
||||
() => ({
|
||||
displayAs: preferences.displayAs,
|
||||
setDisplayAs,
|
||||
isPinned: (pin) => isUsagePinned(preferences, pin),
|
||||
togglePin,
|
||||
}),
|
||||
[preferences, setDisplayAs, togglePin],
|
||||
);
|
||||
return { preferences, display };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageBalanceUnit } from "./types";
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
@@ -9,6 +10,11 @@ export function formatPct(value: number): string {
|
||||
return `${Math.round(clampPct(value))}%`;
|
||||
}
|
||||
|
||||
/** "31%" of the window used, or "69% left" of it. */
|
||||
export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string {
|
||||
return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`;
|
||||
}
|
||||
|
||||
function relativeDuration(iso: string): string | null {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useHostUsage } from "./queries";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
/** A host's usage reports, for its settings page. */
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
const { display } = useUsagePreferences();
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
title={usageCopy.planUsage}
|
||||
view={view}
|
||||
display={display}
|
||||
onRefresh={refresh}
|
||||
testID="usage-card"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { resolveUsageHostId, resolveUsageScreenHostId, type UsageHost } from "./model";
|
||||
import { useUsageHosts } from "./queries";
|
||||
|
||||
/** The active workspace's host; off a workspace route, the last workspace visited. */
|
||||
function useActiveServerId(): string | null {
|
||||
const active = useActiveWorkspaceSelection();
|
||||
const last = useLastWorkspaceSelection();
|
||||
return active?.serverId ?? last?.serverId ?? null;
|
||||
}
|
||||
|
||||
/** The host the sidebar Usage row reads, or null when none reports usage. */
|
||||
export function useUsageHostId(): string | null {
|
||||
return resolveUsageHostId(useActiveServerId(), useUsageHosts());
|
||||
}
|
||||
|
||||
/**
|
||||
* The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. The pick
|
||||
* is the view's state: it resets when the view unmounts.
|
||||
*/
|
||||
export function useUsageHostSelection(): {
|
||||
serverId: string | null;
|
||||
connectedHosts: UsageHost[];
|
||||
select: (serverId: string) => void;
|
||||
} {
|
||||
const hosts = useUsageHosts();
|
||||
const activeServerId = useActiveServerId();
|
||||
const [selectedServerId, select] = useState<string | null>(null);
|
||||
const connectedHosts = useMemo(() => hosts.filter((host) => host.isConnected), [hosts]);
|
||||
return {
|
||||
serverId: resolveUsageScreenHostId({ selectedServerId, activeServerId, hosts }),
|
||||
connectedHosts,
|
||||
select,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { UsageComposerPill } from "./composer-pill";
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export { UsageSidebarItem } from "./sidebar-item";
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { Fragment } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { UsageCard } from "./card";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** One card per report (source + account). */
|
||||
export function UsageList({
|
||||
serverId,
|
||||
reports,
|
||||
display,
|
||||
}: {
|
||||
serverId: string;
|
||||
reports: UsageReportEntry[];
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{reports.map((entry, index) => (
|
||||
<Fragment key={entry.id}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<UsageCard serverId={serverId} entry={entry} />
|
||||
</Fragment>
|
||||
<View style={styles.list}>
|
||||
{reports.map((entry) => (
|
||||
<View key={entry.id} style={settingsStyles.card}>
|
||||
<UsageCard serverId={serverId} entry={entry} display={display} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
list: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayPercent,
|
||||
formatUsageFreshness,
|
||||
groupUsageByHost,
|
||||
replaceReport,
|
||||
headlineWindow,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageHostId,
|
||||
resolveUsageScreenHostId,
|
||||
resolveUsageView,
|
||||
usageWindowRowLabel,
|
||||
type UsageHost,
|
||||
type UsageQueryState,
|
||||
} from "./model";
|
||||
import type { UsageReportEntry, UsageWindow } from "./types";
|
||||
@@ -36,85 +38,31 @@ function ready(data: UsageReportEntry[]): UsageQueryState {
|
||||
return { data, error: null, isFetching: false };
|
||||
}
|
||||
|
||||
describe("resolveUsagePill", () => {
|
||||
it("shows the source icon and the headline window percent", () => {
|
||||
const pill = resolveUsagePill({
|
||||
supportsUsage: true,
|
||||
entry: entry({
|
||||
icon: "<svg/>",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 12 },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 64.6, headline: true },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(pill).toEqual({ icon: "<svg/>", sourceLabel: "Fixture source", text: "65%" });
|
||||
});
|
||||
|
||||
it("derives the headline percent from remaining percent", () => {
|
||||
const pill = resolveUsagePill({
|
||||
supportsUsage: true,
|
||||
entry: entry({
|
||||
windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(pill?.text).toBe("70%");
|
||||
});
|
||||
|
||||
it("shows the plan label when no window is the headline", () => {
|
||||
const pill = resolveUsagePill({
|
||||
supportsUsage: true,
|
||||
entry: entry({
|
||||
planLabel: "Pro",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 40 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" });
|
||||
});
|
||||
|
||||
it("shows only the icon with neither a headline window nor a plan label", () => {
|
||||
const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) });
|
||||
|
||||
expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null });
|
||||
});
|
||||
|
||||
it("is hidden when the agent has no usage report", () => {
|
||||
expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("is hidden on a host without usage sources, whatever data is cached", () => {
|
||||
const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] });
|
||||
|
||||
expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("headlineWindow", () => {
|
||||
it("does not fall back to the first window", () => {
|
||||
expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUsageView", () => {
|
||||
it("asks for a host update when the host lacks usage sources", () => {
|
||||
expect(
|
||||
resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }),
|
||||
).toEqual({ kind: "unavailable", message: "Update the host to see usage" });
|
||||
resolveUsageView({
|
||||
hostLabel: "Laptop",
|
||||
isConnected: true,
|
||||
supportsUsage: false,
|
||||
query: ready([entry({})]),
|
||||
}),
|
||||
).toEqual({ kind: "unavailable", message: "Update Laptop to see usage" });
|
||||
});
|
||||
|
||||
it("asks for a connection before anything else", () => {
|
||||
expect(
|
||||
resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }),
|
||||
).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" });
|
||||
resolveUsageView({
|
||||
hostLabel: "Laptop",
|
||||
isConnected: false,
|
||||
supportsUsage: false,
|
||||
query: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" });
|
||||
});
|
||||
|
||||
it("moves from loading to ready to error", () => {
|
||||
const base = { isConnected: true, supportsUsage: true };
|
||||
const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true };
|
||||
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
|
||||
expect(
|
||||
resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }),
|
||||
@@ -128,49 +76,95 @@ describe("resolveUsageView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupUsageByHost", () => {
|
||||
it("groups reports under each connected host in host order", () => {
|
||||
const first = entry({ sourceId: "one" });
|
||||
const second = entry({ sourceId: "two" });
|
||||
const groups = groupUsageByHost(
|
||||
[
|
||||
{ serverId: "b", label: "Beta", isConnected: true, supportsUsage: true },
|
||||
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
|
||||
{ serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true },
|
||||
],
|
||||
new Map([
|
||||
["a", ready([first])],
|
||||
["b", ready([second, first])],
|
||||
]),
|
||||
);
|
||||
const hosts: UsageHost[] = [
|
||||
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
|
||||
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
|
||||
{ serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true },
|
||||
{ serverId: "b", label: "Beta", isConnected: true, supportsUsage: true },
|
||||
];
|
||||
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
serverId: "b",
|
||||
label: "Beta",
|
||||
view: { kind: "ready", reports: [second, first], isRefreshing: false },
|
||||
},
|
||||
{
|
||||
serverId: "a",
|
||||
label: "Alpha",
|
||||
view: { kind: "ready", reports: [first], isRefreshing: false },
|
||||
},
|
||||
]);
|
||||
describe("resolveUsageHostId", () => {
|
||||
it("reads the active workspace's host", () => {
|
||||
expect(resolveUsageHostId("b", hosts)).toBe("b");
|
||||
});
|
||||
|
||||
it("shows the update message in an old host's group", () => {
|
||||
const groups = groupUsageByHost(
|
||||
[
|
||||
{ serverId: "new", label: "New", isConnected: true, supportsUsage: true },
|
||||
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
|
||||
],
|
||||
new Map([["new", ready([])]]),
|
||||
);
|
||||
it("falls back to the first connected host that reports usage", () => {
|
||||
expect(resolveUsageHostId(null, hosts)).toBe("a");
|
||||
expect(resolveUsageHostId("offline", hosts)).toBe("a");
|
||||
expect(resolveUsageHostId("old", hosts)).toBe("a");
|
||||
});
|
||||
|
||||
expect(groups.map((group) => [group.label, group.view])).toEqual([
|
||||
["New", { kind: "ready", reports: [], isRefreshing: false }],
|
||||
["Old", { kind: "unavailable", message: "Update the host to see usage" }],
|
||||
]);
|
||||
it("has no host when none reports usage", () => {
|
||||
expect(resolveUsageHostId("old", hosts.slice(0, 2))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUsageScreenHostId", () => {
|
||||
it("keeps the user's pick while it stays connected", () => {
|
||||
expect(resolveUsageScreenHostId({ selectedServerId: "old", activeServerId: "b", hosts })).toBe(
|
||||
"old",
|
||||
);
|
||||
expect(
|
||||
resolveUsageScreenHostId({ selectedServerId: "offline", activeServerId: "b", hosts }),
|
||||
).toBe("b");
|
||||
});
|
||||
|
||||
it("defaults to the host the sidebar row reads", () => {
|
||||
for (const activeServerId of ["b", null, "old", "offline"]) {
|
||||
expect(resolveUsageScreenHostId({ selectedServerId: null, activeServerId, hosts })).toBe(
|
||||
resolveUsageHostId(activeServerId, hosts),
|
||||
);
|
||||
}
|
||||
expect(resolveUsageScreenHostId({ selectedServerId: null, activeServerId: "old", hosts })).toBe(
|
||||
"a",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the first connected host when none reports usage, so the screen says to update it", () => {
|
||||
expect(
|
||||
resolveUsageScreenHostId({
|
||||
selectedServerId: null,
|
||||
activeServerId: null,
|
||||
hosts: hosts.slice(0, 2),
|
||||
}),
|
||||
).toBe("old");
|
||||
expect(
|
||||
resolveUsageScreenHostId({ selectedServerId: null, activeServerId: null, hosts: [] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usageWindowRowLabel", () => {
|
||||
it("names the window, its percent and its reset after what pinning it pins", () => {
|
||||
expect(
|
||||
usageWindowRowLabel({
|
||||
pinLabel: "Pin Claude Session",
|
||||
value: "31%",
|
||||
trailing: "resets in 2h",
|
||||
}),
|
||||
).toBe("Pin Claude Session, 31% · resets in 2h");
|
||||
expect(usageWindowRowLabel({ pinLabel: "Pin Codex Weekly", value: "—", trailing: null })).toBe(
|
||||
"Pin Codex Weekly, —",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayPercent", () => {
|
||||
const window = (input: Partial<UsageWindow>): UsageWindow => ({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
...input,
|
||||
});
|
||||
|
||||
it("reads the share used or the share left", () => {
|
||||
expect(displayPercent(window({ usedPct: 31 }), "used")).toBe(31);
|
||||
expect(displayPercent(window({ usedPct: 31 }), "remaining")).toBe(69);
|
||||
expect(displayPercent(window({ remainingPct: 40 }), "used")).toBe(60);
|
||||
expect(displayPercent(window({ usedPct: 50, remainingPct: 45 }), "remaining")).toBe(45);
|
||||
});
|
||||
|
||||
it("has no percent when the window reports none", () => {
|
||||
expect(displayPercent(window({}), "remaining")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
|
||||
export function usedPercent(window: UsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
@@ -8,34 +9,26 @@ export function usedPercent(window: UsageWindow): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The window the source marked as its headline. Sources own that choice; there is no fallback. */
|
||||
export function headlineWindow(report: UsageReport): UsageWindow | null {
|
||||
return report.windows.find((window) => window.headline === true) ?? null;
|
||||
/** The percent a window shows under the user's used/remaining preference. */
|
||||
export function displayPercent(window: UsageWindow, displayAs: UsageDisplayAs): number | null {
|
||||
if (displayAs === "used") return usedPercent(window);
|
||||
if (window.remainingPct != null) return window.remainingPct;
|
||||
const used = usedPercent(window);
|
||||
return used == null ? null : 100 - used;
|
||||
}
|
||||
|
||||
export interface UsagePill {
|
||||
icon: string | null;
|
||||
sourceLabel: string;
|
||||
/** Headline percent, else the plan label, else nothing beside the icon. */
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
export function resolveUsagePill(input: {
|
||||
supportsUsage: boolean;
|
||||
entry: UsageReportEntry | null | undefined;
|
||||
}): UsagePill | null {
|
||||
const { supportsUsage, entry } = input;
|
||||
if (!supportsUsage || !entry) return null;
|
||||
const window = headlineWindow(entry.report);
|
||||
const percent = window ? usedPercent(window) : null;
|
||||
return {
|
||||
icon: entry.icon ?? null,
|
||||
sourceLabel: entry.sourceLabel,
|
||||
text:
|
||||
percent != null
|
||||
? `${Math.round(Math.max(0, Math.min(100, percent)))}%`
|
||||
: (entry.report.planLabel ?? null),
|
||||
};
|
||||
/**
|
||||
* A window row's accessible label: what pinning it pins, then what the row shows. The row is a
|
||||
* checkbox, so its checked state says whether it is pinned: "Pin Claude Session, 31% · resets in
|
||||
* 2h", checked.
|
||||
*/
|
||||
export function usageWindowRowLabel(input: {
|
||||
pinLabel: string;
|
||||
value: string;
|
||||
trailing: string | null | undefined;
|
||||
}): string {
|
||||
const summary = input.trailing ? `${input.value} · ${input.trailing}` : input.value;
|
||||
return `${input.pinLabel}, ${summary}`;
|
||||
}
|
||||
|
||||
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
|
||||
@@ -75,13 +68,16 @@ export interface UsageQueryState {
|
||||
}
|
||||
|
||||
export function resolveUsageView(input: {
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
query: UsageQueryState | undefined;
|
||||
}): UsageView {
|
||||
const { isConnected, supportsUsage, query } = input;
|
||||
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable };
|
||||
if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired };
|
||||
const { hostLabel, isConnected, supportsUsage, query } = input;
|
||||
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
|
||||
if (!supportsUsage) {
|
||||
return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
|
||||
}
|
||||
if (query?.data) {
|
||||
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
||||
}
|
||||
@@ -101,26 +97,34 @@ export interface UsageHost {
|
||||
supportsUsage: boolean;
|
||||
}
|
||||
|
||||
export interface UsageHostGroup {
|
||||
serverId: string;
|
||||
label: string;
|
||||
view: UsageView;
|
||||
/**
|
||||
* The host usage shows by default, on the sidebar row and the Usage screen: the active workspace's
|
||||
* host if it reports usage, else the first host that does.
|
||||
*/
|
||||
export function resolveUsageHostId(
|
||||
activeServerId: string | null,
|
||||
hosts: readonly UsageHost[],
|
||||
): string | null {
|
||||
const reporting = hosts.filter((host) => host.isConnected && host.supportsUsage);
|
||||
const active = reporting.find((host) => host.serverId === activeServerId);
|
||||
return (active ?? reporting[0])?.serverId ?? null;
|
||||
}
|
||||
|
||||
/** One group per connected host, in host order. */
|
||||
export function groupUsageByHost(
|
||||
hosts: readonly UsageHost[],
|
||||
queries: ReadonlyMap<string, UsageQueryState>,
|
||||
): UsageHostGroup[] {
|
||||
return hosts
|
||||
.filter((host) => host.isConnected)
|
||||
.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
view: resolveUsageView({
|
||||
isConnected: true,
|
||||
supportsUsage: host.supportsUsage,
|
||||
query: queries.get(host.serverId),
|
||||
}),
|
||||
}));
|
||||
/**
|
||||
* The host the Usage screen shows: the user's pick while it stays connected, else the default
|
||||
* host. With no host reporting usage, the first connected host, so the screen says to update it.
|
||||
*/
|
||||
export function resolveUsageScreenHostId(input: {
|
||||
selectedServerId: string | null;
|
||||
activeServerId: string | null;
|
||||
hosts: readonly UsageHost[];
|
||||
}): string | null {
|
||||
const connected = input.hosts.filter((host) => host.isConnected);
|
||||
const selected = connected.find((host) => host.serverId === input.selectedServerId);
|
||||
return (
|
||||
selected?.serverId ??
|
||||
resolveUsageHostId(input.activeServerId, input.hosts) ??
|
||||
connected[0]?.serverId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UsagePreferences } from "./preferences";
|
||||
import { resolvePinnedUsage } from "./pinned";
|
||||
import type { UsageReportEntry, UsageWindow } from "./types";
|
||||
|
||||
function report(input: {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
account?: string;
|
||||
icon?: string;
|
||||
windows: UsageWindow[];
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:${input.account ?? "default"}`,
|
||||
account: input.account ? { label: input.account } : {},
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
...(input.icon ? { icon: input.icon } : {}),
|
||||
report: { status: "available", windows: input.windows },
|
||||
};
|
||||
}
|
||||
|
||||
const claude = report({
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
icon: "<svg/>",
|
||||
windows: [
|
||||
{ id: "five-hour", label: "5-hour", usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80 },
|
||||
],
|
||||
});
|
||||
const codex = report({
|
||||
sourceId: "codex",
|
||||
sourceLabel: "Codex",
|
||||
windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }],
|
||||
});
|
||||
|
||||
function preferences(
|
||||
pinned: UsagePreferences["pinned"],
|
||||
displayAs: UsagePreferences["displayAs"] = "used",
|
||||
): UsagePreferences {
|
||||
return { displayAs, pinned };
|
||||
}
|
||||
|
||||
describe("resolvePinnedUsage", () => {
|
||||
it("shows pinned windows in pin order, not report order", () => {
|
||||
const items = resolvePinnedUsage(
|
||||
[claude, codex],
|
||||
preferences([
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{ key: "codex:default/weekly", icon: null, label: "Codex Weekly", percentText: "12%" },
|
||||
{
|
||||
key: "claude:default/five-hour",
|
||||
icon: "<svg/>",
|
||||
label: "Claude 5-hour",
|
||||
percentText: "31%",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats the share left when the user reads usage as remaining", () => {
|
||||
const items = resolvePinnedUsage(
|
||||
[claude, codex],
|
||||
preferences(
|
||||
[
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
],
|
||||
"remaining",
|
||||
),
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.percentText)).toEqual(["69% left", "88% left"]);
|
||||
});
|
||||
|
||||
it("leaves out a pinned window that no report has, or that reports no percent", () => {
|
||||
const noPercent = report({
|
||||
sourceId: "opencode",
|
||||
sourceLabel: "OpenCode",
|
||||
windows: [{ id: "monthly", label: "Monthly" }],
|
||||
});
|
||||
const items = resolvePinnedUsage(
|
||||
[claude, noPercent],
|
||||
preferences([
|
||||
{ sourceId: "claude", windowId: "monthly" },
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "opencode", windowId: "monthly" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows a pin once per account of the source", () => {
|
||||
const personal = report({
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
account: "personal",
|
||||
windows: [{ id: "five-hour", label: "5-hour", usedPct: 10 }],
|
||||
});
|
||||
const work = report({
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
account: "work",
|
||||
windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }],
|
||||
});
|
||||
const items = resolvePinnedUsage(
|
||||
[personal, codex, work],
|
||||
preferences([{ sourceId: "claude", windowId: "five-hour" }]),
|
||||
);
|
||||
|
||||
expect(items.map((item) => [item.label, item.percentText])).toEqual([
|
||||
["Claude (personal) 5-hour", "10%"],
|
||||
["Claude (work) 5-hour", "90%"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { formatDisplayPct } from "./format";
|
||||
import { displayPercent } from "./model";
|
||||
import type { UsagePreferences } from "./preferences";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** One pinned window of one account, as the sidebar Usage item shows it. */
|
||||
export interface PinnedUsageWindow {
|
||||
key: string;
|
||||
icon: string | null;
|
||||
/** Source, account when it has one, and window: "Claude (work) 5-hour". */
|
||||
label: string;
|
||||
percentText: string;
|
||||
}
|
||||
|
||||
function describe(entry: UsageReportEntry, windowLabel: string): string {
|
||||
const account = entry.account.label ? ` (${entry.account.label})` : "";
|
||||
return `${entry.sourceLabel}${account} ${windowLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned windows the Usage item shows: pins in pin order, and within a pin one item per account of the source
|
||||
* in report order. A pinned window a report lacks, or reports no percent for, is left out.
|
||||
*/
|
||||
export function resolvePinnedUsage(
|
||||
reports: readonly UsageReportEntry[],
|
||||
preferences: UsagePreferences,
|
||||
): PinnedUsageWindow[] {
|
||||
return preferences.pinned.flatMap((pin) =>
|
||||
reports.flatMap((entry) => {
|
||||
if (entry.sourceId !== pin.sourceId) return [];
|
||||
const window = entry.report.windows.find((candidate) => candidate.id === pin.windowId);
|
||||
const percent = window ? displayPercent(window, preferences.displayAs) : null;
|
||||
if (!window || percent == null) return [];
|
||||
return [
|
||||
{
|
||||
key: `${entry.id}/${window.id}`,
|
||||
icon: entry.icon ?? null,
|
||||
label: describe(entry, window.label),
|
||||
percentText: formatDisplayPct(percent, preferences.displayAs),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_USAGE_PREFERENCES,
|
||||
isUsagePinned,
|
||||
setUsageDisplayAs,
|
||||
toggleUsagePin,
|
||||
} from "./preferences";
|
||||
|
||||
const claudeFiveHour = { sourceId: "claude", windowId: "five-hour" };
|
||||
const codexWeekly = { sourceId: "codex", windowId: "weekly" };
|
||||
|
||||
describe("usage preferences", () => {
|
||||
it("pins windows in the order the user pins them", () => {
|
||||
const pinned = toggleUsagePin(
|
||||
toggleUsagePin(DEFAULT_USAGE_PREFERENCES, codexWeekly),
|
||||
claudeFiveHour,
|
||||
);
|
||||
|
||||
expect(pinned.pinned).toEqual([codexWeekly, claudeFiveHour]);
|
||||
expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "weekly" })).toBe(true);
|
||||
expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "five-hour" })).toBe(false);
|
||||
});
|
||||
|
||||
it("unpins a pinned window and keeps the others in order", () => {
|
||||
const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }];
|
||||
const unpinned = toggleUsagePin({ displayAs: "used", pinned: three }, claudeFiveHour);
|
||||
|
||||
expect(unpinned.pinned).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]);
|
||||
});
|
||||
|
||||
it("switches between used and remaining without touching pins", () => {
|
||||
const preferences = { displayAs: "used" as const, pinned: [codexWeekly] };
|
||||
|
||||
expect(setUsageDisplayAs(preferences, "remaining")).toEqual({
|
||||
displayAs: "remaining",
|
||||
pinned: [codexWeekly],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Whether percentages read as the share used or the share left. */
|
||||
export type UsageDisplayAs = "used" | "remaining";
|
||||
|
||||
/** A window the user pinned to the sidebar Usage item. It matches every account of the source. */
|
||||
export interface UsagePin {
|
||||
sourceId: string;
|
||||
windowId: string;
|
||||
}
|
||||
|
||||
/** Device-local. `pinned` is in display order. */
|
||||
export interface UsagePreferences {
|
||||
displayAs: UsageDisplayAs;
|
||||
pinned: UsagePin[];
|
||||
}
|
||||
|
||||
export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { displayAs: "used", pinned: [] };
|
||||
|
||||
export const UsagePreferencesSchema = z
|
||||
.object({
|
||||
displayAs: z.enum(["used", "remaining"]).catch("used"),
|
||||
pinned: z.array(z.object({ sourceId: z.string(), windowId: z.string() })).catch([]),
|
||||
})
|
||||
.catch(DEFAULT_USAGE_PREFERENCES);
|
||||
|
||||
function samePin(a: UsagePin, b: UsagePin): boolean {
|
||||
return a.sourceId === b.sourceId && a.windowId === b.windowId;
|
||||
}
|
||||
|
||||
export function isUsagePinned(preferences: UsagePreferences, pin: UsagePin): boolean {
|
||||
return preferences.pinned.some((pinned) => samePin(pinned, pin));
|
||||
}
|
||||
|
||||
/** Unpins a pinned window; pins any other at the end, so the Usage item grows to the right. */
|
||||
export function toggleUsagePin(preferences: UsagePreferences, pin: UsagePin): UsagePreferences {
|
||||
const pinned = isUsagePinned(preferences, pin)
|
||||
? preferences.pinned.filter((existing) => !samePin(existing, pin))
|
||||
: [...preferences.pinned, { sourceId: pin.sourceId, windowId: pin.windowId }];
|
||||
return { ...preferences, pinned };
|
||||
}
|
||||
|
||||
export function setUsageDisplayAs(
|
||||
preferences: UsagePreferences,
|
||||
displayAs: UsageDisplayAs,
|
||||
): UsagePreferences {
|
||||
return { ...preferences, displayAs };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useFetchQueries, useFetchQuery } from "@/data/query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
@@ -11,13 +12,10 @@ import {
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
groupUsageByHost,
|
||||
replaceReport,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageView,
|
||||
type UsageHostGroup,
|
||||
type UsagePill,
|
||||
type UsageHost,
|
||||
type UsageQueryState,
|
||||
type UsageRefresh,
|
||||
} from "./model";
|
||||
@@ -31,10 +29,6 @@ function usageReportsQueryKey(serverId: string) {
|
||||
return ["usage", "reports", serverId] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
|
||||
return ["usage", "agent", serverId, agentId, model] as const;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) throw new Error(usageCopy.clientUnavailable);
|
||||
@@ -45,10 +39,6 @@ async function listReports(serverId: string, forceRefresh = false): Promise<Usag
|
||||
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
|
||||
}
|
||||
|
||||
function reportQueryKey(serverId: string, reportId: string) {
|
||||
return ["usage", "report", serverId, reportId] as const;
|
||||
}
|
||||
|
||||
async function getReport(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
@@ -61,18 +51,13 @@ async function getReport(
|
||||
}
|
||||
|
||||
function supportsUsage(session: SessionState | undefined): boolean {
|
||||
return session?.serverInfo?.features?.usageSources === true;
|
||||
return supportsUsageReports(session?.serverInfo?.features);
|
||||
}
|
||||
|
||||
async function refreshReports(queryClient: QueryClient, serverId: string): Promise<void> {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: usageReportsQueryKey(serverId),
|
||||
queryFn: async () => {
|
||||
const reports = await listReports(serverId, true);
|
||||
for (const report of reports)
|
||||
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
|
||||
return reports;
|
||||
},
|
||||
queryFn: () => listReports(serverId, true),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -92,12 +77,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
const query = useFetchQuery({
|
||||
queryKey: usageReportsQueryKey(serverId),
|
||||
queryFn: async () => {
|
||||
const reports = await listReports(serverId);
|
||||
for (const report of reports)
|
||||
queryClient.setQueryData(reportQueryKey(serverId, report.id), report);
|
||||
return reports;
|
||||
},
|
||||
queryFn: () => listReports(serverId),
|
||||
enabled: isConnected && isSupported,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
@@ -105,7 +85,9 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
const refresh = useCallback(() => {
|
||||
void refreshReports(queryClient, serverId).catch(() => undefined);
|
||||
}, [queryClient, serverId]);
|
||||
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const view = resolveUsageView({
|
||||
hostLabel,
|
||||
isConnected,
|
||||
supportsUsage: isSupported,
|
||||
query: toQueryState(query),
|
||||
@@ -113,19 +95,15 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
return { view, refresh };
|
||||
}
|
||||
|
||||
/** Usage reports for every connected host, grouped by host. */
|
||||
export function useUsageByHost(): {
|
||||
groups: UsageHostGroup[];
|
||||
refresh: (serverId: string) => void;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
/** Every host with whether it is connected and reports usage, in host order. */
|
||||
export function useUsageHosts(): UsageHost[] {
|
||||
const hosts = useHosts();
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
||||
const supportedServerIds = useSessionStore(
|
||||
useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))),
|
||||
);
|
||||
const usageHosts = useMemo(
|
||||
return useMemo(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
@@ -135,70 +113,12 @@ export function useUsageByHost(): {
|
||||
})),
|
||||
[connectionStatuses, hosts, supportedServerIds],
|
||||
);
|
||||
const results = useFetchQueries<UsageReportEntry[]>(
|
||||
usageHosts.map((host) => ({
|
||||
queryKey: usageReportsQueryKey(host.serverId),
|
||||
queryFn: () => listReports(host.serverId),
|
||||
enabled: host.isConnected && host.supportsUsage,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
})),
|
||||
);
|
||||
const groups = groupUsageByHost(
|
||||
usageHosts,
|
||||
new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])),
|
||||
);
|
||||
const refresh = useCallback(
|
||||
(serverId: string) => {
|
||||
void refreshReports(queryClient, serverId).catch(() => undefined);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
return { groups, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* The usage report for the account an agent is spending. The daemon resolves the
|
||||
* report ID, which is re-resolved when the model changes (a new key) and when a
|
||||
* turn completes (the query is paused while the agent runs, and its data is always
|
||||
* stale, so resuming refetches). The report itself is read from the shared cache.
|
||||
*/
|
||||
export function useAgentUsage(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): { pill: UsagePill | null; entry: UsageReportEntry | null } {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
const { model, isRunning } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
return { model: agent?.model ?? null, isRunning: agent?.status === "running" };
|
||||
}),
|
||||
);
|
||||
const identity = useFetchQuery({
|
||||
queryKey: agentUsageQueryKey(serverId, agentId, model),
|
||||
queryFn: async () =>
|
||||
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
|
||||
enabled: isConnected && isSupported && !isRunning,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const reportId = identity.data ?? null;
|
||||
const query = useFetchQuery({
|
||||
queryKey: reportQueryKey(serverId, reportId ?? "none"),
|
||||
queryFn: () => getReport(serverId, reportId!),
|
||||
enabled: isConnected && isSupported && reportId !== null,
|
||||
dataShape: "value",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
const entry = isSupported ? (query.data ?? null) : null;
|
||||
return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the source to fetch one report, and only that report. The result replaces
|
||||
* the report wherever it is cached — its own entry and its host's list — so every
|
||||
* surface showing it moves together; until then the previous report stays on screen.
|
||||
* Forces the source to fetch one report, and only that report. The result replaces the report
|
||||
* in its host's list, so every surface showing it moves together; until then the previous report
|
||||
* stays on screen.
|
||||
*/
|
||||
export function useReportRefresh(
|
||||
serverId: string,
|
||||
@@ -208,7 +128,6 @@ export function useReportRefresh(
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueryData(reportQueryKey(serverId, reportId), report);
|
||||
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
|
||||
reports ? replaceReport(reports, reportId, report) : reports,
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user