diff --git a/docs/glossary.md b/docs/glossary.md index 4e0111475c..03b65db022 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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: `:`. 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`). diff --git a/docs/plugins.md b/docs/plugins.md index 370d3a2bdc..cf08b2a62a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 ( + 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/`) shows the +item's title and icon instead, as before screens had titles; the `addSurface` alias titles its +screen with the id. A `/sidebar/` 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//sidebar/` on the host it + remembers under its own key. It shares header ids and the `plugin::` 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 diff --git a/docs/providers.md b/docs/providers.md index 6ea928f3c9..fa608ba7f0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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/-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). diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts new file mode 100644 index 0000000000..24b90bfe4e --- /dev/null +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -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 { + const released = new Set(); + page.on("websocket", (socket) => + socket.on("framereceived", ({ payload }) => collectRelease(released, payload)), + ); + return released; +} + +function collectRelease(released: Set, 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, subscriptionId: string) { + await expect.poll(() => released.has(subscriptionId)).toBe(true); +} + +function readAlertsObservation(page: Page): Promise { + 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 { + 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; + + 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); + }); + }); +}); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index e327bbd0ca..29a6807c99 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -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; + + 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]); + }); + }); +}); diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts deleted file mode 100644 index 42df720db7..0000000000 --- a/packages/app/e2e/browser/usage-composer-pill.spec.ts +++ /dev/null @@ -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: '', - 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(); - } - }); -}); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 70d2ae10b8..37e0ae26aa 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -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 { + 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); }); }); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 623bc51e67..d99b93fdd2 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -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); }); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts new file mode 100644 index 0000000000..4fdd06d62b --- /dev/null +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 15e5c8bc37..7404173fc8 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -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({}) }); diff --git a/packages/app/e2e/support/helpers/plugin-sidebar-items.ts b/packages/app/e2e/support/helpers/plugin-sidebar-items.ts new file mode 100644 index 0000000000..0722bd8317 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-sidebar-items.ts @@ -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 Deploys screen body; +} + +function RefreshButton({ theme, onPress }) { + return ( + + Refresh + + ); +} + +function DeploysItem({ theme, currentScreen, openScreen }) { + const [refreshes, setRefreshes] = React.useState(0); + return ( + openScreen({ screenId: "deploys" })} + trailing={ setRefreshes((count) => count + 1)} />} + /> + ); +} + +function SyncDetails({ theme, layout, close, openScreen }) { + return ( + + Sync details + Presentation: {layout.compact ? "compact" : "wide"} + openScreen({ screenId: "deploys" })}>Open deploys + Done + + ); +} + +function SyncItem({ openPopover }) { + return 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 ( + + {"Bot screen: " + params.botId} + {"serverId param: " + (params.serverId ?? "none")} + + ); +} + +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 ( + + {bot.name + " options"} + Done + + ); + }; + return botMenus[bot.id]; +} + +function MoreButton({ theme, bot, onPress }) { + return ( + + More + + ); +} + +function BotStatus({ theme, close }) { + return ( + + Bot status details + Done + + ); +} + +function BotsItem({ theme, currentScreen, openScreen, openPopover }) { + return ( + <> + {BOTS.map((bot) => ( + openScreen({ screenId: "bot", params: { botId: bot.id } })} + trailing={ openPopover(botMenu(bot))} />} + /> + ))} + + 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 {}} />; +} + +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 Legacy screen body; +} + +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 { + 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 { + 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 { + 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!; +} diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b5f1a77683..f57ee32497 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -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 { + 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 { 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 { @@ -125,9 +140,9 @@ export async function expectStoredSidebarNav( .toEqual(expected); } -async function expectVerticalOrder( - keys: SidebarNavKey[], - locate: (key: SidebarNavKey) => Locator, +async function expectVerticalOrder( + keys: Key[], + locate: (key: Key) => Locator, subject: string, ): Promise { 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 { + 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 { + 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 { + 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 { + await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows"); +} + +export async function expectFooterItemHidden(page: Page, key: string): Promise { + 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 { + 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; + } +} diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 6a43d0fe30..339475b8e6 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -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; - waitForAgentRequests(count: number): Promise; } 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)>; - /** Successive agent report IDs; the last one repeats. */ - agentReportIds?: Array; - /** 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 | return envelope.message as Record; } -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 }; @@ -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 { 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), }; } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts new file mode 100644 index 0000000000..342045cd1d --- /dev/null +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -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 { + await expect(page).toHaveURL(/\/usage$/); +} + +export async function expectPinnedUsage(page: Page, percents: string[]): Promise { + 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 { + 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 { + 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 { + await page.getByRole("button", { name: "Back", exact: true }).first().click(); + await expect(page).not.toHaveURL(/\/usage$/); +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 5e625a9028..54160ebcb9 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -62,6 +62,8 @@ function installProbe( clientBundle, lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, }; } diff --git a/packages/app/src/components/hosts/host-switcher.tsx b/packages/app/src/components/hosts/host-switcher.tsx new file mode 100644 index 0000000000..9172ab3e5d --- /dev/null +++ b/packages/app/src/components/hosts/host-switcher.tsx @@ -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(null); + const selectedLabel = hosts.find((host) => host.serverId === value)?.label ?? value; + const openPicker = useCallback(() => setOpen(true), []); + return ( + + + + + {selectedLabel} + + + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4902a3ae93..b1de36c8ea 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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 ( - - ); -} - 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} /> + - void }) { + const { items } = useSidebarNavItems("footer"); + const rowsRef = useRef(null); + const visibleItems = items.filter((item) => item.visible); + if (visibleItems.length === 0) return null; + return ( + + {visibleItems.map((item) => + item.kind === "plugin" ? ( + + ) : ( + + ), + )} + + ); +} + function MobileSidebar({ active, theme, @@ -634,6 +641,7 @@ function MobileSidebar({ labels={labels} handleAddHost={handleAddHost} handleOpenHostSettings={handleOpenHostSettings} + onBeforeNavigate={closeSidebar} /> @@ -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: { diff --git a/packages/app/src/components/page-layout/index.tsx b/packages/app/src/components/page-layout/index.tsx index f210369184..e0c46b695a 100644 --- a/packages/app/src/components/page-layout/index.tsx +++ b/packages/app/src/components/page-layout/index.tsx @@ -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 ( - {isCompact ? : } + {isCompact ? ( + + ) : ( + + )} {showTitle ? ( - + {title} + {actions} ) : 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, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index e30604ac9a..f59672d388 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -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; } 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 ( - <> - - - {shortcutKeys && Boolean(state.hovered) ? ( - - ) : null} - - ); - }, - [ThemedIcon, isActive, label, shortcutKeys, variant], - ); + let right = trailing ?? null; + if (right === null && shortcutKeys && isHovered) { + right = ; + } return ( - - + - {renderChildren} - + + {ThemedIcon ? ( + + ) : ( + + )} + {label} + + {right === null ? null : ( + + {right} + + )} + ); } -function SidebarHeaderRowLabel({ - label, - isHighlighted, -}: { - label: string; - isHighlighted: boolean; -}) { - const labelStyle = useMemo( - () => [styles.label, isHighlighted && styles.labelHighlighted], - [isHighlighted], - ); - return {label}; -} +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; + } +} diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 258dc19468..425280207e 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -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(null); if (visibleItems.length === 0) return null; return ( - + {visibleItems.map((item) => { if (item.kind === "plugin") { return ( - ); diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx new file mode 100644 index 0000000000..ec13605d08 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -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 ( + + {children} + + ); +} + +function assignRef(ref: RefObject, 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) => 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) => 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; + +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 ( + + {children} + + ); +} diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 18cdb25dea..3a2729ad8e 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -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 ? : null} + {sheetTitle ? ( + + ) : null} {children} )} @@ -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({ {title} + {trailing ? {trailing} : null} ); } @@ -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, diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index f2330e1b70..c1071ac882 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -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", diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 28d23ae214..c46e2ac15a 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ? : null} - - ), - [agentId, contextWindowMeter, hasAgent, serverId], + () => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}, + [contextWindowMeter, hasAgent], ); const hasGithubAttachment = useMemo( diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index df738d7247..3797d76e8b 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -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({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 7171505492..a954074e8b 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -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"]) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c2a1475776..658c1981df 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "نقل لأسفل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 28600d410c..80a137ccf3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7b9cf848df..74ac40906b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 15b8cc9944..d5b811f49c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6ccbb538b5..b674fec325 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "下に移動", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 76dfb606ad..5f2aa560fd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "아래로 이동", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0c747106c6..bf3704bf63 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7e3fe4f436..ed8521e540 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Переместить вниз", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c54dbae802..d01d7f5130 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "下移", }, diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 9f2329df09..b61eae7c1d 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -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( diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 028114c862..4a77a81311 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -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: [], diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index b737b3ebf0..7eae9a046d 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -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>; - state: ReturnType; + environment: PluginEnvironment; toast: ReturnType; } @@ -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 ( - - - {children} - - + {children} ); } @@ -184,9 +175,9 @@ function ButtonBody({ if (behavior.kind === "popover") { const Content = behavior.Content; return ( - + - + ); } if (behavior.kind !== "menu") return null; @@ -336,19 +327,15 @@ function ButtonControl({ view }: { view: ButtonView }) { {expanded ? ( - - + ) : null} ); @@ -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 ( - {menuContent.rows} - + ); } @@ -665,5 +651,4 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, overflow: "hidden", }, - content: { padding: theme.spacing[3], gap: theme.spacing[2] }, })); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 6e7b13501b..68534a0579 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -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>; state: ReturnType; 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, diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index 57d205a7b9..9ab66ac195 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -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]); } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 89313f907c..a2544bd034 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -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, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 9d40ee57e2..be4cb8068a 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -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({ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index cd85f9bff6..faf3b9215f 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -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, diff --git a/packages/app/src/plugins/contribution-host.ts b/packages/app/src/plugins/contribution-host.ts index cbead73be5..9614594802 100644 --- a/packages/app/src/plugins/contribution-host.ts +++ b/packages/app/src/plugins/contribution-host.ts @@ -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}`; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 94f87d93ed..310ef5f7b9 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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; diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index b58f82b372..ecb8f89084 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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 = { surfaces: [], settingsScreens: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -102,7 +122,10 @@ export function runPluginClientBundle( }; const surfaceIds = new Set(); const settingsScreenIds = new Set(); - const sidebarItemIds = new Set(); + const sidebarItemIds: Record> = { + header: new Set(), + footer: new Set(), + }; const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -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) { - 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) { + 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, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx new file mode 100644 index 0000000000..949bc7480f --- /dev/null +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -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) => Promise) | null = null; + function Surface({ name }: { name: string }) { + received.push(usePaseo()); + rpc = useRpc(status); + return {name}; + } + + const markup = ["Screen", "Sidebar item", "Popover"] + .map((name) => + renderToStaticMarkup( + + + , + ), + ) + .join(""); + + expect(markup).toBe("ScreenSidebar itemPopover"); + 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"]); +}); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx new file mode 100644 index 0000000000..40d06ef035 --- /dev/null +++ b/packages/app/src/plugins/installation-provider.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index 17e277504c..c1a50a98fc 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -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"); diff --git a/packages/app/src/plugins/platform.ts b/packages/app/src/plugins/platform.ts new file mode 100644 index 0000000000..816095e255 --- /dev/null +++ b/packages/app/src/plugins/platform.ts @@ -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"; +} diff --git a/packages/app/src/plugins/popover.tsx b/packages/app/src/plugins/popover.tsx new file mode 100644 index 0000000000..f9680ebb29 --- /dev/null +++ b/packages/app/src/plugins/popover.tsx @@ -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; + toast: ReturnType; +} + +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 ( + + + {children} + + + ); +} + +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 ; +} + +export function PluginPopoverContent({ children }: { children: ReactNode }) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + content: { padding: theme.spacing[3], gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index 75ab40584c..1d7a72011f 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -10,3 +10,5 @@ export { } from "@/components/settings"; export { ExternalLink } from "@/components/ui/external-link"; + +export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit"; diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 526ce1a809..445269c87e 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -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(); + 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(); 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([]); }); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 8f096aeac9..055fe207cd 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -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() {} }), diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 2894784fc7..eed76a530f 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -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>[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)); diff --git a/packages/app/src/plugins/routes.test.ts b/packages/app/src/plugins/routes.test.ts index 18c6bfb1a0..b05b55da6b 100644 --- a/packages/app/src/plugins/routes.test.ts +++ b/packages/app/src/plugins/routes.test.ts @@ -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 { + // 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" }); + }); +}); diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts index d4fbf2ebef..3c51fe4a3b 100644 --- a/packages/app/src/plugins/routes.ts +++ b/packages/app/src/plugins/routes.ts @@ -1,16 +1,58 @@ +import type { PluginScreenParams } from "@getpaseo/plugin/client"; import type { PluginSurfaceContributionIdentity } from "./surface-contribution"; type PluginSurfaceRoute = `/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( serverId: string, pluginId: string, identity: Identity, + params: PluginScreenParams = {}, ): PluginSurfaceRoute { - 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; +} + +/** 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>, +): 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; + } +} diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx deleted file mode 100644 index 7ae51af2b9..0000000000 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ /dev/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 ( - - - {children} - - - ); -} diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 11b5c199df..0fda205671 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -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} > - + - + ); } const ThemedSettingsContent = withUnistyles(SettingsContent); +// Settings routes carry no screen params. const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); export function PluginSettingsContent({ onBackToPlugins, diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 7a38bb8eb5..314e5fe1e7 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -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"]); + }); }); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index 05c868f283..c764a53dcc 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -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(); 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: 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] + ); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx deleted file mode 100644 index 97c0c6fa6d..0000000000 --- a/packages/app/src/plugins/sidebar-items.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/packages/app/src/plugins/sidebar-items/frame.ts b/packages/app/src/plugins/sidebar-items/frame.ts new file mode 100644 index 0000000000..f5f42e5d56 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/frame.ts @@ -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(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; +} diff --git a/packages/app/src/plugins/sidebar-items/index.tsx b/packages/app/src/plugins/sidebar-items/index.tsx new file mode 100644 index 0000000000..11657c5d35 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/index.tsx @@ -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; + +const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +type PluginItemGroup = Extract; + +function renderNothing() { + return null; +} + +interface PluginSidebarItemEntryProps { + group: PluginSidebarGroup; + section: PluginSidebarSection; + /** Anchors a popover opened by an item that renders no kit component. */ + fallbackAnchorRef: RefObject; + onBeforeNavigate?: () => void; +} + +type PluginSidebarItemHostProps = Omit & { + 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 ; + } + return ; +} + +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(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const environment = useMemo( + () => + 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 ( + + + + + + + + ); +} + +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; + 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( + () => ({ + 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( + () => ({ + 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( + () => ({ ...hostProps, currentScreen, openScreen, openPopover }), + [currentScreen, hostProps, openPopover, openScreen], + ); + const Item = item.Component; + const Popover = popover; + return ( + + + {Popover ? ( + + + + + + + + + + ) : null} + + ); +} diff --git a/packages/app/src/plugins/sidebar-items/kit.tsx b/packages/app/src/plugins/sidebar-items/kit.tsx new file mode 100644 index 0000000000..5796d572ca --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/kit.tsx @@ -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(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 ( + + + + ); +} + +/** 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 ( + + + + ); +} + +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], + }, +})); diff --git a/packages/app/src/plugins/sidebar-items/legacy.tsx b/packages/app/src/plugins/sidebar-items/legacy.tsx new file mode 100644 index 0000000000..94d3e69029 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/legacy.tsx @@ -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/`, so the screen's + * header and host switcher belong to this item. + */ +export function LegacyPluginSidebarRow({ + group, + onBeforeNavigate, +}: { + group: Extract; + 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 ( + + ); +} diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index e6b2eea757..90672ed435 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -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 = { "bot-1": "Bot 1", "bot-2": "Bot 2" }; + const bot = { + id: "bot", + title: (params: Record) => 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", + ); + }); }); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index 743ebbc1a7..830677f164 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -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>, +): 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); } diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx deleted file mode 100644 index 30b2a7f76b..0000000000 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ /dev/null @@ -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( - - - , - ); - 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(); - }); -}); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts deleted file mode 100644 index 3c99e38aba..0000000000 --- a/packages/app/src/plugins/surface-runtime.ts +++ /dev/null @@ -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; -} - -export function createPluginSurfaceRuntime( - client: DaemonClient | null, - plugin: Pick, -): 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; -} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index c827cf158b..c6e7b0a6cc 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -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; - client: DaemonClient; + Surface: ComponentType; plugin: NonNullable>; - layout: PluginSurfaceProps["layout"]; - host: PluginSurfaceProps["host"]; + layout: PluginScreenProps["layout"]; + host: PluginScreenProps["host"]; + params: PluginScreenParams; theme: PluginTheme; }) { const navigation = usePluginHostNavigation(host.id); return ( - - - + + + ); } 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(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 ( - - - - - {selectedLabel} - - - - - + 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(() => JSON.parse(paramsKey), [paramsKey]); const identity = useMemo(() => { 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() { ) : null} - {title} + {title} ), [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() { ), - [close, contributionServerIds, identity, pluginId, serverId], + [close, contributionServerIds, identity, params, pluginId, serverId], ); return ( @@ -232,10 +227,10 @@ export function PluginSurfaceScreen() { > @@ -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, - }, })); diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index d7c7537ade..c1548d0521 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -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: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 68e1378199..c64e498333 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -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: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 59d7c3ff36..af1e711eb5 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -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()), 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[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); diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 1490d8e14e..90f6e0e1a2 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -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 Plugin timeline item unavailable.; } @@ -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 ( - + - + ); } diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9305cc3ba8..bab295d623 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -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; + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + /** `addSidebarItem` registrations, so `/plugin//sidebar/` 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; serverId: string; requirements?: PluginRequirements; clientBundle: string; @@ -51,7 +62,8 @@ export type { PluginClientSlashCommandContribution, PluginComposerPillContribution, PluginSidebarContribution, - PluginSurfaceContribution, + PluginSidebarItemContribution, + PluginScreenContribution, PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 78a8bb462a..998f7e90ff 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -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}`} > - + {panel} - + ); } diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index c4ad2ef302..a7e46de422 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -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", diff --git a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx index 90d6e02344..e6785e6784 100644 --- a/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/sidebar/sidebar-nav-section.tsx @@ -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 = ; const moveDownIcon = ; -const BUILTIN_ICONS: Record = { +const BUILTIN_ICONS: Record = { "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 ; } @@ -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; + +function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElement { const { t } = useTranslation(); - const { items, setVisible, move } = useSidebarNavItems(); + const { items, setVisible, move } = useSidebarNavItems(section); return ( {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 ( + <> + + + + ); +} + const styles = StyleSheet.create((theme) => ({ row: { gap: theme.spacing[2], diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index fa1ef6763c..33604b72e9 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -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 }, + ]); + }); +}); diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index f3dbd33520..9691ec8b80 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -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; + +export type BuiltinSidebarItemId
= + (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
{ kind: "builtin"; - key: BuiltinSidebarNavId; - id: BuiltinSidebarNavId; + key: BuiltinSidebarItemId
; + id: BuiltinSidebarItemId
; visible: boolean; } @@ -23,16 +36,19 @@ export interface PluginSidebarNavItem { visible: boolean; } -export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem; +export type SidebarNavItem
= + | BuiltinSidebarNavItem
+ | PluginSidebarNavItem; -const BUILTIN_LABEL_KEYS: Record = { +const BUILTIN_LABEL_KEYS: Record = { "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 = { +const BUILTIN_SHORTCUT_ACTIONS: Record = { "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: Section, + key: string, +): key is BuiltinSidebarItemId
{ + const ids: readonly string[] = BUILTIN_SIDEBAR_ITEM_IDS[section]; + return ids.includes(key); } -export function resolveSidebarNavItems(input: { +export function resolveSidebarNavItems
(input: { + section: Section; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; -}): SidebarNavItem[] { +}): SidebarNavItem
[] { + const builtinIds: readonly BuiltinSidebarItemId
[] = + 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
[] = []; const placed = new Set(); 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 }); } diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index 634b9f96e7..dcebf670df 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -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; + +export interface UseSidebarNavItemsReturn
{ + /** Every item in the section in display order, hidden ones included. */ + items: SidebarNavItem
[]; setVisible: (key: string, visible: boolean) => void; move: (key: string, direction: "up" | "down") => void; } -export function useSidebarNavItems(): UseSidebarNavItemsReturn { +export function useSidebarNavItems
( + section: Section, +): UseSidebarNavItemsReturn
{ 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 }; diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 9fd9292ad3..5175c76224 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -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 ( - + {entry.sourceLabel} - {usage.planLabel ? : null} + {usage.planLabel ? : null} {status ? ( @@ -94,7 +97,7 @@ export function UsageCard({ {usage.windows.length > 0 || balances.length > 0 ? ( {usage.windows.map((window) => ( - + ))} {balances.map((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 ( + + ); +} + /** 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( () => ( {isPending ? ( - + ) : ( - + )} ); @@ -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], diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx deleted file mode 100644 index 26cd77793c..0000000000 --- a/packages/app/src/usage/composer-pill.tsx +++ /dev/null @@ -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 ( - - - {(state) => ( - - )} - - - - - - - - ); -} - -function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) { - return ( - <> - - {pill.text ? ( - - {pill.text} - - ) : null} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - trigger: { - height: 28, - flexShrink: 0, - }, - popover: { - padding: theme.spacing[3], - }, -})); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx new file mode 100644 index 0000000000..582f35102b --- /dev/null +++ b/packages/app/src/usage/controls.tsx @@ -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 ( + + {hostSelection && hostSelection.hosts.length > 0 ? ( + + ) : null} + {view.kind === "unavailable" ? null : ( + <> + + {/* The Changes panel's refresh: an icon, with the label in its tooltip. */} + + {busy ? ( + + ) : ( + + )} + + + )} + + ); +} + +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( + () => ( + + ), + [display, hostSelection, refresh, view], + ); + return { view, refresh, controls }; +} diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index ba0c8b0a46..ec42581fbe 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -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; diff --git a/packages/app/src/usage/display-toggle.tsx b/packages/app/src/usage/display-toggle.tsx new file mode 100644 index 0000000000..f778172b7c --- /dev/null +++ b/packages/app/src/usage/display-toggle.tsx @@ -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[] = [ + { 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 ( + + ); +} diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts new file mode 100644 index 0000000000..09fdb3d8ea --- /dev/null +++ b/packages/app/src/usage/display.ts @@ -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( + () => ({ + displayAs: preferences.displayAs, + setDisplayAs, + isPinned: (pin) => isUsagePinned(preferences, pin), + togglePin, + }), + [preferences, setDisplayAs, togglePin], + ); + return { preferences, display }; +} diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index ac8bffefd0..253586c173 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -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; diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index 54dd4ef939..f3cb0af3b0 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -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 ( diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts new file mode 100644 index 0000000000..5b6597ecdd --- /dev/null +++ b/packages/app/src/usage/hosts.ts @@ -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(null); + const connectedHosts = useMemo(() => hosts.filter((host) => host.isConnected), [hosts]); + return { + serverId: resolveUsageScreenHostId({ selectedServerId, activeServerId, hosts }), + connectedHosts, + select, + }; +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index 54f745a04d..6ab6c1447d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -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"; diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx index 91aa4d4b50..f0c23dd0f7 100644 --- a/packages/app/src/usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -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 ( - - {reports.map((entry, index) => ( - - {index > 0 ? : null} - - + + {reports.map((entry) => ( + + + ))} ); } const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - backgroundColor: theme.colors.border, + list: { + gap: theme.spacing[3], }, })); diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 54ebc4df87..fd46a2e476 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -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: "", - windows: [ - { id: "session", label: "Session", usedPct: 12 }, - { id: "weekly", label: "Weekly", usedPct: 64.6, headline: true }, - ], - }), - }); - - expect(pill).toEqual({ icon: "", 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 => ({ + 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(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index c3cddc70fe..ad5fe99c2f 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -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, -): 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 + ); } diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts new file mode 100644 index 0000000000..0580d3ccfc --- /dev/null +++ b/packages/app/src/usage/pinned.test.ts @@ -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: "", + 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: "", + 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%"], + ]); + }); +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts new file mode 100644 index 0000000000..7d348ffcd2 --- /dev/null +++ b/packages/app/src/usage/pinned.ts @@ -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), + }, + ]; + }), + ); +} diff --git a/packages/app/src/usage/preferences.test.ts b/packages/app/src/usage/preferences.test.ts new file mode 100644 index 0000000000..b060632067 --- /dev/null +++ b/packages/app/src/usage/preferences.test.ts @@ -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], + }); + }); +}); diff --git a/packages/app/src/usage/preferences.ts b/packages/app/src/usage/preferences.ts new file mode 100644 index 0000000000..55c5ee7d8e --- /dev/null +++ b/packages/app/src/usage/preferences.ts @@ -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 }; +} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index a7a717b627..753fb17af5 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -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 { 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( - 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(usageReportsQueryKey(serverId), (reports) => reports ? replaceReport(reports, reportId, report) : reports, ); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx new file mode 100644 index 0000000000..c4993b8b2e --- /dev/null +++ b/packages/app/src/usage/sidebar-item.tsx @@ -0,0 +1,242 @@ +import { router } from "expo-router"; +import { Gauge } from "lucide-react-native"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { usePanelStore } from "@/stores/panel-store"; +import { buildUsageRoute } from "@/utils/host-routes"; +import { useHostUsageWithControls } from "./controls"; +import { useUsagePreferences, type UsageDisplay } from "./display"; +import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import type { UsagePreferences } from "./preferences"; +import { useHostUsage } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; +import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned"; +import type { UsageReportEntry } from "./types"; +import type { UsageHost } from "./model"; +import { UsageBody } from "./usage-section"; + +const NO_REPORTS: UsageReportEntry[] = []; +const NO_ITEMS: PinnedUsageWindow[] = []; + +/** + * The sidebar footer's usage entry: 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; on compact + * layouts it opens the usage sheet instead. + */ +export function UsageSidebarItem() { + const { preferences, display } = useUsagePreferences(); + const serverId = useUsageHostId(); + // Without pins there is nothing to summarize, so the host is not asked for reports. + if (!serverId || preferences.pinned.length === 0) { + return ; + } + return ( + + ); +} + +function PinnedUsageItem({ + serverId, + preferences, + display, +}: { + serverId: string; + preferences: UsagePreferences; + display: UsageDisplay; +}) { + const { view } = useHostUsage(serverId); + const reports = view.kind === "ready" ? view.reports : NO_REPORTS; + const items = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]); + return ; +} + +function useOpenUsageScreen(): () => void { + const isCompact = useIsCompactFormFactor(); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + return useCallback(() => { + if (isCompact) showMobileAgent(); + router.push(buildUsageRoute()); + }, [isCompact, showMobileAgent]); +} + +function UsageEntry({ + serverId, + items, + display, +}: { + serverId: string | null; + items: readonly PinnedUsageWindow[]; + display: UsageDisplay; +}) { + const { t } = useTranslation(); + const label = t(builtinSidebarNavLabelKey("usage")); + const isCompact = useIsCompactFormFactor(); + const openUsageScreen = useOpenUsageScreen(); + const [open, setOpen] = useState(false); + // The sheet mounts on first open, so an unopened sidebar never asks the host for reports. + const [sheetMounted, setSheetMounted] = useState(false); + // Without a host there are no reports to show, so compact goes to the screen, which says so. + const usesSheet = isCompact && serverId !== null; + const handlePress = useCallback(() => { + if (!usesSheet) { + openUsageScreen(); + return; + } + setSheetMounted(true); + setOpen(true); + }, [openUsageScreen, usesSheet]); + + const trigger = + items.length > 0 ? ( + + ) : ( + + ); + if (!usesSheet) return trigger; + return ( + + {trigger} + {sheetMounted ? : null} + + ); +} + +/** + * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls + * in its title row. + */ +function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) return null; + return ( + + ); +} + +function HostUsageSheet({ + title, + serverId, + hosts, + onSelectHost, + display, +}: { + title: string; + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; + display: UsageDisplay; +}) { + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection, display); + return ( + + + + + + ); +} + +function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string { + return `${label}: ${items.map((item) => `${item.label} ${item.percentText}`).join(", ")}`; +} + +function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { + return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger; +} + +function PinnedUsageTrigger({ + label, + items, + onPress, +}: { + label: string; + items: readonly PinnedUsageWindow[]; + onPress: () => void; +}) { + return ( + + {items.map((item) => ( + + + + {item.percentText} + + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: "100%", + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + // A wrapping row packs its lines at the top; center them in the 28px row instead. + alignContent: "center", + columnGap: theme.spacing[3], + rowGap: theme.spacing[1], + // Same row geometry and leading rail as Add project and the footer icons. + minHeight: 28, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[1.5], + borderRadius: theme.borderRadius.lg, + }, + triggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + item: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + percent: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + sheetBody: { + padding: theme.spacing[3], + gap: theme.spacing[3], + }, +})); diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx index 96006f7728..e1eaf0bb02 100644 --- a/packages/app/src/usage/usage-screen.tsx +++ b/packages/app/src/usage/usage-screen.tsx @@ -1,11 +1,14 @@ -import { useCallback } from "react"; import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; +import { useMemo, type ReactNode } from "react"; +import { View } from "react-native"; import { PageLayout } from "@/components/page-layout"; +import { useHostUsageWithControls } from "./controls"; import { usageCopy } from "./copy"; -import type { UsageHostGroup } from "./model"; -import { useUsageByHost } from "./queries"; -import { UsageMessage, UsageSection } from "./usage-section"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; // The screen is reachable by URL, so there may be no history to go back to. function leaveUsage(): void { @@ -18,40 +21,52 @@ function leaveUsage(): void { export function UsageScreen() { const isFocused = useIsFocused(); + if (!isFocused) return {null}; + return ; +} + +function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { return ( - - {isFocused ? : null} + + {children} ); } -function UsageScreenContent() { - const { groups, refresh } = useUsageByHost(); +function FocusedUsageScreen() { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } return ( - <> - {groups.length === 0 ? : null} - {groups.map((group) => ( - - ))} - + ); } -function HostUsageGroup({ - group, - onRefresh, +function HostUsage({ + serverId, + hosts, + onSelectHost, }: { - group: UsageHostGroup; - onRefresh: (serverId: string) => void; + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; }) { - const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]); + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection, display); return ( - + + + + + ); } diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 846bc6d6f5..44b3ecc8ce 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -1,4 +1,3 @@ -import { RefreshCw } from "lucide-react-native"; import { useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -7,6 +6,8 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; +import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; @@ -14,48 +15,39 @@ export function UsageSection({ serverId, title, view, + display, onRefresh, testID, }: { serverId: string; title: string; view: UsageView; + display: UsageDisplay; onRefresh: () => void; testID?: string; }) { - const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - - const refreshButton = useMemo( - () => - view.kind === "unavailable" ? null : ( - - ), - [busy, onRefresh, view.kind], + const trailing = useMemo( + () => , + [display, onRefresh, view], ); return ( - - + + ); } -function UsageBody({ +/** What a host's usage view shows: its reports, or why there are none. */ +export function UsageBody({ serverId, view, + display, onRefresh, }: { serverId: string; view: UsageView; + display: UsageDisplay; onRefresh: () => void; }) { if (view.kind === "unavailable") { @@ -80,7 +72,7 @@ function UsageBody({ return ; } - return ; + return ; } export function UsageMessage({ text }: { text: string }) { diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 9627cb1571..ce6e43b5e6 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatPct, formatResetLabel } from "./format"; -import { usedPercent } from "./model"; +import { clampPct, formatDisplayPct, formatResetLabel } from "./format"; +import { displayPercent, usageWindowRowLabel, usedPercent } from "./model"; +import type { UsageDisplayAs } from "./preferences"; import { deriveTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; @@ -19,11 +20,34 @@ function fillToneStyle(tone: UsageTone) { } } -export function UsageWindowBar({ window }: { window: UsageWindow }) { +// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, +// so a hover never reads as the selection. Pinned rows do not react to hover. +function highlightStyle(pinned: boolean, hovered: boolean) { + if (pinned) return styles.highlightPinned; + return hovered ? styles.highlightHovered : styles.highlightNone; +} + +export function UsageWindowBar({ + window, + displayAs, + pinned, + onTogglePin, + pinLabel, + pinTestID, +}: { + window: UsageWindow; + displayAs: UsageDisplayAs; + pinned: boolean; + onTogglePin: () => void; + /** What the row pins, naming the source and window: "Pin Claude Session". */ + pinLabel: string; + pinTestID: string; +}) { const usedPct = usedPercent(window); + const shownPct = displayPercent(window, displayAs); const tone = window.tone ?? deriveTone(usedPct); - const fillWidth = clampPct(usedPct ?? 0); + const fillWidth = clampPct(shownPct ?? 0); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], [fillWidth, tone], @@ -34,14 +58,59 @@ export function UsageWindowBar({ window }: { window: UsageWindow }) { ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() : formatResetLabel(window.resetsAt); + const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + + // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same + // padding so toggling only changes the background. return ( - + + {({ hovered }: { hovered?: boolean }) => ( + + )} + + ); +} + +function WindowRowContent({ + highlight, + label, + value, + trailing, + isAtRisk, + fillStyle, +}: { + highlight: StyleProp; + label: string; + value: string; + trailing: string | null | undefined; + isAtRisk: boolean; + fillStyle: StyleProp; +}) { + return ( + <> + - {window.label} + {label} - {usedPct != null ? formatPct(usedPct) : "—"} + {value} {trailing ? ( {` · ${trailing}`} ) : null} @@ -50,13 +119,36 @@ export function UsageWindowBar({ window }: { window: UsageWindow }) { - + ); } const styles = StyleSheet.create((theme) => ({ - container: { + row: { gap: 3, + // The highlight bleeds into the card padding so the label and bar stay on the card's rail. + marginHorizontal: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1.5], + // Its own stacking context, so the highlight layer paints above the card and below the text. + zIndex: 0, + }, + highlightNone: { + display: "none", + }, + highlightPinned: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + // The pinned surface at half strength: a separate layer, so the text keeps full opacity. + highlightHovered: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + opacity: theme.opacity[50], }, labelRow: { flexDirection: "row", @@ -65,7 +157,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, label: { - flexShrink: 1, + flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index d4d8a682cf..df0c13295b 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -228,7 +228,7 @@ import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; export default function contribute(client: PluginClientContext) { - client.addSurface("main", Surface); + client.addScreen({ id: "main", title: "Review", Component: Surface }); client.addWorkspacePanel({ id: "review", title: "Review", @@ -294,7 +294,9 @@ export default function contribute(client: PluginClientContext) { `, ); const check = typecheckPlugin(directory); - await expect(check).rejects.toThrow("has no exported member 'PluginComposerPillProps'"); + await expect(check).rejects.toThrow( + /has no exported member (named )?'PluginComposerPillProps'/, + ); await expect(check).rejects.toThrow("Property 'button' is missing"); await expect(check).rejects.toThrow("has no call signatures"); }, 20_000); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 2046b0cd8e..448aa956fa 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -20,17 +20,23 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { GreetingSurface } from "./client/greeting"; +const CLIENT_ENTRY = `import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { GreetingScreen } from "./client/greeting"; + +function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "greeting" })} + /> + ); +} export default function contribute(client: PluginClientContext) { - client.addSurface("greeting", GreetingSurface); - client.addSidebarItem({ - id: "greeting", - title: "Greeting", - icon: "MessageCircle", - surface: "greeting", - }); + client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen }); + client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem }); return () => {}; } `; @@ -63,7 +69,7 @@ export function createGreeting({ name }: RpcInput) { } `; -const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +const CLIENT_GREETING = `import type { PluginScreenProps } from "@getpaseo/plugin/client"; import { useRpc } from "@getpaseo/plugin/client"; import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -71,7 +77,7 @@ import { Pressable, Text, View } from "react-native"; import { greetingRpc } from "../shared/greeting"; import { openExternal } from "./web"; -export function GreetingSurface({ theme, layout }: PluginSurfaceProps) { +export function GreetingScreen({ theme, layout }: PluginScreenProps) { const createGreeting = useRpc(greetingRpc); const greeting = useMutation({ mutationFn: createGreeting }); const styles = useMemo( diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index dbe3ceef64..0f6d7f1e07 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, + supportsUsageReports, type DaemonClientTrace, type CreateAgentRequestOptions, type DaemonTransport, @@ -6572,6 +6573,101 @@ test("sends provider.usage.list.request and resolves provider.usage.list.respons }); }); +test("maps released-host usage reports and filters report IDs", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { providerUsageList: true } }); + await connected; + const providers = [ + { + providerId: "claude", + displayName: "Claude", + status: "available", + planLabel: null, + fetchedAt: null, + windows: [{ id: "session", label: "Session", usedPct: 25 }], + error: null, + }, + { + providerId: "codex", + displayName: "Codex", + status: "error", + planLabel: "Pro", + fetchedAt: "2026-09-29T00:00:00.000Z", + windows: [], + balances: [], + details: [], + error: "unavailable", + }, + ]; + const reports = [ + { + id: "claude", + sourceId: "claude", + sourceLabel: "Claude", + account: {}, + fetchedAt: "2026-09-30T00:00:00.000Z", + report: { + status: "available", + planLabel: undefined, + windows: providers[0]!.windows, + balances: undefined, + details: undefined, + error: undefined, + }, + }, + { + id: "codex", + sourceId: "codex", + sourceLabel: "Codex", + account: {}, + fetchedAt: "2026-09-29T00:00:00.000Z", + report: { + status: "error", + planLabel: "Pro", + windows: [], + balances: [], + details: [], + error: "unavailable", + }, + }, + ]; + for (const reportIds of [undefined, ["codex", "missing"], []]) { + const result = client.listUsageReports({ + requestId: "legacy-usage", + reportIds, + forceRefresh: true, + }); + expect(parseSentFrame(mock.sent.at(-1))).toEqual({ + type: "provider.usage.list.request", + requestId: "legacy-usage", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "legacy-usage", + fetchedAt: "2026-09-30T00:00:00.000Z", + providers, + }, + }), + ); + expect(await result).toStrictEqual({ + requestId: "legacy-usage", + reports: + reportIds === undefined ? reports : reports.filter((entry) => reportIds.includes(entry.id)), + }); + } +}); + test("sends close_items_request and resolves close_items_response", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7100,3 +7196,72 @@ test("reviewed plugin updates gate before requests and preserve exact proposal d ]); } }); + +test.each([ + [undefined, false], + [{}, false], + [{ usageSources: false, providerUsageList: false }, false], + [{ usageSources: true }, true], + [{ providerUsageList: true }, true], + [{ usageSources: true, providerUsageList: true }, true], +] as const)("usage support for features %j is %s", (features, expected) => { + expect(supportsUsageReports(features)).toBe(expected); +}); + +test("uses modern usage RPC when both capabilities are advertised", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } }); + await connected; + const result = client.listUsageReports({ + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "usage.list_reports.request", + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }); + const payload = { + requestId: "modern-usage", + reports: [ + { + id: "claude:work", + sourceId: "claude", + sourceLabel: "Claude", + account: { label: "Work" }, + fetchedAt: "2026-09-30T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + ], + }; + mock.triggerMessage(wrapSessionMessage({ type: "usage.list_reports.response", payload })); + expect(await result).toStrictEqual(payload); +}); + +test("rejects usage requests when the host has neither capability", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: {} }); + await connected; + await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage."); + expect(mock.sent).toEqual([]); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 2fe706c8ae..ba1e09e7cb 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -95,7 +95,6 @@ import type { ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, UsageListReportsResponseMessage, - AgentResolveUsageReportResponseMessage, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -563,7 +562,6 @@ type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage[" type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; type UsageListReportsPayload = UsageListReportsResponseMessage["payload"]; -type AgentResolveUsageReportPayload = AgentResolveUsageReportResponseMessage["payload"]; type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -1188,6 +1186,11 @@ interface PingProbe { drivesLivenessFailure: boolean; } +export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + return features?.usageSources === true || features?.providerUsageList === true; +} + export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ active: (message) => this.owned.owns(message), @@ -5260,6 +5263,37 @@ export class DaemonClient { forceRefresh?: boolean; reportIds?: string[]; }): Promise { + const features = this.getLastServerInfoMessage()?.features; + if (!supportsUsageReports(features)) { + throw new Error("Update the host to see usage."); + } + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + if (features?.usageSources !== true) { + // Released hosts serve a five-minute cache and have no forceRefresh option. + const payload = await this.listProviderUsage({ requestId: options?.requestId }); + return { + requestId: payload.requestId, + reports: payload.providers + .filter( + (provider) => !options?.reportIds || options.reportIds.includes(provider.providerId), + ) + .map((provider) => ({ + id: provider.providerId, + sourceId: provider.providerId, + sourceLabel: provider.displayName, + account: {}, + fetchedAt: provider.fetchedAt ?? payload.fetchedAt, + report: { + status: provider.status, + windows: provider.windows, + balances: provider.balances ?? undefined, + details: provider.details ?? undefined, + planLabel: provider.planLabel ?? undefined, + error: provider.error ?? undefined, + }, + })), + }; + } return this.sendNamespacedCorrelatedSessionRequest({ requestId: options?.requestId, message: { @@ -5270,19 +5304,6 @@ export class DaemonClient { }); } - async resolveAgentUsageReport(options: { - agentId: string; - requestId?: string; - }): Promise { - return this.sendNamespacedCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "agent.resolve_usage_report.request", - agentId: options.agentId, - }, - }); - } - async listCommands(options: ListCommandsOptions): Promise; async listCommands(agentId: string, requestId?: string): Promise; async listCommands( diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index e1605e20f6..239b3580ee 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -47,8 +47,46 @@ interface PluginNavigableHostProps extends PluginHostProps { }; } +/** String keys and values: params travel in the screen's URL. */ +export type PluginScreenParams = Record; + +export interface PluginOpenScreenInput { + screenId: string; + params?: PluginScreenParams; +} + +export interface PluginScreenLocation { + screenId: string; + params: PluginScreenParams; +} + export interface PluginSurfaceProps extends PluginNavigableHostProps {} +export interface PluginScreenProps extends PluginSurfaceProps { + /** The params the screen was opened with; `{}` when none. */ + params: PluginScreenParams; +} + +export interface PluginPopoverProps extends PluginHostProps { + close(): void; + openScreen(input: PluginOpenScreenInput): void; +} + +export interface PluginSidebarItemProps extends PluginHostProps { + /** This plugin's screen open on this item's host, with its params, else null. */ + currentScreen: PluginScreenLocation | null; + openScreen(input: PluginOpenScreenInput): void; + /** Anchored to the item on wide layouts; a bottom sheet on compact layouts. */ + openPopover(Content: ComponentType): void; +} + +export interface PluginSidebarItemContribution { + id: string; + /** Settings row label, accessibility label, and default label for SidebarRow. */ + title: string; + Component: ComponentType; +} + export interface PluginIconProps { name: string; size?: number; @@ -84,10 +122,19 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { agentId?: string; } -export interface PluginClientContext extends PluginCommandCapabilities { - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +interface PluginClientContextAliases { + /** @deprecated Use `addScreen`. */ addSurface(id: string, Component: ComponentType): PluginCleanup; + /** @deprecated Use `addSidebarHeaderItem`. */ addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; +} + +export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; @@ -123,11 +170,21 @@ export interface PluginSettingsScreenContribution { Component: ComponentType; } +/** The screen header's title: fixed, or derived from the params the screen was opened with. */ +export type PluginScreenTitle = string | ((params: PluginScreenParams) => string); + +export interface PluginScreenContribution { + id: string; + title: PluginScreenTitle; + Component: ComponentType; +} + export interface PluginSurfaceContribution { id: string; Component: ComponentType; } +/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */ export interface PluginSidebarContribution { id: string; title: string; @@ -174,6 +231,8 @@ export interface PluginCommandCapabilities { contract: PluginRpcContract, input: ZodInput, ): Promise>; + openScreen(input: PluginOpenScreenInput): void; + /** @deprecated Use `openScreen`. */ openSurface(id: string): void; openSettings(id: string): void; } diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index b6f0e342f7..810462354f 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -1,6 +1,13 @@ export type { PluginHostProps, PluginSurfaceProps, + PluginScreenProps, + PluginPopoverProps, + PluginScreenParams, + PluginOpenScreenInput, + PluginScreenLocation, + PluginSidebarItemProps, + PluginSidebarItemContribution, PluginIconProps, PluginPanelLocation, PluginOpenPanelOptions, @@ -12,6 +19,8 @@ export type { PluginWorkspacePanelContribution, PluginSettingsScreenContribution, PluginSurfaceContribution, + PluginScreenContribution, + PluginScreenTitle, PluginSidebarContribution, PluginTimelineTransformerContribution, PluginTimelineItemProps, diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 35abb0475d..6686d8292b 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -63,3 +63,24 @@ export interface ExternalLinkProps { onError?: (error: unknown) => void; } export declare const ExternalLink: ComponentType; + +export type SidebarIcon = string | ComponentType<{ size: number; color: string }>; +export interface SidebarRowProps { + /** + * Tells rows of one item apart when the item renders several: "bot-2". Unique within the item. + * Omit it when the item renders one row. + */ + id?: string; + /** A Lucide icon name or a component. */ + icon?: SidebarIcon; + /** Defaults to the item's registered title. */ + label?: string; + onPress(): void; + active?: boolean; + /** Right slot. Renders beside the row's pressable, so a button here presses on its own. */ + trailing?: ReactNode; +} +/** A sidebar navigation row. Render it from a sidebar item's `Component`. */ +export declare const SidebarRow: ComponentType; +/** A line between groups of rows. Render it from a sidebar item's `Component`. */ +export declare const SidebarSeparator: ComponentType; diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index a72477d19d..c65a814753 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -18,7 +18,6 @@ export const PROVIDER_CAPABILITIES = [ "session.revert.files", "session.subsession", "session.unarchive", - "session.usage_reference", "permission", "permission.tool_policy", "timeline.plugin", @@ -228,7 +227,6 @@ export type ProviderInput = } | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } | { type: "session.interrupt"; requestId: string; sessionId: string } - | { type: "session.usage_reference"; requestId: string; sessionId: string } | { type: "session.permission"; sessionId: string; @@ -528,11 +526,6 @@ export type ProviderEvent = | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } - | { - type: "usage_reference"; - requestId: string; - reference: { source: string; input: JsonValue } | null; - } | { type: "request.failed"; requestId: string; error: ProviderError } | { type: "session.opened"; @@ -616,8 +609,6 @@ export function requiredProviderCapabilities(input: ProviderInput): readonly Pro case "session.interrupt": case "session.close": return []; - case "session.usage_reference": - return ["session.usage_reference"]; case "sessions": return ["session.list"]; case "session.open": { @@ -896,13 +887,6 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio z .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) .strict(), - z - .object({ - type: z.literal("session.usage_reference"), - requestId: idSchema, - sessionId: idSchema, - }) - .strict(), z .object({ type: z.literal("session.permission"), @@ -1288,13 +1272,6 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio }) .strip(), z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), - z - .object({ - type: z.literal("usage_reference"), - requestId: idSchema, - reference: z.object({ source: idSchema, input: z.json() }).nullable(), - }) - .strip(), z .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) .strip(), diff --git a/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts index 0d1dd04ac5..1a8c5de939 100644 --- a/packages/plugin/src/server/usage.test.ts +++ b/packages/plugin/src/server/usage.test.ts @@ -17,14 +17,13 @@ test("shared usage tones preserve the app threshold contract", () => { expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]); }); -test("shared windows retain percentage, reset time, and optional headline", () => { - expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30, headline: true })).toEqual({ +test("shared windows retain percentage and reset time", () => { + expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30 })).toEqual({ id: "a", label: "A", usedPct: 30, remainingPct: 70, resetsAt: null, - headline: true, }); expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({ id: "b", diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 3e34e7acf1..ed49db0f1b 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -11,7 +11,6 @@ export interface UsageWindow { runsOutAt?: string | null; shortfallPct?: number | null; tone?: "default" | "ok" | "warning" | "danger"; - headline?: boolean; } export interface UsageBalance { @@ -49,7 +48,7 @@ export interface UsageSourceRegistration { /** Stable account identity, resolved without fetching usage. */ identify(input: unknown): Promise<{ key: string; label?: string } | null>; fetch(input: unknown): Promise; - discover?(): Promise; + discover(): Promise; } export function windowFromUsedPct(input: { @@ -58,7 +57,6 @@ export function windowFromUsedPct(input: { utilizationPct: number | null | undefined; resetsAt?: string | null; tone?: UsageWindow["tone"]; - headline?: boolean; }): UsageWindow { const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; const window: UsageWindow = { @@ -69,7 +67,6 @@ export function windowFromUsedPct(input: { resetsAt: input.resetsAt ?? null, }; if (input.tone) window.tone = input.tone; - if (input.headline) window.headline = true; return window; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 589137d720..6549ca3aa6 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1781,11 +1781,6 @@ export const UsageListReportsRequestMessageSchema = z.object({ reportIds: z.array(z.string()).optional(), forceRefresh: z.boolean().optional(), }); -export const AgentResolveUsageReportRequestMessageSchema = z.object({ - type: z.literal("agent.resolve_usage_report.request"), - requestId: z.string(), - agentId: z.string(), -}); export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), @@ -3246,7 +3241,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProviderDiagnosticRequestMessageSchema, ProviderUsageListRequestMessageSchema, UsageListReportsRequestMessageSchema, - AgentResolveUsageReportRequestMessageSchema, ResumeAgentRequestMessageSchema, ImportAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -6247,7 +6241,7 @@ export const ProviderUsageListResponseMessageSchema = z.object({ export const UsageReportSchema = z.object({ status: ProviderUsageStatusSchema, planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema.extend({ headline: z.boolean().optional() })), + windows: z.array(ProviderUsageWindowSchema), balances: z.array(ProviderUsageBalanceSchema).optional(), details: z.array(ProviderUsageDetailSchema).optional(), error: z.string().optional(), @@ -6265,10 +6259,6 @@ export const UsageListReportsResponseMessageSchema = z.object({ type: z.literal("usage.list_reports.response"), payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }), }); -export const AgentResolveUsageReportResponseMessageSchema = z.object({ - type: z.literal("agent.resolve_usage_report.response"), - payload: z.object({ requestId: z.string(), reportId: z.string().nullable() }), -}); const AgentSlashCommandSchema = z.object({ name: z.string(), @@ -6956,7 +6946,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, UsageListReportsResponseMessageSchema, - AgentResolveUsageReportResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -7138,9 +7127,6 @@ export type ProviderUsageTone = z.infer; export type UsageReport = z.infer; export type UsageReportEntry = z.infer; export type UsageListReportsResponseMessage = z.infer; -export type AgentResolveUsageReportResponseMessage = z.infer< - typeof AgentResolveUsageReportResponseMessageSchema ->; export type ProviderUsageStatus = z.infer; export type ProviderUsage = z.infer; export type ProviderUsageWindow = z.infer; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index effd5157c1..f2b42525e9 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -646,11 +646,6 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } -export interface UsageReference { - source: string; - input: JsonValue; -} - export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; @@ -659,8 +654,6 @@ export interface AgentSession { /** New provider-owned rows to commit on registration. streamHistory must also * replay them at their original timestamps; restored sessions omit old rows. */ readonly initialTimeline?: ImportedTimelineEntry[]; - /** Resolved at fetch time because model and credentials may change during a session. */ - getUsageReference?(): Promise; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8774b8784c..c18f30a108 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -396,24 +396,6 @@ describe("PluginAgentClientRegistry", () => { await registry.shutdown(); }); - test("returns no usage reference when a plugin provider lacks the capability", async () => { - const harness = createProviderHarness(); - const registry = new PluginAgentClientRegistry(createTestLogger()); - registry.replace([harness.registration]); - const client = registry.clients()[harness.registration.id]!; - const session = await client.createSession({ - provider: harness.registration.id, - cwd: "/workspace", - }); - try { - expect(await session.getUsageReference?.()).toBeNull(); - expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false); - } finally { - await session.close(); - registry.replace([]); - } - }); - test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { const harness = createProviderHarness({ completeTurn: false }); const registry = new PluginAgentClientRegistry(createTestLogger()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index e6712d3c3b..1c26066fa8 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -51,7 +51,6 @@ import type { ProviderRefreshContext, SteerActiveTurnOptions, SteerResult, - UsageReference, } from "./agent-sdk-types.js"; import { isDefaultAgentCreateConfigUnattended, @@ -108,15 +107,9 @@ function providerError(error: ProviderError): Error { function isProviderRequestReply( event: ProviderEvent, -): event is Extract< - ProviderEvent, - { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } -> { +): event is Extract { return ( - event.type === "request.completed" || - event.type === "catalog" || - event.type === "sessions" || - event.type === "usage_reference" + event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" ); } @@ -526,10 +519,7 @@ class ProviderRuntime { } private finishRequest( - event: Extract< - ProviderEvent, - { type: "request.completed" | "catalog" | "sessions" | "usage_reference" } - >, + event: Extract, ): void { const request = this.requests.get(event.requestId); if (!request) return; @@ -569,17 +559,6 @@ class ProviderRuntimeSession { return this.capabilities; } - async getUsageReference(): Promise { - if (!this.capabilities.includes("session.usage_reference")) return null; - const event = await this.runtime.complete({ - type: "session.usage_reference", - requestId: randomUUID(), - sessionId: this.providerSessionId, - }); - if (event.type !== "usage_reference") throw new Error("Invalid usage reference response"); - return event.reference; - } - onEvent(listener: (event: ProviderEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -1096,10 +1075,6 @@ class PluginAgentSession implements AgentSession { return agentCapabilities(this.bridge.negotiatedCapabilities); } - getUsageReference(): Promise { - return this.bridge.getUsageReference(); - } - get features(): AgentFeature[] { return this.bridge.config.settings.map((setting) => setting.type === "toggle" diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 50ad1ae94d..569e842125 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -458,7 +458,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession } }, getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), - getUsageReference: inner.getUsageReference?.bind(inner), getAvailableModes: () => inner.getAvailableModes(), getCurrentMode: () => inner.getCurrentMode(), setMode: (modeId) => inner.setMode(modeId), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 0394900d3b..9d106a2c1a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -222,13 +222,6 @@ function createSessionWithConfig( ); } -test("ACP usage reference uses the provider ID", async () => { - for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) { - const session = createSessionWithConfig({ provider }); - expect(await session.getUsageReference()).toEqual({ source: provider, input: {} }); - } -}); - function createKiroSession( options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {}, logger: ReturnType = createTestLogger(), diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 019725ce3f..2d47bb3b6b 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1633,10 +1633,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { readonly provider: string; readonly capabilities: AgentCapabilityFlags; - async getUsageReference() { - return { source: this.provider, input: {} }; - } - private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly defaultCommand: [string, ...string[]]; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 3e91b9d639..dbd10aea4d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -25,7 +25,6 @@ import type { } from "../../agent-sdk-types.js"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; -import { buildProviderRegistry } from "../../provider-registry.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; @@ -530,56 +529,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("ClaudeAgentClient binary resolution", () => { const logger = createTestLogger(); - test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => { - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); - const session = await client.createSession( - { provider: "claude", cwd: process.cwd() }, - { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } }, - ); - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { configDir: "/accounts/second" }, - }); - await session.close(); - }); - - test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])( - "Claude usage reference is null with %s override", - async (name) => { - const client = new ClaudeAgentClient({ - logger, - resolveBinary: async () => "/test/claude/bin", - }); - const session = await client.createSession( - { provider: "claude", cwd: process.cwd() }, - { env: { [name]: "override" } }, - ); - expect(await session.getUsageReference?.()).toBeNull(); - await session.close(); - }, - ); - - test("Claude custom alias keeps its CLAUDE_CONFIG_DIR usage reference", async () => { - const registry = buildProviderRegistry(logger, { - providerOverrides: { - "work-claude": { - extends: "claude", - label: "Work Claude", - env: { CLAUDE_CONFIG_DIR: "/accounts/work" }, - }, - }, - }); - const session = await registry["work-claude"].createClient(logger).createSession({ - provider: "work-claude", - cwd: process.cwd(), - }); - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { configDir: "/accounts/work" }, - }); - await session.close(); - }); - test("resolves the installed Claude Code version", async () => { await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 7eecf56c96..dd79b57a25 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import path from "node:path"; -import { homedir } from "node:os"; import { type AgentDefinition, type CanUseTool, @@ -2042,15 +2041,6 @@ class ClaudeAgentSession implements AgentSession { readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; - async getUsageReference() { - const env = this.buildSdkEnv(); - if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null; - return { - source: "claude", - input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") }, - }; - } - private readonly config: ClaudeAgentConfig; private readonly launchEnv?: Record; private readonly agentId?: string; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index e0e7fa0379..bb7d56d07a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -171,44 +171,6 @@ function createSession( return session; } -test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => { - const session = new CodexAppServerAgentSession( - createConfig(), - null, - createTestLogger(), - () => { - throw new Error("unused"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { CODEX_HOME: "/accounts/second" }, - ); - expect(await session.getUsageReference()).toEqual({ - source: "codex", - input: { codexHome: "/accounts/second" }, - }); - const custom = new CodexAppServerAgentSession( - createConfig(), - null, - createTestLogger(), - () => { - throw new Error("unused"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" }, - ); - expect(await custom.getUsageReference()).toBeNull(); -}); - function createProviderWithFakeAppServer( appServer: FakeCodexAppServer, options?: { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cee50fbf4c..119be789b9 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3521,7 +3521,6 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, private readonly initialResumePurpose: "interactive" | "history" = "interactive", - private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { this.logger = logger.child({ module: "agent", @@ -3549,17 +3548,6 @@ export class CodexAppServerAgentSession implements AgentSession { } } - async getUsageReference() { - if (this.usageEnv.OPENAI_BASE_URL) return null; - return { - source: "codex", - input: { - codexHome: - this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"), - }, - }; - } - get id(): string | null { return this.currentThreadId; } @@ -7262,7 +7250,6 @@ export class CodexAppServerAgentClient implements AgentClient { autoReviewEnabled, launchContext?.agentId, "interactive", - buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; @@ -7295,7 +7282,6 @@ export class CodexAppServerAgentClient implements AgentClient { autoReviewEnabled, launchContext?.agentId, options?.purpose ?? "interactive", - buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env), ); await session.connect(); return session; diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 6858ed216d..90b82cff4b 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,10 +1,8 @@ import { describe, expect, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import path, { join } from "node:path"; +import path from "node:path"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; @@ -172,79 +170,6 @@ function createToolCatalog(): PaseoToolCatalog { } describe("OMP agent client and session", () => { - test("usage reference reads current get_state provider and account on each call", async () => { - const home = mkdtempSync(join(tmpdir(), "paseo-omp-reference-")); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. - const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { - DatabaseSync: new (path: string) => { - exec(sql: string): void; - prepare(sql: string): { run(...args: unknown[]): void }; - close(): void; - }; - }; - const db = new DatabaseSync(join(agentDir, "agent.db")); - db.exec(` - CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); - CREATE INDEX idx_cache_expires ON cache(expires_at); - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), - updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) - ); - `); - const insert = db.prepare( - "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, 'oauth', ?)", - ); - insert.run( - "openai-codex", - JSON.stringify({ - access: "codex-fixture", - accountId: "codex-account", - expires: Date.now() + 60_000, - }), - ); - insert.run( - "anthropic", - JSON.stringify({ - access: "claude-fixture", - accountId: "claude-account", - expires: Date.now() + 60_000, - }), - ); - db.close(); - try { - const omp = new OmpHarness(); - await omp.start({}, undefined, { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: agentDir, - XDG_DATA_HOME: "", - }); - const runtime = omp.runtime(); - runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } }; - const requestsBefore = runtime.getStateRequestCount; - expect(await omp.getUsageReference()).toEqual({ - source: "codex", - input: { accessToken: "codex-fixture", accountId: "codex-account" }, - }); - expect(runtime.getStateRequestCount).toBe(requestsBefore + 1); - runtime.state = { ...runtime.state, model: { provider: "anthropic", id: "claude-model" } }; - expect(await omp.getUsageReference()).toEqual({ - source: "claude", - input: { accessToken: "claude-fixture" }, - }); - expect(runtime.getStateRequestCount).toBe(requestsBefore + 2); - await omp.close(); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); test("owns launch configuration and registers native host tools", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "ask" }, createToolCatalog()); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 8e931b55fd..4e700df275 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -115,7 +115,6 @@ import { mapOmpRpcUiPermissionRequest, } from "./rpc-ui-permission-mapper.js"; import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; -import { resolveOmpUsageReference } from "./usage-reference.js"; const OMP_PROVIDER = "omp"; const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { @@ -201,7 +200,6 @@ interface OmpAgentSessionOptions { noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; providerIdleDeadlineMs?: number; - usageEnv?: NodeJS.ProcessEnv; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -735,7 +733,6 @@ export class OmpAgentSession implements AgentSession { this.state = options.initialState; this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; - this.usageEnv = options.usageEnv ?? process.env; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS; @@ -785,12 +782,6 @@ export class OmpAgentSession implements AgentSession { private readonly restartRuntime: OmpAgentSessionOptions["restartRuntime"]; private readonly config: AgentSessionConfig; private readonly logger: Logger; - private readonly usageEnv: NodeJS.ProcessEnv; - - async getUsageReference() { - const state = await this.runtimeSession.getState(); - return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv); - } get id(): string | null { return this.state.sessionId; @@ -2324,7 +2315,6 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, providerIdleDeadlineMs: this.providerIdleDeadlineMs, - usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, }); } catch (error) { await hostTools?.close(); @@ -2391,7 +2381,6 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, providerIdleDeadlineMs: this.providerIdleDeadlineMs, - usageEnv: { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, live: false, }); } catch (error) { diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 5c53dd3e4f..b292a9b32f 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -540,10 +540,6 @@ export class OmpHarness { return this.omp.latestSession(); } - async getUsageReference() { - return this.requireSession().getUsageReference(); - } - runningToolCallIds(): string[] { const statusByCall = new Map(); for (const item of this.timeline()) { diff --git a/packages/server/src/server/agent/providers/omp/usage-reference.test.ts b/packages/server/src/server/agent/providers/omp/usage-reference.test.ts deleted file mode 100644 index 659c0afcea..0000000000 --- a/packages/server/src/server/agent/providers/omp/usage-reference.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createRequire } from "node:module"; -import { afterEach, expect, test } from "vitest"; -import { resolveOmpUsageReference } from "./usage-reference.js"; - -const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as { - DatabaseSync: new (path: string) => { - exec(sql: string): void; - prepare(sql: string): { run(...args: unknown[]): void }; - close(): void; - }; -}; -// Upstream: packages/ai/src/auth/sqlite-credential-store.ts:576-581,711-724. -const OMP_AUTH_SCHEMA = ` - CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER NOT NULL); - CREATE INDEX idx_cache_expires ON cache(expires_at); - CREATE TABLE auth_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - credential_type TEXT NOT NULL, - data TEXT NOT NULL, - disabled_cause TEXT DEFAULT NULL, - identity_key TEXT DEFAULT NULL, - created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), - updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) - ); -`; -const dirs: string[] = []; -const future = Date.now() + 3_600_000; -function fixture() { - const home = mkdtempSync(join(tmpdir(), "omp-usage-")); - dirs.push(home); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - const dbFile = join(agentDir, "agent.db"); - const db = new DatabaseSync(dbFile); - db.exec(OMP_AUTH_SCHEMA); - const env = { OMP_PROFILE: "", PI_CODING_AGENT_DIR: agentDir, XDG_DATA_HOME: "" }; - const row = ( - id: number, - provider = "openai-codex", - options: { disabled?: boolean; expires?: number; type?: string } = {}, - ) => - db - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run( - id, - provider, - options.type ?? "oauth", - JSON.stringify({ - access: `fixture-token-${id}`, - expires: options.expires ?? future, - accountId: `account-${id}`, - }), - options.disabled ? "disabled" : null, - ); - const sticky = (id: number, expires = Math.floor(Date.now() / 1000) + 100) => - db - .prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)") - .run( - "session:sticky:openai-codex:session-1", - JSON.stringify({ type: "oauth", index: 0, credentialId: id }), - expires, - ); - const read = (override = {}) => - resolveOmpUsageReference("session-1", "openai-codex", { ...env, ...override }); - return { db, dbFile, agentDir, row, sticky, read }; -} -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); -}); -test("sticky id selects the right OAuth row and leaves SQLite byte-identical", () => { - const f = fixture(); - f.row(1); - f.row(2); - f.sticky(2); - f.db.close(); - const before = readFileSync(f.dbFile); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-2", accountId: "account-2" }, - }); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-2", accountId: "account-2" }, - }); - expect(readFileSync(f.dbFile)).toEqual(before); -}); -test("single OAuth row resolves without sticky", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); -}); -test("multiple rows without sticky are ambiguous", () => { - const f = fixture(); - f.row(1); - f.row(2); - f.db.close(); - expect(f.read()).toBeNull(); -}); -test("fallback counts only valid OAuth rows", () => { - const f = fixture(); - f.row(1); - f.row(2, "openai-codex", { expires: Date.now() - 1 }); - f.row(3, "openai-codex", { disabled: true }); - f.db - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run(4, "openai-codex", "oauth", "invalid-json", null); - f.db.close(); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); -}); -test("disabled and expired rows do not resolve", () => { - const a = fixture(); - a.row(1, "openai-codex", { disabled: true }); - a.sticky(1); - a.db.close(); - expect(a.read()).toBeNull(); - const b = fixture(); - b.row(1, "openai-codex", { expires: Date.now() - 1 }); - b.db.close(); - expect(b.read()).toBeNull(); -}); -test("expired sticky falls back to a single row", () => { - const f = fixture(); - f.row(1); - f.sticky(1, 1); - f.db.close(); - expect(f.read()?.source).toBe("codex"); -}); -test("env key does not displace OAuth with or without sticky", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); - const g = fixture(); - g.row(1); - g.sticky(1); - g.db.close(); - expect(g.read({ OPENAI_CODEX_OAUTH_TOKEN: "fixture-key" })?.source).toBe("codex"); -}); -test("broker environment and config URLs block attribution", () => { - const f = fixture(); - f.row(1); - f.db.close(); - expect(f.read({ OMP_AUTH_BROKER_URL: "https://broker.invalid" })).toBeNull(); - writeFileSync( - join(f.agentDir, "config.yml"), - "auth:\n broker:\n url: https://broker.invalid\n", - ); - expect(f.read()).toBeNull(); -}); -test("broker config.yaml and flat keys block attribution with config.yml precedence", () => { - const f = fixture(); - f.row(1); - f.db.close(); - writeFileSync(join(f.agentDir, "config.yaml"), '"auth.broker.url": https://broker.invalid\n'); - expect(f.read()).toBeNull(); - writeFileSync(join(f.agentDir, "config.yml"), "auth:\n broker: {}\n"); - expect(f.read()).toEqual({ - source: "codex", - input: { accessToken: "fixture-token-1", accountId: "account-1" }, - }); - writeFileSync(join(f.agentDir, "config.yml"), '"auth.broker.url": https://broker.invalid\n'); - expect(f.read()).toBeNull(); -}); -test("models config API key overrides sticky OAuth", () => { - const f = fixture(); - f.row(1); - f.sticky(1); - f.db.close(); - writeFileSync( - join(f.agentDir, "models.yml"), - "providers:\n openai-codex:\n apiKey: fixture-key\n", - ); - expect(f.read()).toBeNull(); -}); -test("provider mismatch and malformed sticky fail closed", () => { - const f = fixture(); - f.row(1, "anthropic"); - f.sticky(1); - f.db.close(); - expect(f.read()).toBeNull(); -}); - -test("Anthropic OAuth maps to Claude and changes are read on each request", () => { - const f = fixture(); - f.row(1, "anthropic"); - f.db.close(); - expect( - resolveOmpUsageReference("session-1", "anthropic", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: f.agentDir, - }), - ).toEqual({ - source: "claude", - input: { accessToken: "fixture-token-1" }, - }); - writeFileSync( - join(f.agentDir, "config.yml"), - "auth:\n broker:\n url: https://broker.invalid\n", - ); - expect( - resolveOmpUsageReference("session-1", "anthropic", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: f.agentDir, - }), - ).toBeNull(); -}); - -test("named profile ignores agent override and uses existing XDG profile data", () => { - const f = fixture(); - f.db.close(); - const root = mkdtempSync(join(tmpdir(), "omp-profile-")); - dirs.push(root); - const data = join(root, "xdg", "omp", "profiles", "work"); - mkdirSync(data, { recursive: true }); - const profileDb = new DatabaseSync(join(data, "agent.db")); - profileDb.exec(OMP_AUTH_SCHEMA); - profileDb - .prepare( - "INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause) VALUES (?, ?, ?, ?, ?)", - ) - .run( - 1, - "openai-codex", - "oauth", - JSON.stringify({ access: "profile-token", expires: future }), - null, - ); - profileDb.close(); - expect( - resolveOmpUsageReference( - "session-1", - "openai-codex", - { - OMP_PROFILE: "work", - PI_PROFILE: "other", - PI_CODING_AGENT_DIR: f.agentDir, - PI_CONFIG_DIR: join(root, "config"), - XDG_DATA_HOME: join(root, "xdg"), - }, - "linux", - ), - ).toEqual({ source: "codex", input: { accessToken: "profile-token" } }); -}); - -test("missing database returns null without creating a file", () => { - const home = mkdtempSync(join(tmpdir(), "omp-missing-db-")); - dirs.push(home); - const agentDir = join(home, "agent"); - mkdirSync(agentDir); - expect( - resolveOmpUsageReference("session-1", "openai-codex", { - OMP_PROFILE: "", - PI_CODING_AGENT_DIR: agentDir, - XDG_DATA_HOME: "", - }), - ).toBeNull(); - expect(existsSync(join(agentDir, "agent.db"))).toBe(false); -}); - -test("missing cache or credentials table returns null", () => { - const first = fixture(); - first.db.exec("DROP TABLE cache"); - first.db.close(); - expect(first.read()).toBeNull(); - const second = fixture(); - second.db.exec("DROP TABLE auth_credentials"); - second.db.close(); - expect(second.read()).toBeNull(); -}); diff --git a/packages/server/src/server/agent/providers/omp/usage-reference.ts b/packages/server/src/server/agent/providers/omp/usage-reference.ts deleted file mode 100644 index 384e59697e..0000000000 --- a/packages/server/src/server/agent/providers/omp/usage-reference.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { join } from "node:path"; - -import { z } from "zod"; -import { parse as parseYaml } from "yaml"; -import type { UsageReference } from "../../agent-sdk-types.js"; -import { resolveOmpDiagnosticPaths } from "./provider-config.js"; - -const stickySchema = z.object({ type: z.string(), credentialId: z.number().int().positive() }); -const rowSchema = z.object({ - id: z.number().int().positive(), - provider: z.string(), - credential_type: z.string(), - data: z.string(), - disabled_cause: z.string().nullable(), -}); -const oauthSchema = z.object({ - access: z.string().min(1), - expires: z.number(), - accountId: z.string().optional(), - orgId: z.string().optional(), -}); -const configSchema = z - .object({ - "auth.broker.url": z.string().optional(), - auth: z - .object({ broker: z.object({ url: z.string().optional() }).passthrough().optional() }) - .passthrough() - .optional(), - }) - .passthrough(); -const modelsSchema = z - .object({ - providers: z - .record(z.string(), z.object({ apiKey: z.unknown().optional() }).passthrough()) - .optional(), - }) - .passthrough(); - -interface UsageDb { - prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[] }; - close(): void; -} -const localRequire = createRequire(import.meta.url); - -function readYaml(file: string): unknown { - try { - return parseYaml(readFileSync(file, "utf8")); - } catch { - return undefined; - } -} - -function hasKeyOverride(provider: string, env: NodeJS.ProcessEnv, agentDir: string): boolean { - // Upstream: packages/ai/src/auth-broker/discover.ts:87-110,197-225,321-329. - const configFile = ["config.yml", "config.yaml"] - .map((name) => join(agentDir, name)) - .find((file) => existsSync(file)); - const config = configSchema.safeParse(configFile ? readYaml(configFile) : undefined); - if ( - env.OMP_AUTH_BROKER_URL || - (config.success && (config.data.auth?.broker?.url ?? config.data["auth.broker.url"])) - ) - return true; - // Upstream: packages/ai/src/auth/cascade.ts:22-84. - const models = modelsSchema.safeParse(readYaml(join(agentDir, "models.yml"))); - return models.success && models.data.providers?.[provider]?.apiKey !== undefined; -} - -function readCredential( - db: UsageDb, - provider: string, - sessionId: string, -): z.infer | null { - // Upstream: packages/ai/src/auth/affinity.ts:83-88,115-132; sqlite-credential-store.ts:447-448,711-722. - const cache = db - .prepare( - "SELECT value FROM cache WHERE key = ? AND expires_at > CAST(strftime('%s','now') AS INTEGER)", - ) - .get(`session:sticky:${provider}:${sessionId}`) as { value?: unknown } | undefined; - if (cache) { - const sticky = stickySchema.safeParse(JSON.parse(String(cache.value))); - if (!sticky.success || sticky.data.type !== "oauth") return null; - return parseOAuthCredential( - db - .prepare( - "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE id = ?", - ) - .get(sticky.data.credentialId), - provider, - ); - } - // Upstream: packages/ai/src/auth/cascade.ts:276-345 selects OAuth before env or stored API keys. - const rows = db - .prepare( - "SELECT id, provider, credential_type, data, disabled_cause FROM auth_credentials WHERE provider = ? AND credential_type = 'oauth' AND disabled_cause IS NULL", - ) - .all(provider); - const validRows = rows - .map((row) => parseOAuthCredential(row, provider)) - .filter((row): row is z.infer => row !== null); - return validRows.length === 1 ? validRows[0] : null; -} - -function parseOAuthCredential( - value: unknown, - provider: string, -): z.infer | null { - const row = rowSchema.safeParse(value); - if ( - !row.success || - row.data.provider !== provider || - row.data.credential_type !== "oauth" || - row.data.disabled_cause !== null - ) - return null; - try { - // Upstream: packages/ai/src/auth/sqlite-credential-store.ts:117-155 stores OAuth data as JSON without type. - const oauth = oauthSchema.safeParse(JSON.parse(row.data.data)); - return oauth.success && oauth.data.expires > Date.now() ? oauth.data : null; - } catch { - return null; - } -} - -/** Read OMP's current credential attribution without changing its store. */ -export function resolveOmpUsageReference( - sessionId: string, - provider: string, - env: NodeJS.ProcessEnv, - platform: NodeJS.Platform = process.platform, -): UsageReference | null { - let source: string; - if (provider === "openai-codex") source = "codex"; - else if (provider === "anthropic") source = "claude"; - else return null; - if (!sessionId) return null; - // Upstream: packages/utils/src/dirs.ts:82-94,330-343,583-585 (profile, override, agent dir). - const paths = resolveOmpDiagnosticPaths(env, undefined, platform); - if (hasKeyOverride(provider, env, paths.agentDir)) return null; - let db: UsageDb; - try { - const sqlite = localRequire("node:sqlite") as { - DatabaseSync: new (path: string, options: { readOnly: boolean }) => UsageDb; - }; - db = new sqlite.DatabaseSync(paths.agentDb, { readOnly: true }); - } catch { - return null; - } - try { - const oauth = readCredential(db, provider, sessionId); - if (!oauth) return null; - if (source === "claude") { - return { source, input: { accessToken: oauth.access } }; - } - return { - source, - input: { - accessToken: oauth.access, - ...(oauth.accountId ? { accountId: oauth.accountId } : {}), - }, - }; - } catch { - return null; - } finally { - db.close(); - } -} diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 0634ec25cb..15bae2f56c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -403,100 +403,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }, ); - test("usage reference follows the active OpenCode model and OAuth account", async () => { - const cwd = tmpCwd(); - const runtime = new TestOpenCodeHarness(); - const openCode = new TestOpenCodeClient(); - runtime.enqueueClient(openCode); - const client = new OpenCodeAgentClient( - logger, - { - env: { - OPENCODE_AUTH_CONTENT: JSON.stringify({ - openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" }, - "opencode-go": { type: "api", key: "go-key" }, - }), - }, - }, - { serverManager: runtime, createClient: runtime.createClient }, - ); - const session = await client.createSession(buildConfig(cwd)); - await session.setModel?.("openai/gpt-5"); - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "oauth-token", accountId: "acct-1" }, - }); - await session.setModel?.("opencode-go/qwen"); - expect(await session.getUsageReference?.()).toEqual({ - source: "opencode-go", - input: { apiKey: "go-key" }, - }); - await session.setModel?.("other/model"); - expect(await session.getUsageReference?.()).toBeNull(); - const events: AgentStreamEvent[] = []; - session.subscribe((event) => events.push(event)); - const sessionId = (await session.getRuntimeInfo()).sessionId; - openCode.emitEvent({ - type: "message.updated", - properties: { - info: { - id: "msg_changed_model", - sessionID: sessionId, - role: "user", - model: { providerID: "openai", modelID: "gpt-5" }, - }, - }, - } as OpenCodeEvent); - await vi.waitFor(async () => - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "oauth-token", accountId: "acct-1" }, - }), - ); - expect(events).toContainEqual({ - type: "model_changed", - provider: "opencode", - runtimeInfo: { - provider: "opencode", - sessionId, - model: "openai/gpt-5", - modeId: null, - thinkingOptionId: null, - }, - }); - await session.close(); - rmSync(cwd, { recursive: true, force: true }); - }); - - test("usage reference ignores another auth entry with a different type", async () => { - const cwd = tmpCwd(); - const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); - const client = new OpenCodeAgentClient( - logger, - { - env: { - OPENCODE_AUTH_CONTENT: JSON.stringify({ - openai: { type: "api", key: "other-key" }, - "opencode-go": { type: "api", key: "go-key" }, - }), - }, - }, - { serverManager: runtime, createClient: runtime.createClient }, - ); - const session = await client.createSession(buildConfig(cwd)); - try { - await session.setModel?.("opencode-go/qwen"); - expect(await session.getUsageReference?.()).toEqual({ - source: "opencode-go", - input: { apiKey: "go-key" }, - }); - } finally { - await session.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }); - test("creates a session with valid id and provider", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index b2cab99c6e..70f7f367e1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -12,8 +12,6 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { randomBytes } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; @@ -58,7 +56,6 @@ import { type SteerResult, type ToolCallDetail, type ToolCallTimelineItem, - type UsageReference, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { @@ -1503,7 +1500,6 @@ export class OpenCodeAgentClient implements AgentClient { false, unbindBridge, connectServer, - { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -1558,7 +1554,6 @@ export class OpenCodeAgentClient implements AgentClient { registeredAcquisition !== null, unbindBridge, connectServer, - { ...process.env, ...this.runtimeSettings?.env, ...launchContext?.env }, ); } catch (error) { await connection.release(); @@ -3365,67 +3360,6 @@ interface OpenCodeServerConnection { release: () => Promise; } -const openCodeOAuthAuthSchema = z - .object({ - openai: z - .object({ - type: z.literal("oauth"), - access: z.string().min(1), - accountId: z.string().optional(), - }) - .passthrough() - .optional(), - }) - .passthrough(); -const openCodeGoAuthSchema = z - .object({ - "opencode-go": z - .object({ type: z.literal("api"), key: z.string().min(1) }) - .passthrough() - .optional(), - }) - .passthrough(); - -async function readOpenCodeUsageAuth(env: NodeJS.ProcessEnv): Promise { - try { - const content = - env.OPENCODE_AUTH_CONTENT ?? - (await fs.readFile( - path.join( - env.XDG_DATA_HOME || path.join(env.HOME || os.homedir(), ".local", "share"), - "opencode", - "auth.json", - ), - "utf8", - )); - return JSON.parse(content); - } catch { - return null; - } -} - -function resolveOpenCodeUsageReference(model: string, auth: unknown): UsageReference | null { - if (!auth) return null; - if (model.startsWith("openai/")) { - const parsed = openCodeOAuthAuthSchema.safeParse(auth); - if (!parsed.success) return null; - const credential = parsed.data.openai; - if (!credential) return null; - return { - source: "codex", - input: { - accessToken: credential.access, - ...(credential.accountId ? { accountId: credential.accountId } : {}), - }, - }; - } - const parsed = openCodeGoAuthSchema.safeParse(auth); - if (!parsed.success) return null; - const credential = parsed.data["opencode-go"]; - if (!credential) return null; - return { source: "opencode-go", input: { apiKey: credential.key } }; -} - class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; @@ -3514,7 +3448,6 @@ class OpenCodeAgentSession implements AgentSession { private readonly externallyDriven = false, releaseBridge?: () => void, connectServer?: () => Promise, - private readonly usageEnv: NodeJS.ProcessEnv = process.env, ) { this.config = config; this.server = { client, events, url: serverUrl ?? "", release: releaseServer }; @@ -3620,13 +3553,6 @@ class OpenCodeAgentSession implements AgentSession { }; } - async getUsageReference(): Promise { - const model = this.config.model; - if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null; - const auth = await readOpenCodeUsageAuth(this.usageEnv); - return resolveOpenCodeUsageReference(model, auth); - } - async setModel(modelId: string | null): Promise { await this.reconnectIfServerExited(); const normalizedModelId = diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index c8146bf2d1..a441cebcb5 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -355,7 +355,8 @@ test.each([ await resumed?.close(); await original?.close(); await client.shutdown(); - await rm(root, { recursive: true, force: true }); + // Windows can retain a transient executable lock after provider shutdown. + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, 240_000, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 7308185db0..969b221f02 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -23,7 +23,7 @@ import { PiRpcAgentSession, transformPiModels, } from "./agent.js"; -import { FakePi, type FakePiSession } from "./test-utils/fake-pi.js"; +import { FakePi } from "./test-utils/fake-pi.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; @@ -202,71 +202,6 @@ async function createSession( return { pi, session, events }; } -test("Pi usage reference follows the current OAuth model and agent directory", async () => { - const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-usage-")); - try { - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ - "openai-codex": { - type: "oauth", - access: "codex-token", - accountId: "account-1", - refresh: "refresh", - expires: Date.now() + 60_000, - }, - anthropic: { - type: "oauth", - access: "claude-token", - refresh: "refresh", - expires: Date.now() + 60_000, - }, - }), - ); - const pi = new FakePi(); - let runtime!: FakePiSession; - pi.queueSessionSetup((sessionRuntime) => { - runtime = sessionRuntime; - runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; - }); - const session = await createClient(pi).createSession(createConfig(), { - env: { PI_CODING_AGENT_DIR: agentDir }, - }); - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "codex-token", accountId: "account-1" }, - }); - runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; - expect(await session.getUsageReference?.()).toEqual({ - source: "claude", - input: { accessToken: "claude-token" }, - }); - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ - "openai-codex": { type: "oauth", access: "codex-token" }, - anthropic: { type: "api_key", key: "api-key" }, - }), - ); - runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; - expect(await session.getUsageReference?.()).toEqual({ - source: "codex", - input: { accessToken: "codex-token" }, - }); - runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; - writeFileSync( - path.join(agentDir, "auth.json"), - JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }), - ); - expect(await session.getUsageReference?.()).toBeNull(); - runtime.state.model = { provider: "other", id: "other", name: "Other" }; - expect(await session.getUsageReference?.()).toBeNull(); - await session.close(); - } finally { - rmSync(agentDir, { recursive: true, force: true }); - } -}); - test("forwards launch-context env to the Pi process launch", async () => { const pi = new FakePi(); const client = createClient(pi); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 5d7966367e..5fd7b051ef 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; @@ -243,7 +242,6 @@ interface PiRpcAgentSessionOptions { extensionTimeoutMs?: number; logger: Logger; usagePollScheduler?: PiUsagePollScheduler; - usageEnv?: Record; } interface PiResumeConfig { @@ -530,27 +528,6 @@ function resolvePiAgentDir(env: Record | undefined): string { return resolvePath(configured); } -const piCodexUsageAuthSchema = z - .object({ - "openai-codex": z - .object({ - type: z.literal("oauth"), - access: z.string().min(1), - accountId: z.string().optional(), - }) - .passthrough() - .optional(), - }) - .passthrough(); -const piClaudeUsageAuthSchema = z - .object({ - anthropic: z - .object({ type: z.literal("oauth"), access: z.string().min(1) }) - .passthrough() - .optional(), - }) - .passthrough(); - function readPiGlobalMcpConfig(env: Record | undefined): Record { const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json"); if (!existsSync(globalConfigPath)) { @@ -1145,35 +1122,6 @@ export class PiRpcAgentSession implements AgentSession { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; - async getUsageReference() { - await this.refreshState(); - const provider = this.state.model?.provider; - let source: string | null = null; - if (provider === "openai-codex") source = "codex"; - if (provider === "anthropic") source = "claude"; - if (!source || !provider) return null; - try { - const auth: unknown = JSON.parse( - await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"), - ); - if (provider === "openai-codex") { - const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"]; - if (!credential) return null; - return { - source, - input: { - accessToken: credential.access, - ...(credential.accountId ? { accountId: credential.accountId } : {}), - }, - }; - } - const credential = piClaudeUsageAuthSchema.parse(auth).anthropic; - return credential ? { source, input: { accessToken: credential.access } } : null; - } catch { - return null; - } - } - private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); @@ -1201,7 +1149,6 @@ export class PiRpcAgentSession implements AgentSession { private readonly currentModeId: string | null; private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; - private readonly usageEnv?: Record; private closed = false; private readonly closeController = new AbortController(); private readonly pendingExtensionHydrations = new Set>(); @@ -1221,7 +1168,6 @@ export class PiRpcAgentSession implements AgentSession { this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; this.extensionHost = createPiExtensionHost(this.logger); - this.usageEnv = options.usageEnv; this.usagePoller = new PiUsagePoller({ scheduler: options.usagePollScheduler, readStats: () => this.runtimeSession.getSessionStats(), @@ -2515,7 +2461,6 @@ export class PiRpcAgentClient implements AgentClient { extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, - usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); @@ -2579,7 +2524,6 @@ export class PiRpcAgentClient implements AgentClient { extensionTimeoutMs: this.providerParams.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, - usageEnv: mcpEnv, }); } catch (error) { await runtimeSession.close().catch(() => undefined); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index fa260011c3..214544c39c 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -150,7 +150,6 @@ const INBOUND_PERMISSION = { project_icon_request: "workspace.read", "provider.usage.list.request": "daemon.read", "usage.list_reports.request": "daemon.read", - "agent.resolve_usage_report.request": "daemon.read", provider_diagnostic_request: "daemon.read", pull_request_timeline_request: "workspace.read", "push.unregister.request": "workspace.read", @@ -376,7 +375,6 @@ const OUTBOUND_PERMISSION = { project_icon_response: "workspace.read", "provider.usage.list.response": "daemon.read", "usage.list_reports.response": "daemon.read", - "agent.resolve_usage_report.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], pull_request_timeline_response: "workspace.read", diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index f11b7c50a4..830d12251a 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -28,7 +28,6 @@ import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { readPluginProviderIcon } from "./provider-icon.js"; import { UsageSourceRegistry } from "./usage-sources/index.js"; import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; -import type { UsageReference } from "../agent/agent-sdk-types.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); @@ -144,18 +143,10 @@ export class PluginService { return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); } - listUsageReports(options?: { - forceRefresh?: boolean; - reportIds?: string[]; - references?: UsageReference[]; - }) { + listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[] }) { return this.usageSources.listReports(options); } - resolveUsageReference(reference: UsageReference) { - return this.usageSources.resolveReference(reference); - } - listLegacyUsage() { return this.usageSources.listLegacyUsage(); } @@ -577,7 +568,6 @@ export class PluginService { label: source.label, icon: source.icon, discover: async () => { - if (!source.discover) return []; const result = await this.runtime.discoverUsage(pluginId, source.id); if (!Array.isArray(result)) throw new Error(`Invalid usage discovery from ${source.id}`); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 16ee512c37..cf1cd2c32a 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -19,7 +19,6 @@ export interface PluginUsageSourceMetadata { id: string; label: string; icon?: string; - discover: boolean; } export type PluginProcessRequest = @@ -108,7 +107,6 @@ const usageSourceMetadataSchema = z id: z.string().min(1), label: z.string().min(1), icon: z.string().optional(), - discover: z.boolean(), }) .strict(); const providerConnectRequestSchema = z diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1ed1bd6fc3..2c371d6ba1 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -133,6 +133,7 @@ export function createPluginWorker(options: { !/^[a-z][a-z0-9._-]*$/.test(id) || !source.label.trim() || typeof source.fetch !== "function" || + typeof source.discover !== "function" || !source.input || typeof source.input.parseAsync !== "function" ) { @@ -289,7 +290,6 @@ export function createPluginWorker(options: { icon: source.icon ? await readPluginProviderIcon(message.pluginDirectory, source.icon) : undefined, - discover: !!source.discover, })), ); send({ @@ -336,8 +336,7 @@ export function createPluginWorker(options: { void (async () => { const source = usageSources.get(message.sourceId); if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); - if (message.type === "usage.discover") - return jsonTransportValue(source.discover ? await source.discover() : []); + if (message.type === "usage.discover") return jsonTransportValue(await source.discover()); const input = await source.input.parseAsync(message.input); return jsonTransportValue( message.type === "usage.identify" diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index c09ef9ebe2..5615bef6ac 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2030,3 +2030,29 @@ export default function contribute(plugin: any) { } }); }); + +it("rejects a usage source without discovery at registration", async () => { + const directory = await createPlugin( + "missing-discovery", + ` +export default function contribute(server) { + server.registerUsageSource({ + id: "missing-discovery", + label: "Missing discovery", + input: { parseAsync: async (value) => value }, + identify: async () => ({ key: "account" }), + fetch: async () => ({ status: "available", windows: [] }), + }); + return () => {}; +} +`, + ); + const runtime = createTestRuntime(); + try { + await expect(runtime.startPlugin("missing-discovery", directory)).rejects.toThrow( + "Invalid usage source: missing-discovery", + ); + } finally { + await runtime.stopAll(); + } +}); diff --git a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts deleted file mode 100644 index c4f2287bbf..0000000000 --- a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { expect, test } from "vitest"; -import { ACPAgentSession } from "../agent/providers/acp-agent.js"; -import type { UsageReference } from "../agent/agent-sdk-types.js"; -import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; -import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js"; -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { DaemonClient } from "../test-utils/daemon-client.js"; -import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { BuiltinPluginLoader } from "./builtin/index.js"; - -const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); -const fixtureBuiltins = () => new BuiltinPluginLoader(fixtureRoot, ["session-usage-reference"]); - -test("resolves a provider plugin session reference through its usage source", async () => { - const directory = fileURLToPath( - new URL("./test-fixtures/session-usage-reference/", import.meta.url), - ); - const daemon = await createTestPaseoDaemon({ - pluginsEnabled: false, - builtinPlugins: fixtureBuiltins(), - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - const agent = await client.createAgent({ - provider: "fixture-session-provider", - cwd: directory, - }); - const result = await client.resolveAgentUsageReport({ agentId: agent.id }); - expect(result.reportId).toBe("fixture-session-usage:from-session"); - expect( - (await client.listUsageReports({ reportIds: [result.reportId!] })).reports[0], - ).toMatchObject({ - id: result.reportId, - sourceId: "fixture-session-usage", - report: { windows: [{ usedPct: 31 }] }, - }); - const reports = await client.listUsageReports(); - expect(reports.reports.map((entry) => entry.id)).toContain( - "fixture-session-usage:from-session", - ); - const missing = await client.createAgent({ - provider: "fixture-session-provider", - cwd: directory, - model: "missing", - }); - expect((await client.resolveAgentUsageReport({ agentId: missing.id })).reportId).toBeNull(); - } finally { - await client.close(); - await daemon.close(); - } -}, 60_000); - -test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => { - const directory = fileURLToPath( - new URL("./test-fixtures/session-usage-reference/", import.meta.url), - ); - const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const; - const logger = createTestLogger(); - const references = new Map(); - const claude = await new ClaudeAgentClient({ - logger, - resolveBinary: async () => "/test/claude/bin", - }).createSession( - { provider: "claude", cwd: directory }, - { - env: { - HOME: directory, - CLAUDE_CONFIG_DIR: "", - ANTHROPIC_BASE_URL: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_AUTH_TOKEN: "", - }, - }, - ); - references.set("claude", (await claude.getUsageReference?.()) ?? null); - await claude.close(); - const codex = new CodexAppServerAgentSession( - { provider: "codex", cwd: directory }, - null, - logger, - () => { - throw new Error("Codex runtime should not start"); - }, - {}, - false, - false, - false, - undefined, - "interactive", - { HOME: directory, OPENAI_BASE_URL: "" }, - ); - references.set("codex", await codex.getUsageReference()); - for (const provider of providers.slice(2)) { - const session = new ACPAgentSession( - { provider, cwd: directory }, - { - provider, - logger, - defaultCommand: ["unused"], - defaultModes: [], - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - }, - ); - references.set(provider, await session.getUsageReference()); - } - const agentClients = Object.fromEntries( - providers.map((provider) => { - const client = createTestAgentClient(provider); - const createSession = client.createSession.bind(client); - client.createSession = async (...args) => { - const session = await createSession(...args); - session.getUsageReference = async () => references.get(provider) ?? null; - return session; - }; - return [provider, client]; - }), - ); - const daemon = await createTestPaseoDaemon({ - pluginsEnabled: false, - builtinPlugins: fixtureBuiltins(), - agentClients, - providerOverrides: { - cursor: { extends: "acp", label: "Cursor", command: ["cursor-agent", "acp"] }, - kimi: { extends: "acp", label: "Kimi", command: ["kimi", "acp"] }, - "generic-acp": { extends: "acp", label: "Generic ACP", command: ["generic-acp", "acp"] }, - }, - }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); - try { - await client.connect(); - for (const provider of providers) { - const agent = await client.createAgent({ provider, cwd: directory }); - const result = await client.resolveAgentUsageReport({ agentId: agent.id }); - expect(result.reportId, provider).toBe(`${provider}:default`); - } - } finally { - await client.close(); - await daemon.close(); - } -}, 60_000); diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts deleted file mode 100644 index c5aeaede16..0000000000 --- a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { - negotiateProviderCapabilities, - type ProviderEvent, -} from "@getpaseo/plugin/server/provider"; -import { z } from "zod"; - -export default function contribute(server: PluginServerContext) { - for (const id of ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"]) { - let input = z.object({}).strict(); - if (id === "claude") input = z.object({ configDir: z.string() }).strict(); - if (id === "codex") input = z.object({ codexHome: z.string() }).strict(); - server.registerUsageSource({ - id, - label: id, - input, - identify: async () => ({ key: "default" }), - fetch: async () => ({ - status: "available", - windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], - }), - }); - } - server.registerUsageSource({ - id: "fixture-session-usage", - label: "Fixture session usage", - input: z.object({ account: z.string() }).strict(), - identify: async (input) => ({ key: (input as { account: string }).account }), - fetch: async () => ({ - status: "available", - windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }], - }), - }); - server.registerProvider({ - id: "fixture-session-provider", - label: "Fixture session provider", - async connect(request) { - const capabilities = negotiateProviderCapabilities(request.capabilities, [ - "prompt.message", - "session.usage_reference", - ]); - const listeners = new Set<(event: ProviderEvent) => void>(); - const models = new Map(); - const emit = (event: ProviderEvent) => { - for (const listener of listeners) listener(event); - }; - return { - version: 1, - capabilities, - async send(input) { - if (input.type === "catalog") - emit({ - type: "catalog", - requestId: input.requestId, - catalog: { - models: [ - { id: "fixture", label: "Fixture" }, - { id: "missing", label: "Missing" }, - ], - modes: [], - }, - }); - if (input.type === "session.open") { - models.set(input.sessionId, input.config.model); - emit({ - type: "session.opened", - requestId: input.requestId, - sessionId: input.sessionId, - capabilities, - restoration: "core", - cwd: input.config.cwd, - }); - emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); - } - if (input.type === "session.usage_reference") - emit({ - type: "usage_reference", - requestId: input.requestId, - reference: { - source: - models.get(input.sessionId) === "missing" - ? "missing-source" - : "fixture-session-usage", - input: { account: "from-session" }, - }, - }); - if (input.type === "session.close") - emit({ type: "request.completed", requestId: input.requestId }); - }, - onEvent(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - async close() { - listeners.clear(); - }, - }; - }, - }); - return () => {}; -} diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json deleted file mode 100644 index f7b37baee9..0000000000 --- a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/paseo-plugin.json +++ /dev/null @@ -1 +0,0 @@ -{ "id": "session-usage-reference", "requirements": { "paseo": ">=0.9.2" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts index 48a4ee5eec..5845073a7c 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -23,7 +23,7 @@ export default function contribute(server: PluginServerContext) { fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + windows: [{ id: "count", label: "Count", usedPct: fetches }], }; }, }); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts index a8f1f28211..347e8050d0 100644 --- a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -23,7 +23,7 @@ export default function contribute(server: PluginServerContext) { fetches++; return { status: "available", - windows: [{ id: "count", label: "Count", usedPct: fetches, headline: true }], + windows: [{ id: "count", label: "Count", usedPct: fetches }], }; }, }); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 2b8d0b672b..41fa0ef2cb 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -4,7 +4,7 @@ import { UsageSourceRegistry } from "./index.js"; function source(input: { id: string; discover?: () => Promise; - identify?: (value: unknown) => Promise<{ key: string } | null>; + identify?: (value: unknown) => Promise<{ key: string; label?: string } | null>; fetch?: (value: unknown) => Promise; }) { return { @@ -18,35 +18,26 @@ function source(input: { }; } -test("identifies accounts without fetching and preserves IDs across token rotation", async () => { +test("discovery preserves account IDs and updates input after token rotation", async () => { const registry = new UsageSourceRegistry(); - let fetches = 0; let token = "old"; registry.register( source({ id: "codex", discover: async () => [{ account: "work", token }], - fetch: async (input) => { - fetches++; - return { - status: "available", - windows: [{ id: "token", label: (input as { token: string }).token }], - }; - }, + fetch: async (input) => ({ + status: "available", + windows: [{ id: "token", label: (input as { token: string }).token }], + }), }), ); - expect( - await registry.resolveReference({ source: "codex", input: { account: "work", token } }), - ).toBe("codex:work"); - expect(fetches).toBe(0); + const first = await registry.listReports(); + expect(first.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(first[0]?.report.windows[0]?.label).toBe("old"); token = "new"; - expect( - await registry.resolveReference({ source: "codex", input: { account: "work", token } }), - ).toBe("codex:work"); - expect( - (await registry.listReports({ reportIds: ["codex:work"] }))[0]?.report.windows[0]?.label, - ).toBe("new"); - expect(fetches).toBe(1); + const refreshed = await registry.listReports({ forceRefresh: true }); + expect(refreshed.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(refreshed[0]?.report.windows[0]?.label).toBe("new"); }); test("coalesces per account, caches errors, and refreshes only requested IDs", async () => { @@ -100,9 +91,6 @@ test("invalid keys become source errors and missing identities produce no report const reports = await registry.listReports(); expect(reports.map((entry) => entry.id)).toEqual(["source:!error"]); expect(reports[0]?.report.status).toBe("error"); - expect( - await registry.resolveReference({ source: "source", input: { account: "none" } }), - ).toBeNull(); }); test("legacy listing uses oldest fetchedAt", async () => { @@ -111,62 +99,12 @@ test("legacy listing uses oldest fetchedAt", async () => { registry.register( source({ id: "source", discover: async () => [{ account: "a" }, { account: "b" }] }), ); - await registry.listReports({ references: [{ source: "source", input: { account: "a" } }] }); + await registry.listReports(); now = 2000; await registry.listReports({ reportIds: ["source:b"], forceRefresh: true }); expect((await registry.listLegacyUsage()).fetchedAt).toBe(new Date(1000).toISOString()); }); -test("two Codex homes and a token route resolve to their vendor account IDs", async () => { - const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const { identify } = await import("../../../../../../plugins/codex-usage-source/server/usage.js"); - const personal = await mkdtemp(join(tmpdir(), "usage-personal-")); - const work = await mkdtemp(join(tmpdir(), "usage-work-")); - try { - const token = (accountId: string, email: string) => - `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, email })).toString("base64url")}.signature`; - await writeFile( - join(personal, "auth.json"), - JSON.stringify({ - tokens: { - account_id: "personal-id", - access_token: token("personal-id", "personal@example.test"), - }, - }), - ); - await writeFile( - join(work, "auth.json"), - JSON.stringify({ - tokens: { account_id: "work-id", access_token: token("work-id", "work@example.test") }, - }), - ); - const registry = new UsageSourceRegistry(); - registry.register( - source({ - id: "codex", - discover: async () => [{ codexHome: personal }, { codexHome: work }], - identify: async (input) => identify(input as Parameters[0]), - }), - ); - - expect( - await registry.resolveReference({ - source: "codex", - input: { accessToken: token("work-id", "work@example.test") }, - }), - ).toBe("codex:work-id"); - expect((await registry.listReports()).map((entry) => [entry.id, entry.account.label])).toEqual([ - ["codex:personal-id", "personal@example.test"], - ["codex:work-id", "work@example.test"], - ]); - } finally { - await rm(personal, { recursive: true, force: true }); - await rm(work, { recursive: true, force: true }); - } -}); - test("concurrent requests for the same ID share one vendor fetch", async () => { let finish!: (report: unknown) => void; const response = new Promise((resolve) => { @@ -177,15 +115,15 @@ test("concurrent requests for the same ID share one vendor fetch", async () => { registry.register( source({ id: "coalesced", + discover: async () => [{ account: "one" }], fetch: async () => { fetches++; return response; }, }), ); - await registry.resolveReference({ source: "coalesced", input: { account: "one" } }); - const first = registry.listReports({ reportIds: ["coalesced:one"] }); - const second = registry.listReports({ reportIds: ["coalesced:one"], forceRefresh: true }); + const first = registry.listReports(); + const second = registry.listReports(); finish({ status: "available", windows: [] }); const [one, two] = await Promise.all([first, second]); expect(one[0]).toBe(two[0]); @@ -211,11 +149,13 @@ test("source failure IDs cannot collide with an account named error", async () = test("expired cached entries are pruned when a new report is written", async () => { let now = 0; const registry = new UsageSourceRegistry(() => now, 100); - registry.register(source({ id: "source" })); - await registry.resolveReference({ source: "source", input: { account: "old" } }); + let account = "old"; + registry.register(source({ id: "source", discover: async () => [{ account }] })); + await registry.listReports(); await registry.listReports({ reportIds: ["source:old"] }); now = 101; - await registry.resolveReference({ source: "source", input: { account: "new" } }); + account = "new"; + await registry.listReports(); await registry.listReports({ reportIds: ["source:new"] }); const cache = Reflect.get(registry, "cache") as Map; expect([...cache.keys()]).toEqual(["source:new"]); @@ -231,11 +171,23 @@ test("discovery failures have an ID outside the account namespace", async () => }, }), ); - expect(await registry.resolveReference({ source: "source", input: { account: "error" } })).toBe( - "source:error", - ); const discovered = await registry.listReports(); expect(discovered.map((entry) => entry.id)).toEqual(["source:!error"]); - const both = await registry.listReports({ reportIds: ["source:error", "source:!error"] }); - expect(both.map((entry) => entry.report.status)).toEqual(["available", "error"]); +}); + +test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => { + const registry = new UsageSourceRegistry(() => 1000); + registry.register( + source({ + id: "claude", + discover: async () => [{ account: "work" }, { account: "personal" }, { account: "default" }], + identify: async (input) => { + const { account } = input as { account: string }; + return { key: account, label: account === "default" ? undefined : account }; + }, + }), + ); + expect( + (await registry.listLegacyUsage()).providers.map((provider) => provider.displayName), + ).toEqual(["claude (work)", "claude (personal)", "claude"]); }); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index a6705114de..ef8b4bd75f 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -3,7 +3,6 @@ import { type ProviderUsage, type UsageReportEntry, } from "@getpaseo/protocol/messages"; -import type { UsageReference } from "../../agent/agent-sdk-types.js"; export interface UsageSource { id: string; @@ -43,12 +42,6 @@ export class UsageSourceRegistry { for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); } - async resolveReference(reference: UsageReference): Promise { - const source = this.sources.get(reference.source); - if (!source) return null; - return this.identify(source, reference.input); - } - private async identify(source: UsageSource, input: unknown): Promise { try { const account = await source.identify(input); @@ -71,9 +64,9 @@ export class UsageSourceRegistry { } async listReports( - options: { forceRefresh?: boolean; reportIds?: string[]; references?: UsageReference[] } = {}, + options: { forceRefresh?: boolean; reportIds?: string[] } = {}, ): Promise { - const ids = options.reportIds ?? (await this.discoverReportIds(options.references ?? [])); + const ids = options.reportIds ?? (await this.discoverReportIds()); return Promise.all( [...new Set(ids)] .filter((id) => this.known.has(id) || this.cache.has(id)) @@ -81,7 +74,7 @@ export class UsageSourceRegistry { ); } - private async discoverReportIds(references: UsageReference[]): Promise { + private async discoverReportIds(): Promise { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { try { @@ -97,11 +90,10 @@ export class UsageSourceRegistry { } }), ); - const live = await Promise.all(references.map((reference) => this.resolveReference(reference))); - return [...discovered.flat(), ...live.filter((id): id is string => id !== null)]; + return discovered.flat(); } - // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { const reports = await this.listReports(); return { @@ -113,7 +105,9 @@ export class UsageSourceRegistry { : new Date(this.now()).toISOString(), providers: reports.map((entry) => ({ providerId: entry.sourceId, - displayName: entry.sourceLabel, + displayName: entry.account.label + ? `${entry.sourceLabel} (${entry.account.label})` + : entry.sourceLabel, status: entry.report.status, planLabel: entry.report.planLabel ?? null, windows: entry.report.windows, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f83040c8c9..1307cf808f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -134,7 +134,6 @@ import { type AgentPermissionResponse, type AgentRunOptions, type AgentSessionConfig, - type UsageReference, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; @@ -514,9 +513,7 @@ export interface SessionOptions { listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[]; - references?: UsageReference[]; }): Promise; - resolveUsageReference(reference: UsageReference): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; @@ -999,8 +996,6 @@ export class Session { }); this.usageSession = new UsageSession({ emit: (msg) => this.emit(msg), - listAgents: () => this.agentManager.listAgents(), - getAgent: (agentId) => this.agentManager.getAgent(agentId), runtime: pluginRuntime, logger: this.sessionLogger, }); @@ -3016,8 +3011,6 @@ export class Session { return this.usageSession.handleLegacyList(msg); case "usage.list_reports.request": return this.usageSession.handleListReports(msg); - case "agent.resolve_usage_report.request": - return this.usageSession.handleResolveAgentReport(msg); default: return undefined; } diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index 7914af3872..a56d6f6c9a 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -1,18 +1,11 @@ import pino from "pino"; import { expect, test } from "vitest"; -import type { AgentSession } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { UsageSession } from "./usage-session.js"; -test("collects live references for usage reports and resolves an agent report", async () => { +test("lists reports from usage sources", async () => { const emitted: SessionOutboundMessage[] = []; - const references: unknown[] = []; - const reference = { source: "fixture", input: { account: "one" } }; - const agent = { - session: { - getUsageReference: async () => reference, - } as AgentSession, - }; + const requested: Array<{ forceRefresh?: boolean; reportIds?: string[] }> = []; const entry = { id: "fixture:one", account: {}, @@ -23,17 +16,11 @@ test("collects live references for usage reports and resolves an agent report", }; const usage = new UsageSession({ emit: (message) => emitted.push(message), - listAgents: () => [agent, { session: null }], - getAgent: (id) => (id === "one" ? agent : null), runtime: { async listUsageReports(options) { - references.push(options.references); + requested.push(options); return [entry]; }, - async resolveUsageReference(value) { - references.push(value); - return entry.id; - }, async listLegacyUsage() { return { fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }; }, @@ -42,15 +29,9 @@ test("collects live references for usage reports and resolves an agent report", }); await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); - await usage.handleResolveAgentReport({ - type: "agent.resolve_usage_report.request", - requestId: "one", - agentId: "one", - }); - expect(references).toEqual([[reference], reference]); - expect(emitted.map((message) => message.type)).toEqual([ - "usage.list_reports.response", - "agent.resolve_usage_report.response", + expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]); + expect(emitted).toEqual([ + { type: "usage.list_reports.response", payload: { requestId: "list", reports: [entry] } }, ]); }); @@ -58,15 +39,10 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ emit: (message) => emitted.push(message), - listAgents: () => [], - getAgent: () => null, runtime: { async listUsageReports() { return []; }, - async resolveUsageReference() { - return null; - }, async listLegacyUsage(): Promise { throw new Error("quota service down"); }, @@ -79,24 +55,3 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = payload: { requestId: "u1", code: "provider_usage_list_failed" }, }); }); - -test("unknown agent returns agent_not_found", async () => { - const emitted: SessionOutboundMessage[] = []; - const usage = new UsageSession({ - emit: (message) => emitted.push(message), - listAgents: () => [], - getAgent: () => null, - runtime: { - listUsageReports: async () => [], - resolveUsageReference: async () => null, - listLegacyUsage: async () => ({ fetchedAt: "", providers: [] }), - }, - logger: pino({ level: "silent" }), - }); - await usage.handleResolveAgentReport({ - type: "agent.resolve_usage_report.request", - requestId: "missing", - agentId: "missing", - }); - expect(emitted[0]).toMatchObject({ type: "rpc_error", payload: { code: "agent_not_found" } }); -}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index 6665a7454f..c94eaf2fcf 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -1,23 +1,14 @@ import type pino from "pino"; import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; -import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; -interface UsageAgent { - session: AgentSession | null; -} - export interface UsageSessionOptions { emit(message: SessionOutboundMessage): void; - listAgents(): UsageAgent[]; - getAgent(agentId: string): UsageAgent | null; runtime?: { listUsageReports(options: { forceRefresh?: boolean; reportIds?: string[]; - references: UsageReference[]; }): Promise; - resolveUsageReference(reference: UsageReference): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; logger: pino.Logger; @@ -31,22 +22,9 @@ export class UsageSession { ): Promise { try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const references = - msg.reportIds === undefined - ? ( - await Promise.all( - this.options - .listAgents() - .map( - async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null, - ), - ) - ).filter((reference): reference is UsageReference => reference !== null) - : []; const reports = await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, reportIds: msg.reportIds, - references, }); this.options.emit({ type: "usage.list_reports.response", @@ -57,29 +35,6 @@ export class UsageSession { } } - async handleResolveAgentReport( - msg: Extract, - ): Promise { - try { - if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const agent = this.options.getAgent(msg.agentId); - if (!agent) { - this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found"); - return; - } - const reference = (await agent.session?.getUsageReference?.()) ?? null; - const reportId = reference - ? await this.options.runtime.resolveUsageReference(reference) - : null; - this.options.emit({ - type: "agent.resolve_usage_report.response", - payload: { requestId: msg.requestId, reportId }, - }); - } catch (error) { - this.emitError(msg, error, "agent_resolve_usage_report_failed"); - } - } - // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. async handleLegacyList( msg: Extract, @@ -111,10 +66,7 @@ export class UsageSession { } private emitError( - msg: Extract< - SessionInboundMessage, - { type: "usage.list_reports.request" | "agent.resolve_usage_report.request" } - >, + msg: Extract, error: unknown, code: string, ): void { diff --git a/plugin-examples/hosts/index.client.tsx b/plugin-examples/hosts/index.client.tsx index f6b50a0a08..76480eac00 100644 --- a/plugin-examples/hosts/index.client.tsx +++ b/plugin-examples/hosts/index.client.tsx @@ -1,8 +1,15 @@ -import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { useCallback } from "react"; +import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { Hosts } from "./client/hosts"; +function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + const open = useCallback(() => openScreen({ screenId: "main" }), [openScreen]); + return ; +} + export default function contribute(client: PluginClientContext) { - client.addSurface("main", Hosts); - client.addSidebarItem({ id: "hosts", title: "Host agents", icon: "Server", surface: "main" }); + client.addScreen({ id: "main", title: "Host agents", Component: Hosts }); + client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem }); return () => {}; } diff --git a/plugin-examples/modal-ui/index.client.tsx b/plugin-examples/modal-ui/index.client.tsx index e7b2baf54e..287d3bdffa 100644 --- a/plugin-examples/modal-ui/index.client.tsx +++ b/plugin-examples/modal-ui/index.client.tsx @@ -1,13 +1,21 @@ -import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { useCallback } from "react"; +import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { ModalExamples } from "./client/examples"; +function ModalExamplesItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + const open = useCallback(() => openScreen({ screenId: "main" }), [openScreen]); + return ( + + ); +} + export default function contribute(plugin: PluginClientContext) { - plugin.addSurface("main", ModalExamples); - plugin.addSidebarItem({ + plugin.addScreen({ id: "main", title: "Modal examples", Component: ModalExamples }); + plugin.addSidebarHeaderItem({ id: "main", title: "Modal examples", - icon: "PanelsTopLeft", - surface: "main", + Component: ModalExamplesItem, }); plugin.addWorkspacePanel({ id: "examples", diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index b4d5295185..710e3b9cb4 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -98,26 +98,21 @@ function fixtureFetch(expectedToken: string): typeof fetch { }) as unknown as typeof fetch; } -describe("Claude usage input forms", () => { - it("reads only the selected configDir file", async () => { +describe("Claude default credential lookup", () => { + it("reads the configured Claude home file", async () => { const dir = mkdtempSync(join(tmpdir(), "claude-usage-input-")); try { writeFileSync( join(dir, ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "fixture-file" } }), ); - expect((await fetchUsage({ configDir: dir }, fixtureFetch("fixture-file"))).status).toBe( + expect((await fetchUsage({}, fixtureFetch("fixture-file"), { claudeHome: dir })).status).toBe( "available", ); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("uses an accessToken without reading a file", async () => { - expect( - (await fetchUsage({ accessToken: "fixture-direct" }, fixtureFetch("fixture-direct"))).status, - ).toBe("available"); - }); it("default discovery reads the credential file", async () => { const dir = mkdtempSync(join(tmpdir(), "claude-usage-default-")); try { @@ -131,11 +126,13 @@ describe("Claude usage input forms", () => { rmSync(dir, { recursive: true, force: true }); } }); - it("stays unavailable when non-default configDir has no credentials", async () => { + it("stays unavailable when the configured home has no credentials", async () => { const dir = mkdtempSync(join(tmpdir(), "claude-empty-config-")); try { const fetchApi = vi.fn() as unknown as typeof fetch; - expect((await fetchUsage({ configDir: dir }, fetchApi)).status).toBe("unavailable"); + expect((await fetchUsage({}, fetchApi, { claudeHome: dir, platform: "linux" })).status).toBe( + "unavailable", + ); expect(fetchApi).not.toHaveBeenCalled(); } finally { rmSync(dir, { recursive: true, force: true }); @@ -151,12 +148,14 @@ describe("Claude Keychain token usage", () => { }), ); const credentials = await readClaudeKeychainCredentials(run, "fixture-account"); - const token = (credentials as { claudeAiOauth: { accessToken: string } }).claudeAiOauth - .accessToken; const fetchApi = vi.fn( async () => new Response(null, { status: 401 }), ) as unknown as typeof fetch; - const report = await fetchUsage({ accessToken: token }, fetchApi); + const report = await fetchUsage({}, fetchApi, { + platform: "darwin", + claudeHome: "/nonexistent-paseo-test-home", + readKeychainCredentials: async () => credentials, + }); expect(report.status).toBe("unavailable"); expect(fetchApi).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledTimes(1); diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index 421d0abd8a..d20162e93d 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchUsage } from "./usage.js"; +import { inputSchema } from "../shared/input.js"; import type { UsageReport } from "@getpaseo/plugin/server/usage"; function writeClaudeCredentials( @@ -615,7 +616,7 @@ it("identify reads account and organization from the selected Claude config", as }, }), ); - expect(await identify({ configDir: directory })).toEqual({ + expect(await identify({}, undefined, undefined, { accountHome: directory })).toEqual({ key: "account-uuid.org-uuid", label: "test@example.com", }); @@ -624,7 +625,7 @@ it("identify reads account and organization from the selected Claude config", as } }); -it("token-only Claude inputs use a cached OAuth profile identity", async () => { +it("Claude credentials without account metadata use a cached OAuth profile identity", async () => { const { identify } = await import("./usage.js"); let calls = 0; const fetchProfile: typeof fetch = async () => { @@ -637,37 +638,46 @@ it("token-only Claude inputs use a cached OAuth profile identity", async () => { { status: 200 }, ); }; - expect(await identify({ accessToken: "fixture-claude-token" }, fetchProfile)).toEqual({ + expect(await identify({}, fetchProfile, Date.now, tokenLookup("fixture-claude-token"))).toEqual({ key: "account-uuid.org-uuid", label: "test@example.com", }); - expect(await identify({ accessToken: "fixture-claude-token" }, fetchProfile)).toEqual({ + expect(await identify({}, fetchProfile, Date.now, tokenLookup("fixture-claude-token"))).toEqual({ key: "account-uuid.org-uuid", label: "test@example.com", }); expect(calls).toBe(1); }); -it("identifies the same configDir credentials that fetch uses when oauthAccount is absent", async () => { +it("identifies the same default credentials that fetch uses when oauthAccount is absent", async () => { const { identify } = await import("./usage.js"); const directory = mkdtempSync(join(tmpdir(), "claude-credential-identity-")); try { writeClaudeCredentials(directory, "fixture-credential-token"); writeFileSync(join(directory, ".claude.json"), JSON.stringify({})); let usageRequested = false; - await fetchUsage({ configDir: directory }, async () => { - usageRequested = true; - return new Response(null, { status: 401 }); - }); + await fetchUsage( + {}, + async () => { + usageRequested = true; + return new Response(null, { status: 401 }); + }, + { claudeHome: directory }, + ); expect(usageRequested).toBe(true); let profileRequested = false; - const account = await identify({ configDir: directory }, async () => { - profileRequested = true; - return jsonResponse({ - account: { uuid: "account-uuid", email: "owner@example.test" }, - organization: { uuid: "org-uuid" }, - }); - }); + const account = await identify( + {}, + async () => { + profileRequested = true; + return jsonResponse({ + account: { uuid: "account-uuid", email: "owner@example.test" }, + organization: { uuid: "org-uuid" }, + }); + }, + Date.now, + { claudeHome: directory, accountHome: directory }, + ); expect(profileRequested).toBe(true); expect(account).toEqual({ key: "account-uuid.org-uuid", label: "owner@example.test" }); } finally { @@ -683,12 +693,13 @@ it("expires cached token profiles after five minutes", async () => { calls++; return jsonResponse({ account: { uuid: "account-uuid" }, organization: { uuid: "org-uuid" } }); }; - const input = { accessToken: "ttl-fixture-token" }; - await identify(input, fetchProfile, () => now); - await identify(input, fetchProfile, () => now); + const input = {}; + const lookup = tokenLookup("ttl-fixture-token"); + await identify(input, fetchProfile, () => now, lookup); + await identify(input, fetchProfile, () => now, lookup); expect(calls).toBe(1); now = 300_001; - await identify(input, fetchProfile, () => now); + await identify(input, fetchProfile, () => now, lookup); expect(calls).toBe(2); }); @@ -770,8 +781,26 @@ it("bounds cached profiles across many token rotations", async () => { return jsonResponse({ account: { uuid: "account" }, organization: { uuid: "org" } }); }; for (let index = 0; index < 129; index++) { - await identify({ accessToken: `rotation-fixture-${index}` }, fetchProfile); + await identify({}, fetchProfile, Date.now, tokenLookup(`rotation-fixture-${index}`)); } - await identify({ accessToken: "rotation-fixture-0" }, fetchProfile); + await identify({}, fetchProfile, Date.now, tokenLookup("rotation-fixture-0")); expect(calls).toBe(130); }); + +it("discovery input rejects explicit credential routes", () => { + expect(inputSchema.safeParse({ accessToken: "unused" }).success).toBe(false); + expect(inputSchema.safeParse({ configDir: "/unused" }).success).toBe(false); + expect(inputSchema.parse({})).toEqual({}); +}); + +const tokenDirectories: string[] = []; +afterEach(() => { + for (const directory of tokenDirectories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); +function tokenLookup(accessToken: string) { + const directory = mkdtempSync(join(tmpdir(), "claude-profile-token-")); + tokenDirectories.push(directory); + writeClaudeCredentials(directory, accessToken); + return { claudeHome: directory, accountHome: directory }; +} diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 48ac928d16..9e280df0e5 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -336,11 +336,9 @@ interface ClaudeCredentialLookup { /** Shared credential lookup for usage fetches and account identification. */ export async function resolveClaudeCredentials( - input: UsageInput, + _input: UsageInput, lookup: ClaudeCredentialLookup = {}, ): Promise { - if ("accessToken" in input) return { oauth: { accessToken: input.accessToken } }; - if ("configDir" in input) return readCredentialFile(join(input.configDir, ".credentials.json")); const claudeHome = lookup.claudeHome ?? process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude"); const fileCredentials = await readCredentialFile(join(claudeHome, ".credentials.json")); if (fileCredentials) return fileCredentials; @@ -433,7 +431,6 @@ export async function fetchUsage( scopedLimitsFromResponse(resp.limits), ); const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; - if (windows[0]) windows[0].headline = true; if (windows.length === 0) { // The response parsed but described nothing. That silence is how the previous @@ -484,21 +481,18 @@ export async function identify( now: () => number = Date.now, credentialLookup: ClaudeCredentialLookup = {}, ) { - const directory = - "configDir" in input ? input.configDir : (credentialLookup.accountHome ?? homedir()); - if (!("accessToken" in input)) { - try { - const config = z - .object({ oauthAccount: ClaudeAccountSchema }) - .parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8"))); - const account = config.oauthAccount; - return { - key: `${account.accountUuid}.${account.organizationUuid}`, - ...(account.emailAddress ? { label: account.emailAddress } : {}), - }; - } catch { - // Credentials may still be present even when account metadata is absent. - } + const directory = credentialLookup.accountHome ?? homedir(); + try { + const config = z + .object({ oauthAccount: ClaudeAccountSchema }) + .parse(JSON.parse(await fs.readFile(join(directory, ".claude.json"), "utf8"))); + const account = config.oauthAccount; + return { + key: `${account.accountUuid}.${account.organizationUuid}`, + ...(account.emailAddress ? { label: account.emailAddress } : {}), + }; + } catch { + // Credentials may still be present even when account metadata is absent. } const credentials = await resolveClaudeCredentials(input, credentialLookup); if (!credentials) return null; diff --git a/plugins/claude-usage-source/shared/input.ts b/plugins/claude-usage-source/shared/input.ts index 3668dd668f..9d9a5f432e 100644 --- a/plugins/claude-usage-source/shared/input.ts +++ b/plugins/claude-usage-source/shared/input.ts @@ -1,7 +1,3 @@ import { z } from "zod"; -export const inputSchema = z.union([ - z.object({ configDir: z.string().min(1) }).strict(), - z.object({ accessToken: z.string().min(1) }).strict(), - z.object({}).strict(), -]); +export const inputSchema = z.object({}).strict(); export type UsageInput = z.infer; diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index e45291bfbd..dfa58ef603 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -1,11 +1,28 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, beforeEach, expect, test } from "vitest"; import { fetchUsage, identify } from "./usage.js"; +import { inputSchema } from "../shared/input.js"; + +let fixtureHome: string; +beforeEach(async () => { + fixtureHome = await mkdtemp(join(tmpdir(), "usage-codex-default-")); + process.env["CODEX_HOME"] = fixtureHome; + await writeAuth("fixture-supplied"); +}); + +async function writeAuth(accessToken: string, accountId?: string) { + await writeFile( + join(fixtureHome, "auth.json"), + JSON.stringify({ tokens: { access_token: accessToken, account_id: accountId } }), + ); +} + const originalHome = process.env["CODEX_HOME"]; -afterEach(() => { +afterEach(async () => { + await rm(fixtureHome, { recursive: true, force: true }); if (originalHome === undefined) delete process.env["CODEX_HOME"]; else process.env["CODEX_HOME"] = originalHome; }); @@ -41,42 +58,33 @@ test("default input reads CODEX_HOME auth and preserves the usage request", asyn expect(report).toMatchObject({ status: "available", planLabel: "plus", - windows: [{ id: "session", usedPct: 30, headline: true }], + windows: [{ id: "session", usedPct: 30 }], }); } finally { await rm(home, { recursive: true, force: true }); } }); -test("explicit codexHome reads only that auth file", async () => { - const home = await mkdtemp(join(tmpdir(), "usage-codex-")); - try { - await writeFile( - join(home, "auth.json"), - JSON.stringify({ tokens: { access_token: "fixture-home", account_id: "account-home" } }), - ); - await fetchUsage({ codexHome: home }, (_url, init) => - response(init?.headers ?? {}, "account-home"), - ); - expect(await identify({ codexHome: home })).toEqual({ key: "account-home" }); - } finally { - await rm(home, { recursive: true, force: true }); +test("discovery input rejects explicit credential routes", () => { + for (const input of [ + { codexHome: "/unused" }, + { accessToken: "unused" }, + { accessToken: "unused", accountId: "unused" }, + ]) { + expect(inputSchema.safeParse(input).success).toBe(false); } + expect(inputSchema.parse({})).toEqual({}); }); test("identify returns a key when fetch finds Codex token credentials", async () => { - await fetchUsage( - { accessToken: "fixture-supplied", accountId: "account-supplied" }, - (_url, init) => response(init?.headers ?? {}, "account-supplied"), - ); - expect( - await identify({ accessToken: "fixture-supplied", accountId: "account-supplied" }), - ).toEqual({ key: "account-supplied" }); + await writeAuth("fixture-supplied", "account-supplied"); + await fetchUsage({}, (_url, init) => response(init?.headers ?? {}, "account-supplied")); + expect(await identify({})).toEqual({ key: "account-supplied" }); }); test("coerces credit balance and marks a 96 percent window dangerous", async () => { const report = await fetchUsage( - { accessToken: "fixture-supplied" }, + {}, async () => new Response( JSON.stringify({ @@ -100,7 +108,7 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () test("HTML usage body is unavailable", async () => { const report = await fetchUsage( - { accessToken: "fixture-supplied" }, + {}, async () => new Response("Login", { status: 200 }), ); expect(report.status).toBe("unavailable"); @@ -109,6 +117,7 @@ test("HTML usage body is unavailable", async () => { test("401 leaves auth.json byte for byte unchanged and makes no refresh request", async () => { const home = await mkdtemp(join(tmpdir(), "usage-codex-")); try { + process.env["CODEX_HOME"] = home; const authPath = join(home, "auth.json"); const before = JSON.stringify({ OPENAI_API_KEY: null, @@ -122,7 +131,7 @@ test("401 leaves auth.json byte for byte unchanged and makes no refresh request" }); await writeFile(authPath, before); let calls = 0; - const report = await fetchUsage({ codexHome: home }, async (url) => { + const report = await fetchUsage({}, async (url) => { expect(String(url)).toBe("https://chatgpt.com/backend-api/wham/usage"); calls++; return new Response(null, { status: 401 }); @@ -135,17 +144,21 @@ test("401 leaves auth.json byte for byte unchanged and makes no refresh request" } }); -test("token-only account claim survives token rotation", async () => { +test("default auth account claim survives token rotation", async () => { const token = (suffix: string) => `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "work-id" }, suffix })).toString("base64url")}.signature`; - expect(await identify({ accessToken: token("first") })).toEqual({ key: "work-id" }); - expect(await identify({ accessToken: token("second") })).toEqual({ key: "work-id" }); - expect(await identify({ accessToken: "opaque-token" })).toBeNull(); + await writeAuth(token("first")); + expect(await identify({})).toEqual({ key: "work-id" }); + await writeAuth(token("second")); + expect(await identify({})).toEqual({ key: "work-id" }); + await writeAuth("opaque-token"); + expect(await identify({})).toBeNull(); }); test("identify reads an email label from auth id_token when access token has no profile", async () => { const home = await mkdtemp(join(tmpdir(), "usage-codex-label-")); try { + process.env["CODEX_HOME"] = home; const idToken = `header.${Buffer.from(JSON.stringify({ email: "id-owner@example.test" })).toString("base64url")}.signature`; await writeFile( join(home, "auth.json"), @@ -153,7 +166,7 @@ test("identify reads an email label from auth id_token when access token has no tokens: { account_id: "account-id", access_token: "opaque-token", id_token: idToken }, }), ); - expect(await identify({ codexHome: home })).toEqual({ + expect(await identify({})).toEqual({ key: "account-id", label: "id-owner@example.test", }); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index c7b07ce8f3..3fb660ae09 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -33,17 +33,13 @@ const responseSchema = z.object({ }); export async function readAuth( - input: CodexUsageInput, + _input: CodexUsageInput, ): Promise<{ token: string; accountId?: string; idToken?: string } | null> { - if ("accessToken" in input) return { token: input.accessToken, accountId: input.accountId }; - const candidates = - "codexHome" in input - ? [join(input.codexHome, "auth.json")] - : [ - ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), - join(homedir(), ".config", "codex", "auth.json"), - join(homedir(), ".codex", "auth.json"), - ]; + const candidates = [ + ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), + join(homedir(), ".config", "codex", "auth.json"), + join(homedir(), ".codex", "auth.json"), + ]; for (const path of candidates) { try { const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8"))); @@ -64,7 +60,6 @@ function usageWindow( id: string, label: string, value: z.infer | null | undefined, - headline = false, ): UsageWindow | null { if (!value) return null; const usedPct = value.used_percent ?? 0; @@ -74,7 +69,6 @@ function usageWindow( utilizationPct: usedPct, resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, tone: toneFromUsedPct(usedPct), - headline, }); } @@ -101,7 +95,7 @@ export async function fetchUsage( if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] }; const usage = responseSchema.parse(JSON.parse(text)); const windows = [ - usageWindow("session", "Session", usage.rate_limit?.primary_window, true), + usageWindow("session", "Session", usage.rate_limit?.primary_window), usageWindow("weekly", "Weekly", usage.rate_limit?.secondary_window), usageWindow("code_review", "Code review", usage.code_review_rate_limit?.primary_window), ].filter((window): window is UsageWindow => window !== null); diff --git a/plugins/codex-usage-source/shared/input.ts b/plugins/codex-usage-source/shared/input.ts index 0c54b51e69..baa8ee7ca6 100644 --- a/plugins/codex-usage-source/shared/input.ts +++ b/plugins/codex-usage-source/shared/input.ts @@ -1,8 +1,4 @@ import { z } from "zod"; -export const inputSchema = z.union([ - z.object({ codexHome: z.string().min(1) }).strict(), - z.object({ accessToken: z.string().min(1), accountId: z.string().optional() }).strict(), - z.object({}).strict(), -]); +export const inputSchema = z.object({}).strict(); export type CodexUsageInput = z.infer; diff --git a/plugins/grok-usage-source/server/usage.ts b/plugins/grok-usage-source/server/usage.ts index 251674f597..ba4ab829cf 100644 --- a/plugins/grok-usage-source/server/usage.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -144,7 +144,6 @@ export async function fetchUsage( const resp = GrokUsageResponseSchema.parse(await res.json()); const balance = grokMonthlyCreditBalance(resp); const window = grokUsageWindow(resp); - if (window) window.headline = true; return { status: "available", diff --git a/plugins/kimi-usage-source/server/usage.ts b/plugins/kimi-usage-source/server/usage.ts index bd20560ae7..c590b1c984 100644 --- a/plugins/kimi-usage-source/server/usage.ts +++ b/plugins/kimi-usage-source/server/usage.ts @@ -312,7 +312,6 @@ export async function fetchUsage( } const windows = kimiUsageWindowsFromPayload(await res.json()); - if (windows[0]) windows[0].headline = true; return { status: "available", diff --git a/plugins/minimax-usage-source/server/usage.ts b/plugins/minimax-usage-source/server/usage.ts index 9c0b019e0f..4ae802a285 100644 --- a/plugins/minimax-usage-source/server/usage.ts +++ b/plugins/minimax-usage-source/server/usage.ts @@ -255,7 +255,6 @@ export async function fetchUsage( const weeklyWindow = toWeeklyWindow(name, model); if (weeklyWindow) windows.push(weeklyWindow); } - if (windows[0]) windows[0].headline = true; return { status: windows.length > 0 ? "available" : "unavailable", diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts index a9120cbebe..c475d96b36 100644 --- a/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -1,9 +1,30 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { afterEach, beforeEach, expect, test } from "vitest"; import { discover, fetchUsage, identify } from "./usage.js"; +import { inputSchema } from "../shared/input.js"; + +let fixtureDirectory: string; +let fixturePath: string; +beforeEach(async () => { + fixtureDirectory = await mkdtemp(join(tmpdir(), "go-default-")); + fixturePath = join(fixtureDirectory, "auth.json"); + await writeFile( + fixturePath, + JSON.stringify({ "opencode-go": { type: "api", key: "fixture-secret" } }), + ); +}); +afterEach(async () => { + await rm(fixtureDirectory, { recursive: true, force: true }); +}); + +test("discovery input rejects an explicit API key", () => { + expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false); + expect(inputSchema.parse({})).toEqual({}); +}); + const upstreamResponse = { usage: { rolling: { status: "ok", percent: 21, resetsAt: "2026-09-26T20:00:00.000Z" }, @@ -33,12 +54,10 @@ test("discovers and fetches the default key from read-only auth.json", async () { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" }, ]); expect(report.status).toBe("available"); - expect( - report.windows.map((window) => [window.id, window.usedPct, window.resetsAt, window.headline]), - ).toEqual([ - ["rolling", 21, "2026-09-26T20:00:00.000Z", true], - ["weekly", 42, "2026-09-28T00:00:00.000Z", undefined], - ["monthly", 100, "2026-10-01T00:00:00.000Z", undefined], + expect(report.windows.map((window) => [window.id, window.usedPct, window.resetsAt])).toEqual([ + ["rolling", 21, "2026-09-26T20:00:00.000Z"], + ["weekly", 42, "2026-09-28T00:00:00.000Z"], + ["monthly", 100, "2026-10-01T00:00:00.000Z"], ]); // The current upstream endpoint returns windows only; do not invent balances. expect(report.balances).toBeUndefined(); @@ -68,23 +87,24 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn }); test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { - const report = await fetchUsage( - { apiKey: "fixture-key" }, - async () => new Response(null, { status }), - ); + const report = await fetchUsage({}, async () => new Response(null, { status }), fixturePath); expect(report.status).toBe("unavailable"); expect(report.windows).toEqual([]); }); test("identify returns a key when fetch finds OpenCode Go credentials", async () => { let requested = false; - await fetchUsage({ apiKey: "fixture-secret" }, async () => { - requested = true; - return new Response(null, { status: 401 }); - }); + await fetchUsage( + {}, + async () => { + requested = true; + return new Response(null, { status: 401 }); + }, + fixturePath, + ); expect(requested).toBe(true); - const identity = await identify({ apiKey: "fixture-secret" }); + const identity = await identify({}, fixturePath); expect(identity?.key).toMatch(/^[a-f0-9]{64}$/); expect(identity?.key).not.toBe("fixture-secret"); - expect(await identify({ apiKey: "fixture-secret" })).toEqual(identity); + expect(await identify({}, fixturePath)).toEqual(identity); }); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts index 0a761fdb99..dd9944544e 100644 --- a/plugins/opencode-go-usage-source/server/usage.ts +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -49,11 +49,11 @@ export async function discover(path = authPath()): Promise> { } export async function fetchUsage( - input: Input, + _input: Input, fetchApi: typeof fetch = fetch, path = authPath(), ): Promise { - const apiKey = "apiKey" in input ? input.apiKey : await readDefaultKey(path); + const apiKey = await readDefaultKey(path); if (!apiKey) return { status: "unavailable", windows: [] }; const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, @@ -76,13 +76,12 @@ export async function fetchUsage( utilizationPct: value.percent, resetsAt: value.resetsAt, tone: toneFromUsedPct(value.percent), - headline: id === "rolling", }), ); return { status: "available", planLabel: "Go", windows }; } -export async function identify(input: Input, path = authPath()) { - const key = "apiKey" in input ? input.apiKey : await readDefaultKey(path); +export async function identify(_input: Input, path = authPath()) { + const key = await readDefaultKey(path); return key ? { key: hashAccountKey(key) } : null; } diff --git a/plugins/opencode-go-usage-source/shared/input.ts b/plugins/opencode-go-usage-source/shared/input.ts index 64fb3bd25b..9ba7acfb94 100644 --- a/plugins/opencode-go-usage-source/shared/input.ts +++ b/plugins/opencode-go-usage-source/shared/input.ts @@ -1,7 +1,4 @@ import { z } from "zod"; -export const inputSchema = z.union([ - z.object({ apiKey: z.string().min(1) }).strict(), - z.object({}).strict(), -]); +export const inputSchema = z.object({}).strict(); export type Input = z.infer; diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index fa0b717293..033235b7f0 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -9,7 +9,7 @@ category: Plugins # Plugin quickstart A plugin is a TypeScript project installed into one Paseo daemon. It can add -[surfaces and sidebar items](/docs/plugins/reference#surfaces-and-sidebar-items), +[screens and sidebar items](/docs/plugins/reference#screens-and-sidebar-items), [workspace panels](/docs/plugins/reference#workspace-panels), [Command Center items](/docs/plugins/reference#command-center-items), [slash commands](/docs/plugins/reference#slash-commands), @@ -37,7 +37,7 @@ npm install development dependencies for typechecking and tests only; Paseo supplies the plugin SDK, React, React Native, TanStack Query, and Zod at runtime. -The scaffold is a working plugin: a sidebar surface with a button that asks the daemon for a +The scaffold is a working plugin: a screen, opened from the sidebar, with a button that asks the daemon for a greeting through an RPC. ```text @@ -45,7 +45,7 @@ workspace-plugin/ paseo-plugin.json # plugin ID and supported Paseo versions index.client.tsx # runs in the Paseo app index.server.ts # runs in a daemon subprocess - client/greeting.tsx # the surface component + client/greeting.tsx # the screen component client/web.ts # the only file allowed to touch browser APIs server/greeting.ts # the RPC handler shared/greeting.ts # the RPC contract, imported by both @@ -54,20 +54,26 @@ workspace-plugin/ ``` Each entry default-exports one function that registers contributions and returns a cleanup -function. `index.client.tsx` registers the surface and the sidebar item that opens it: +function. `index.client.tsx` registers the screen and the sidebar item that opens it: ```tsx -import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { GreetingSurface } from "./client/greeting"; +import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { GreetingScreen } from "./client/greeting"; + +function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "greeting" })} + /> + ); +} export default function contribute(client: PluginClientContext) { - client.addSurface("greeting", GreetingSurface); - client.addSidebarItem({ - id: "greeting", - title: "Greeting", - icon: "MessageCircle", - surface: "greeting", - }); + client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen }); + client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem }); return () => {}; } ``` diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index eeb5a4cc80..7b24b37099 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -67,24 +67,29 @@ is a compile error. Use this table as the complete registration checklist. -| Old registration and location | New registration and location | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | -| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | -| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | -| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | -| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | -| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | -| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | -| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | -| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` | -| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | -| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | -| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | -| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | -| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | -| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | -| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | +| Old registration and location | New registration and location | +| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | +| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` | +| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) | +| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | +| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | +| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | +| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | +| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | +| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` | +| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | +| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | +| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | +| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | +| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | +| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | +| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | + +Register the screen that replaces an `addSidebarItem` under that item's `id` to keep saved links +working. A `/plugin//sidebar/` link, and the older `/plugin//`, opens the +`addSidebarItem` with that `id`, or else the screen with that `id`. Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerContext` from `@getpaseo/plugin/server`. Remove imports of the old context type. Client registrations return idempotent removal functions, except header buttons and composer pills, which return `{ update, remove }` handles. Preserve any remover the plugin calls before teardown; Paseo removes outstanding diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 5e7e7a030f..4d84edb578 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -14,7 +14,7 @@ Migrating an existing plugin? Follow the standalone [runtime-entry migration gui Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute: -- React Native surfaces and sidebar items to Paseo clients; +- React Native screens and sidebar header and footer items to Paseo clients; - workspace and agent panels opened as workspace tabs; - global, workspace, and agent actions in the Command Center; - slash commands in the message composer; @@ -98,7 +98,7 @@ and cannot show this new diagnostic. At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the old `index.ts` fails to load and points at the [migration guide](/docs/plugins/migration). -Plugin, surface, sidebar-item, workspace-panel, Command Center item, attachment-source, and +Plugin, screen, sidebar-item, workspace-panel, Command Center item, attachment-source, and slash-command IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development @@ -263,7 +263,7 @@ for remote workspaces; `localhost` URLs refer to that desktop. | Target host/workspace is unknown or its workspace list has not loaded | Throws `Workspace is unavailable on the requested host.` before creating a tab | Use the [settings API](#settings-screens) for typed host-scoped persistence across clients. -Use `openSettings`, `openSurface`, and `openPanel` for your own registered contributions. +Use `openSettings`, `openScreen`, and `openPanel` for your own registered contributions. ### Server runtime @@ -331,9 +331,9 @@ export default function contribute(server: PluginServerContext) { } ``` -`discover()` supplies configured inputs; return `[]` when no account is configured. The daemon also passes each live agent's `session.usage_reference` input to `identify()`. That method returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `:`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation and input routes, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive. +`discover()` is required and supplies configured inputs; return `[]` when no account is configured. `identify(input)` returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `:`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive. -`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on a window to show it first. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. +`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. ## Entry point and cleanup @@ -346,7 +346,7 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Main } from "./client/main"; export default function contribute(client: PluginClientContext) { - client.addSurface("main", Main); + client.addScreen({ id: "main", title: "My plugin", Component: Main }); return () => {}; } ``` @@ -676,18 +676,19 @@ saved; environment overrides are not persisted with it. Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. -## Surfaces and sidebar items +## Screens and sidebar items -Register a component, then point a sidebar item at its surface ID: +A screen is a full page of plugin UI. A sidebar item is a component Paseo renders in the sidebar +header or footer; it decides what a press does. Register the screen, then an item that opens it: `client/main.tsx`: ```tsx -import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { PluginScreenProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; -export function Main({ theme, host, layout }: PluginSurfaceProps) { +export function Main({ theme, host, layout }: PluginScreenProps) { const styles = useMemo( () => ({ screen: { @@ -711,32 +712,180 @@ export function Main({ theme, host, layout }: PluginSurfaceProps) { `index.client.tsx`: -```ts -import type { PluginClientContext } from "@getpaseo/plugin/client"; +```tsx +import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { Main } from "./client/main"; +function MainItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "main" })} + /> + ); +} + export default function contribute(client: PluginClientContext) { - client.addSurface("main", Main); - client.addSidebarItem({ - id: "main", - title: "My plugin", - icon: "Blocks", - surface: "main", - }); + client.addScreen({ id: "main", title: "My plugin", Component: Main }); + client.addSidebarHeaderItem({ id: "main", title: "My plugin", Component: MainItem }); return () => {}; } ``` -`PluginSurfaceProps` contains: +`PluginScreenProps` contains: | Field | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. | | `host` | Selected host `id` and display `label`. | | `layout` | `compact` and the `ios`, `android`, or `web` platform. | +| `params` | The params the screen was opened with, as string keys and values. `{}` when none. | | `navigation` | Optional client navigation. `openAgent({ agentId, serverId? })` and `openWorkspace({ workspaceId, serverId? })` open targets on `serverId`, or on the selected host when omitted. `openBrowser({ url, workspaceId, serverId? })` is available only on Electron; see [links and browsers](#external-links-and-workspace-browsers). | -Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. +Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the screen body. + +`addScreen({ id, title, Component })` registers a screen. `title` is the screen header's title: a +string, or a function that takes the screen's params and returns one, so a bot screen can show the +bot's name. The function runs again when the params change. Registration throws on an empty title +or one that is neither a string nor a function. A title function that throws or returns an empty +string shows the screen ID instead; the screen still renders. + +`openScreen({ screenId, params })` opens a screen with params, such as the bot a bot screen shows. +Params live in the screen's URL query, so a reload, back and forward, and a link to the screen keep +them. Any string key works; values must be strings, and anything else throws. Opening the same +screen with other params shows the new params. + +### Sidebar items + +`addSidebarHeaderItem` adds a row to the list at the top of the sidebar. `addSidebarFooterItem` +adds a row to the footer, between **Add project** and the footer's icon row. The icon row is fixed; +plugins can't add to it. Both take `{ id, title, Component }`. `title` labels the item in +Settings > Sidebar, where users reorder and hide items, and is the default label and accessibility +label of `SidebarRow`. Settings shows a generic plugin icon for these items; a row from the +deprecated `addSidebarItem` keeps its registered icon. + +With the plugin on several hosts, an item renders from the host of the screen the app shows. Off a +host's screens, it uses the host last picked in one of the plugin's screens or last opened from one +of its items, else the first host. + +`Component` receives `PluginSidebarItemProps`: + +| Field | Meaning | +| ------------------------- | --------------------------------------------------------------------------------------------- | +| `theme`, `host`, `layout` | As in `PluginScreenProps`. | +| `currentScreen` | `{ screenId, params }` of this plugin's screen open on the item's host, or `null`. | +| `openScreen(input)` | Opens one of this plugin's screens: `{ screenId, params? }`. Unknown screen IDs throw. | +| `openPopover(Content)` | Opens `Content` anchored to the pressed row on wide layouts and as a bottom sheet on compact. | + +`Content` receives `theme`, `host`, `layout`, `close()`, and `openScreen(input)`. Opening a screen +closes the popover. + +Render the item with the sidebar kit from `@getpaseo/plugin/client/ui`: + +| Component | Props and behavior | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SidebarRow` | Required `onPress`; optional `icon` (Lucide name or `{ size, color }` component), `label`, `active`, `trailing`, `id`. A full-width row. `trailing` renders beside the row's pressable, so a button in it presses on its own; a press elsewhere in it presses the row. `id` tells rows of one item apart in test IDs. | +| `SidebarSeparator` | No props. The sidebar's separator line, between groups of rows. | + +A popover anchors to the row that was pressed, whether the press landed on the row or on its +`trailing` content, so a per-row "More" button opens its popover next to its own row. + +```tsx +import type { PluginSidebarItemProps, PluginPopoverProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { Pressable, Text } from "react-native"; + +function SyncDetails({ theme, close, openScreen }: PluginPopoverProps) { + return ( + openScreen({ screenId: "sync" })}> + Open sync history + + ); +} + +function SyncItem({ openPopover }: PluginSidebarItemProps) { + return openPopover(SyncDetails)} />; +} + +client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem }); +``` + +An item that throws renders nothing; the rest of the sidebar keeps working. + +#### Several rows in one item + +An item's `Component` may return a fragment, an array, or `null`, so one item can render a list +that changes at runtime. Settings lists the item once, and it moves and hides as one block. This +item shows a row per bot and opens the bot's screen with its ID as a param: + +```tsx +import type { + PluginClientContext, + PluginScreenProps, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui"; +import { Text } from "react-native"; +import { botName, useBots } from "./client/bots"; + +function BotScreen({ theme, params }: PluginScreenProps) { + return Bot {params.botId}; +} + +function BotsItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + const bots = useBots(); + const openBotId = currentScreen?.screenId === "bot" ? currentScreen.params.botId : null; + return ( + <> + + {bots.map((bot) => ( + openScreen({ screenId: "bot", params: { botId: bot.id } })} + /> + ))} + + ); +} + +export default function contribute(client: PluginClientContext) { + client.addScreen({ + id: "bot", + title: (params) => botName(params.botId) ?? "Bot", + Component: BotScreen, + }); + client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem }); + return () => {}; +} +``` + +#### Add and remove items at runtime + +`addSidebarHeaderItem` and `addSidebarFooterItem` work after the entry returns. Each returns a +remover; the item appears and disappears without a reload: + +```tsx +let removeAlerts: (() => void) | null = null; + +function showAlerts(client: PluginClientContext) { + removeAlerts ??= client.addSidebarFooterItem({ + id: "alerts", + title: "Alerts", + Component: AlertsItem, + }); +} + +function hideAlerts() { + removeAlerts?.(); + removeAlerts = null; +} +``` ## Host UI @@ -1399,12 +1548,12 @@ Every callback receives: | `context` | All | Matching discriminator. | | `paseo` | All | Selected host's existing `PaseoApi`. | | `rpc(contract, input)` | All | Typed call to this installation's daemon-side plugin handler. | -| `openSurface(id)` | All | Opens one of this plugin's registered global surfaces. | +| `openScreen(input)` | All | Opens one of this plugin's registered screens: `{ screenId, params? }`. | | `workspace` | Workspace and agent | Synchronous workspace snapshot. | | `agent` | Agent | Synchronous matching agent snapshot. | | `openPanel(id, options?)` | Workspace and agent | Opens a registered panel in the callback's current context. Pass `{ location: "explorer" }` to target Explorer. | -An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work. +An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown screen and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work. ## Slash commands @@ -1609,6 +1758,27 @@ your subscriptions, timers, and other resources. Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client. +`usePaseo()` is your plugin's one Paseo client, the same `client.paseo` your setup receives, in +every surface. Release what you subscribe to in your cleanup; when the plugin is disabled, reloaded, +or removed, or its host goes away, Paseo disposes the client and ends every subscription still open. + +```tsx +import { usePaseo } from "@getpaseo/plugin/client"; +import { useEffect, useState } from "react"; +import { Text } from "react-native"; + +function ProjectChanges() { + const paseo = usePaseo(); + const [changes, setChanges] = useState(0); + useEffect(() => { + const observation = paseo.observeEvents(["project.update"]); + observation.subscribe({ snapshot() {}, update: () => setChanges((count) => count + 1) }); + return () => void observation.release(); + }, [paseo]); + return {changes} project changes; +} +``` + ```tsx import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client"; import { Pressable, Text } from "react-native"; @@ -1768,7 +1938,7 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingButton } from "./client/greeting"; export default function contribute(client: PluginClientContext) { - client.addSurface("main", GreetingButton); + client.addScreen({ id: "main", title: "Greeting", Component: GreetingButton }); return () => {}; } ``` @@ -2080,16 +2250,16 @@ The switch is the root `pluginsEnabled` field in `config.json`. After changing i Use `paseo plugin ls` to read the current status and error. -| Symptom | Check | -| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/migration). | -| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. | -| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/`. Move the work behind an RPC and import its contract from `shared/`. | -| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/`. Register that contribution from `index.client.tsx` instead. | -| `Node module cannot be imported into the plugin client bundle` | Client code imports `node:*`. Move the operation to `server/` and call it through an RPC. | -| Sidebar item is missing | The plugin is `running`, the item references an existing surface, the icon name is valid, and the client is on the installation's host. | -| Client module is unavailable | Import only the host-provided client modules listed above. | -| RPC rejects | Check both Zod schemas and the daemon-side handler error. | -| Edited code does not appear | Run `npm run typecheck`, then `paseo plugin reload `. | -| Reload fails | Read `paseo plugin ls` and `paseo plugin logs `, fix the source error, then reload; Paseo does not restore the previous bundle. | -| Plugin exits unexpectedly | Read `paseo plugin logs ` for retained initialization, cleanup, stderr, and final crash output. | +| Symptom | Check | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `This plugin was made for an older version of Paseo` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/migration). | +| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. | +| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/`. Move the work behind an RPC and import its contract from `shared/`. | +| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/`. Register that contribution from `index.client.tsx` instead. | +| `Node module cannot be imported into the plugin client bundle` | Client code imports `node:*`. Move the operation to `server/` and call it through an RPC. | +| Sidebar item is missing | The plugin is `running`, the item is not hidden in Settings > Sidebar, its component does not throw, and the client is on the installation's host. | +| Client module is unavailable | Import only the host-provided client modules listed above. | +| RPC rejects | Check both Zod schemas and the daemon-side handler error. | +| Edited code does not appear | Run `npm run typecheck`, then `paseo plugin reload `. | +| Reload fails | Read `paseo plugin ls` and `paseo plugin logs `, fix the source error, then reload; Paseo does not restore the previous bundle. | +| Plugin exits unexpectedly | Read `paseo plugin logs ` for retained initialization, cleanup, stderr, and final crash output. | diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index f9a3086180..6fc8c90f87 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: paseo-plugin -description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a native surface, sidebar item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs. +description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a screen, sidebar header or footer item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs. --- # Paseo plugins @@ -27,21 +27,21 @@ public docs; this skill indexes the references and examples. Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. -| Contribution | Registration | Use it when | Reference | -| ------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` | -| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | -| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands | -| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | -| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | -| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | -| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | -| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | -| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | -| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | -| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | -| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | +| Contribution | Registration | Use it when | Reference | +| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | +| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | +| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | +| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | +| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | +| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | +| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | +| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | +| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | +| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | +| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | +| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | +| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | | Lifecycle task | Example | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | @@ -189,18 +189,24 @@ export default function contribute(client: PluginClientContext) { Use `useWorkspace(id, selector)` and `useAgent(id, selector)`. Selectors are required and their results use shallow equality. Never select the whole snapshot or add an RPC to discover the active workspace or agent. Command callbacks receive the selected host's `paseo`, typed -`rpc(contract, input)`, `openSurface(id)`, and contextual `openPanel(id)` capabilities. +`rpc(contract, input)`, `openScreen({ screenId, params? })`, and contextual `openPanel(id)` +capabilities. -## Add a sidebar surface +## Add a screen and a sidebar item -Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`. +Plugin screens use React Native primitives and work across desktop, browser, iOS, and Android. A sidebar item, in the header or the footer, is a component: render one or more `SidebarRow`s from `@getpaseo/plugin/client/ui`, with `SidebarSeparator` between groups, and call `openScreen` or `openPopover` from them. A screen's `title` is a string or a function of its `params`; its component receives the `params` it was opened with. Color text from `theme.colors` and pad from `layout.compact`. ```tsx -import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { + PluginClientContext, + PluginScreenProps, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; import { useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; -function Counter({ theme, layout }: PluginSurfaceProps) { +function Counter({ theme, layout }: PluginScreenProps) { const [count, setCount] = useState(0); const styles = useMemo( () => ({ @@ -231,19 +237,24 @@ function Counter({ theme, layout }: PluginSurfaceProps) { ); } +function CounterItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + openScreen({ screenId: "main" })} + /> + ); +} + export default function contribute(client: PluginClientContext) { - client.addSurface("main", Counter); - client.addSidebarItem({ - id: "main", - title: "Counter", - icon: "ListPlus", - surface: "main", - }); + client.addScreen({ id: "main", title: "Counter", Component: Counter }); + client.addSidebarHeaderItem({ id: "main", title: "Counter", Component: CounterItem }); return () => {}; } ``` -Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. +Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every screen and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. Before writing imports, classify each module as shared, client, or server. Follow the [SDK import boundaries](https://paseo.sh/docs/plugins/reference.md#runtime-modules), including @@ -278,7 +289,7 @@ Use the existing Paseo SDK for normal Paseo operations. Use plugin RPC only for ### Call Paseo from a surface -`usePaseo()` borrows the selected host's current connection. Never create another client inside a surface. +`usePaseo()` borrows the selected host's current connection. Never create another client inside a surface. It is the plugin's one client in every surface: release your subscriptions in your cleanup; plugin teardown ends the rest. ```tsx import { usePaseo } from "@getpaseo/plugin/client"; @@ -625,7 +636,7 @@ If the user asks to disable the global switch, set `pluginsEnabled` to `false`, Do not edit a local config when the target is a remote daemon. Perform the edit on the daemon machine, or ask the user to use **Settings → Plugins → Enable plugins**. `paseo reload --host ` reloads the remote daemon's own file but does not edit it. -When the same sidebar contribution exists on several connected hosts, Paseo shows it once with a host picker. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host. +When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host. ## Typecheck and manage @@ -671,7 +682,7 @@ After a change: Common failures: -- Missing sidebar item: wrong host, plugin not `running`, invalid Lucide icon, or sidebar item points to a missing surface. +- Missing sidebar item: wrong host, plugin not `running`, the item is hidden in Settings > Sidebar, or its component throws (an invalid Lucide icon name throws). - Unavailable client module: client bundles can use only the host-provided modules listed above. - RPC rejection: input or output failed its Zod schema, or the handler threw. Inspect `paseo plugin logs ` for handler output. - Plugin exits or reload fails: inspect `paseo plugin ls` for status and `paseo plugin logs ` for initialization, cleanup, or crash output.