Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 49b622bd14 test(opencode): reproduce v2 misdetected as v1 when --version answers after 5 s
Failing reproduction for #6051. No fix.
2026-10-05 03:45:37 +02:00
paseo-bot[bot] 36e6ccd6c7 fix(claude): show plan mode when Claude enters it on its own (#6074)
When Claude Code calls its EnterPlanMode tool, it reports the new mode
through a system status message with permissionMode "plan". The Claude
provider only read the compacting status from those messages, so the
session kept the previous mode and never emitted mode_changed: the app,
`paseo inspect`, and the stored agent record all kept showing the old
mode.

The provider now records the mode carried by a status message and emits
mode_changed when it differs, sharing the bookkeeping with the init
message so the plan-approval resume mode stays the mode Claude left.

Closes #6063

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 03:04:07 +02:00
Mohamed Boudra fa0f04058d Keep account usage accessible before the first turn and restore compact sheets
* fix(usage): keep details hoverable and restore compact sidebar sheets

Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet.

* fix(usage): restore settings cog with a fitted popover and compact sheet

Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout.

* fix(app): show account usage before the first agent turn

Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive.

* fix(usage): preserve keyboard access and hover card positioning

Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation.

* chore: remove QA screenshots from the source tree

* fix(app): let hover cards claim Escape before agent shortcuts

Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn.

* fix(app): reopen focused hover cards with button keys

Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts.

* fix(app): release overlay ownership when hover cards close

Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns.

* test(app): pin Linux keyboard identity for composer Find

* test(app): synchronize context usage hover journeys

Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
2026-10-05 01:59:15 +01:00
James Hu d517b3314e fix(opencode): wait for the server to listen before opening the event stream (#5873)
* fix(opencode): wait for the server to listen before opening the event stream

OpenCode can accept TCP connections before its HTTP handler is ready. Starting the event consumer at spawn lets a retry land in that window and hang without receiving server.connected.

Gate the first request on the listening promise. Use a five-second watchdog before connection readiness and abandon stalled attempts independently of fetch abort, ignoring any late records. Keep the existing thirty-second watchdog after readiness.

* fix(opencode): forward the listening gate from the agent client

The first startup fix passed the listening gate from the server manager, but the agent client factory dropped it before building the consumer. Lab logs showed four generations retrying before listening and then hitting the first-record watchdog.

Forward the gate through that factory. Exercise the client-owned manager with a real spawned process in the existing provider dispatch test, and assert that no event request arrives before listening.
2026-10-05 02:18:32 +02:00
Mohamed Boudra a8e6c57f15 fix(app): match compact sidebar footer glyphs to the composer toolbar size
#6044 enlarged the compact footer tap targets to 44x44 and also bumped the
glyphs to 24px. Keep the targets and return the glyphs to 20px, the composer
toolbar glyph size. The compact footer spec now asserts the glyph size.
2026-10-05 01:07:16 +02:00
Mohamed Boudra 1f8a4559a8 fix(providers): show custom context in expandable tool rows (#6090)
* fix(providers): show custom context in expandable tool rows

Preserve custom message type and data instead of attributing extension context to the assistant. Apply the same mapping in live output and history for OMP and Pi, and honor hidden Pi messages without suppressing structured extension updates.

* fix(server): mark truncated plain-text tool content

Keep the existing timeline content budget while making omitted text visible to readers of extension context and other plain-text tools.
2026-10-05 00:03:20 +01:00
paseo-bot[bot] 2187124642 Answer an ACP agent's open permission request when its turn fails (#6045)
* fix(acp): answer open permission requests when a turn fails

When an ACP prompt failed while one of its session/request_permission
requests was still open, Paseo stopped showing the request but never
answered it, so agents that refuse new prompts until the request is
settled stayed stuck until the agent was reloaded.

A turn that ends without completing now answers its open permission
requests with cancelled, as interrupt and close already do.

Closes #6040

* test(acp): cover permission requests left open by a cancelled turn

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 23:31:39 +01:00
Mohamed Boudra 154a088d6e feat(app): default sidebar workspace slots to last activity (#6087) 2026-10-04 23:04:17 +01:00
Bae Seongmin 71b236d38a fix(server): update the running npm installation prefix (#5649) 2026-10-04 23:07:22 +02:00
Mohamed Boudra 8a4ce5a482 fix(website): replace the native search clear control with a muted X button (#6082) 2026-10-04 21:15:23 +01:00
paseo-bot[bot] b3ed7363c6 fix(claude): keep a background subagent's card labeled after its child streams (#6067)
Claude Code answers a background Agent call right away, then streams the
child's frames. Each frame re-emitted the parent's card from the sidechain
tracker after the tool-input cache had dropped the call, replacing the
settled, labeled card with a bare running "Task". The tracker now emits the
parent card only while the parent's tool call is still open.

Closes #6059

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-04 22:07:57 +02:00
Mohamed Boudra c062280429 Ship the plugin directory redesign and keep registry installs behind a daemon flag (#6080)
* website: plugin page design mockups (four directions, throwaway routes)

* website: final plugin page mockup (A directory, B browse frame, list cards, D detail)

* website: browse pages use a four-column card grid in the final plugin mockup

* feat(plugins): keep registry installs behind a daemon flag, off by default

Off restores the pre-registry behavior: bare owner/repo is GitHub shorthand and the
daemon never resolves or contacts a registry on install, update preview, or update.
Enable with pluginRegistryEnabled: true or PASEO_PLUGIN_REGISTRY_ENABLED=1.

* feat(website): ship the plugin directory, browse pages, and single-column detail

Directory: count, build/submit links, What's new, the nine categories, and Most installed
with a This week / This month / All time switch. Browse pages at /plugins/all and
/plugins/category/<slug> with a category sidebar, tabs, and a four-column grid; old
/plugins?category= links redirect. Install counts gain daily KV buckets kept 60 days.

* fix(website): keep a README's leading GitHub alert on the plugin page

* feat(website): search plugins from the directory and browse pages

Typing replaces the URL with /plugins/all?q=<term> (or the current category
with ?q=), which renders matching plugins on the server. Matching covers
name, description, ID, and author. Old /plugins?q= links redirect.

* fix(website): keep the directory's ranking window when searching
2026-10-04 20:38:39 +01:00
Phieu DinhandPhieu Dinh febe9611bf fix(app): stop composer autofocus on touch screens (#5477)
A tablet or an unfolded foldable is wide enough for the desktop layout,
so opening an agent autofocused the composer and raised the on-screen
keyboard over the conversation. Autofocus on open now also requires a
fine pointer, the same signal the app already uses for hover.

A mouse-driven window still focuses the composer when an agent opens,
and sending a message still keeps the composer focused.

Co-authored-by: Phieu Dinh <phieu@fiu.one>
2026-10-04 21:04:30 +02:00
117 changed files with 3929 additions and 1783 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
@@ -92,7 +92,7 @@ portal can cover them.
Painting and keyboard ownership use the same relative layer model. Register
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
highest painted scope alone receives overlay keys, traps focus, and restores
focus when it closes. Do not add component-local global Escape listeners: two
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
If an overlay is rendered by a global host rather than beneath its opener in
+2 -2
View File
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. 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`.
- **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. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. 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`).
+2 -2
View File
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
@@ -150,5 +150,5 @@ Before opening a PR that touches hover:
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
+2 -2
View File
@@ -197,7 +197,7 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+10 -5
View File
@@ -117,7 +117,12 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
@@ -133,13 +138,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
still wins over source resolution.
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
```bash
paseo plugin install github:owner/repository
paseo plugin install owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install github:owner/monorepo:plugins/review
paseo plugin install owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
@@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent
"3m ago",
);
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
+10 -1
View File
@@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo
}
}
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
test("opens and refocuses Find with Control+f from the composer on Linux", async ({
page,
}, testInfo) => {
// Chromium retains the host navigator.platform even when its user agent is overridden.
const session = await page.context().newCDPSession(page);
await session.send("Emulation.setUserAgentOverride", {
userAgent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
platform: "Linux x86_64",
});
await findFromComposer(page, "Control+f", testInfo);
});
@@ -1,7 +1,8 @@
import { test } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import {
expectComposerDraft,
expectComposerFocused,
expectComposerNotFocused,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
await agent.cleanup();
}
});
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-open-",
title: "Composer focus on open",
});
try {
await openAgentRoute(page, agent);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
});
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
await page.evaluate(async (frames) => {
for (let frame = 0; frame < frames; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}, count);
}
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test("opening an agent leaves the composer unfocused", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-touch-",
title: "Composer focus on touch",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
expect(
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
// Autofocus makes its first attempt on the next animation frame.
await waitForAnimationFrames(page, 4);
await expectComposerNotFocused(page);
} finally {
await agent.cleanup();
}
});
});
@@ -1,16 +1,21 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test as base } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import {
composerLocator,
expectComposerVisible,
submitMessageWithButton,
} from "../support/helpers/composer";
import {
type AgentUsageScript,
closeContextWindowSheet,
contextWindowSheet,
contextWindowDetails,
expectRefreshButtons,
expiredLogin,
gate,
hoverContextWindowMeter,
leaveContextWindowMeter,
type MockAgentSession,
moveOntoContextWindowDetails,
onPersonalLogin,
onWorkLogin,
openAgent,
@@ -24,6 +29,7 @@ import {
} from "../support/helpers/context-window";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
import { installLoginUsage } from "../support/helpers/usage-login";
const test = base.extend<{ agent: MockAgentSession }>({
agent: async ({ page: _page }, provide) => {
@@ -107,6 +113,66 @@ const DESKTOP = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
for (const layout of [
{
name: "desktop",
viewport: DESKTOP,
open: hoverContextWindowMeter,
close: leaveContextWindowMeter,
},
{
name: "compact",
viewport: COMPACT,
open: pressContextWindowMeter,
close: closeContextWindowSheet,
},
]) {
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
test.setTimeout(180_000);
const source = await installLoginUsage({
status: "available",
windows: [{ id: "session", label: "Session", usedPct: 31 }],
});
try {
const agent = await seedMockAgentWorkspace({
repoPrefix: "context-window-empty-",
title: "Agent before its first turn",
});
try {
await page.setViewportSize(layout.viewport);
await openAgent(page, agent);
await test.step("the empty meter opens context details and account usage", async () => {
const details = await layout.open(page, "Context window: No context data");
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
await test.step("the first context reading replaces the empty state", async () => {
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
const details = await layout.open(page);
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
).toBeVisible();
});
} finally {
await agent.cleanup();
}
} finally {
await source.cleanup();
}
});
}
type OpenDetails = (page: Page) => Promise<Locator>;
type Shot = (state: string) => Promise<void>;
@@ -142,7 +208,6 @@ async function expectCardsToStreamIn(
// Only the agent's login, never the host's default one, and no pins.
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
await expectUnpinnableRows(details);
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
return details;
}
@@ -177,24 +242,43 @@ async function expectOnlyContextWindowWithoutUsage(
}
for (const theme of ["light", "dark"] as const) {
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
test(`the context window hover card shows the agent's usage (${theme})`, async ({
page,
agent,
}) => {
test.setTimeout(240_000);
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`);
await test.step("reports stream in one card at a time, without Refresh", async () => {
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
// The tooltip cannot be pressed, so its cards have no Refresh.
await expectRefreshButtons(tooltip, 0);
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
await expectRefreshButtons(card, 2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("the card stays open while the pointer moves onto it", async () => {
const card = contextWindowDetails(page);
await moveOntoContextWindowDetails(page, card);
await page.waitForTimeout(300);
await expect(card).toBeVisible();
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
});
await test.step("after a resume under another login, only that login shows", async () => {
// Refresh can retain focus; return to the composer before testing pointer dismissal.
await composerLocator(page).focus();
// Closed while the old login's report is still on its way.
await leaveContextWindowMeter(page);
const slowSource = gate();
@@ -208,25 +292,25 @@ for (const theme of ["light", "dark"] as const) {
usage.answerNext({
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
});
const tooltip = await hoverContextWindowMeter(page);
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
const card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
// The old login's report lands during the new request, before the new login's.
slowSource.open();
resumed.open();
await expect(
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }),
).toBeVisible();
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
finished.open();
});
await test.step("a report with a problem shows it on the card", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
await reloadAgent(page);
const tooltip = await hoverContextWindowMeter(page);
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
const card = await hoverContextWindowMeter(page);
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
await shot("problem");
});
@@ -256,13 +340,15 @@ for (const theme of ["light", "dark"] as const) {
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
await expectRefreshButtons(sheet, 2);
// Without hover, each card prints its freshness instead of a Refresh tooltip.
await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const sheet = contextWindowSheet(page);
const sheet = contextWindowDetails(page);
await refreshUsageCard(sheet, "Claude");
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
@@ -283,3 +369,81 @@ for (const theme of ["light", "dark"] as const) {
});
});
}
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
}) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stop).toBeVisible();
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await expect(trigger).toHaveAttribute("tabindex", "0");
await trigger.focus();
const details = contextWindowDetails(page);
await expect(details).toBeVisible();
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
await trigger.press("ArrowDown");
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
for (const key of ["Enter", " "]) {
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press(key);
await expect(details).toBeVisible();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
}
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press("ArrowDown");
await expect(details).toBeVisible();
await page.setViewportSize({ width: 1280, height: 850 });
await expect(details).toHaveCount(0);
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const input = composerLocator(page);
await input.focus();
await hoverContextWindowMeter(page);
await expect(input).toBeFocused();
await leaveContextWindowMeter(page);
await expect(input).toBeFocused();
});
test("Find remains available after dismissing context details", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await trigger.focus();
await expect(contextWindowDetails(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press("ControlOrMeta+f");
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
});
@@ -96,37 +96,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("toggles comments using each file language's tokens", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-comment-shortcut-" });
const sources = [
{ filename: "comment.ts", content: "const value = 1;", commented: "// const value = 1;" },
{ filename: "comment.py", content: "value = 1", commented: "# value = 1" },
{ filename: "comment.css", content: "body {}", commented: "/* body {} */" },
];
for (const source of sources) {
await writeFile(path.join(workspace.repoPath, source.filename), source.content, "utf8");
}
await workspace.navigateTo();
for (const source of sources) {
await openWorkspaceFile(page, source.filename);
await editor(page).click();
await editor(page).press("ControlOrMeta+/");
await expect(editor(page)).toHaveText(source.commented);
await expect
.poll(() => readFile(path.join(workspace.repoPath, source.filename), "utf8"))
.toBe(source.commented);
await editor(page).press("ControlOrMeta+/");
await expect(editor(page)).toHaveText(source.content);
await expect
.poll(() => readFile(path.join(workspace.repoPath, source.filename), "utf8"))
.toBe(source.content);
}
await test.info().attach("comment-shortcut", {
body: await page.screenshot(),
contentType: "image/png",
});
});
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -190,6 +190,9 @@ async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
const bounds = await button.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(44);
expect(bounds?.height).toBeGreaterThanOrEqual(44);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
}
}
@@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expect(workspaceRow(page)).toBeVisible();
await toggleTrailing(page, "Diff stats", true);
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
}
@@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) {
await gotoAppShell(page);
const row = workspaceRow(page);
await expect(row).toBeVisible();
await toggleTrailing(page, "Diff stats");
await page.mouse.move(0, 0);
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
return titleWidth(row);
+13 -29
View File
@@ -27,6 +27,8 @@ import {
expectPinnedUsage,
pinRow,
openUsageOptions,
closeUsageOptions,
expectUsageOptionsFitContent,
showUsageAs,
refreshAllUsage,
togglePin,
@@ -243,7 +245,7 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
});
@@ -426,7 +428,7 @@ for (const theme of ["light", "dark"] as const) {
desktop: { width: 1440, height: 900 },
compact: { width: 390, height: 844 },
})) {
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript((value) => {
const key = "@paseo:app-settings";
@@ -440,42 +442,24 @@ for (const theme of ["light", "dark"] as const) {
await page.goto("/usage");
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = screen.getByRole("button", { name: "Settings", exact: true });
const chevron = settings.locator("svg").locator("../..");
const settings = page.getByTestId("usage-options-menu");
await expect(settings).toHaveAccessibleName("Settings");
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(chevron).toHaveCSS("transform", "none");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
).toHaveCSS("opacity", "1");
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
await togglePin(screen, "Claude", "Weekly");
await page.mouse.move(0, viewport.height - 1);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
).toHaveCSS("opacity", "1");
await openUsageOptions(page);
await expect(settings).toHaveAttribute("aria-expanded", "true");
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
"aria-selected",
"true",
);
await settings.blur();
await page.mouse.move(0, viewport.height - 1);
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
await expectUsageOptionsFitContent(page);
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
await showUsageAs(page, "remaining");
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
);
await settings.click();
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await openUsageOptions(page);
await closeUsageOptions(page);
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
});
@@ -95,8 +95,8 @@ test.describe("usage settings", () => {
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
// Percentages now live on the Usage screen, and still apply to the host section.
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
// The shared percentages setting applies to the host section.
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
const hostUsageUrl = page.url();
await page.goto("/usage");
await showUsageAs(page, "remaining");
@@ -14,6 +14,9 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
closeUsageSheet,
closeUsageOptions,
openUsageSheetFromIcon,
expectNoUsageItem,
expectOnUsageScreen,
expectPinnedUsage,
@@ -62,6 +65,33 @@ async function footerClip(page: Page) {
}
test.describe("Usage item", () => {
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]);
await gotoAppShell(page);
await openCompactSidebar(page);
const startingUrl = page.url();
await expectNoUsageItem(page);
await test.step("the icon opens a sheet without leaving the current screen", async () => {
await openUsageSheetFromIcon(page);
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-icon-sheet");
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await closeUsageSheet(page);
});
await test.step("the summary opens the same sheet", async () => {
await usageItem(page).click();
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
await closeUsageSheet(page);
});
});
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
page,
}) => {
@@ -189,25 +219,22 @@ test.describe("Usage item", () => {
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the same inline Settings row and title-row Refresh all.
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
"aria-expanded",
"false",
);
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await openUsageOptions(page);
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
"true",
);
await qaScreenshot(page, "compact-settings-expanded");
await sheet.getByTestId("usage-options-toggle").click();
await closeUsageOptions(page);
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
// The Usage sheet reserves the top 20% as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
@@ -229,7 +256,7 @@ test.describe("Usage item", () => {
"true",
);
await qaScreenshot(page, "usage-settings-expanded");
await page.getByTestId("usage-options-toggle").click();
await closeUsageOptions(page);
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
@@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise<void> {
await expect(composerInput(page)).toBeFocused();
}
export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -59,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
await input.press("Enter");
}
/** The Send button works on compact screens, where Enter inserts a newline. */
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
await fillComposerDraft(page, text);
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
@@ -26,38 +26,50 @@ export async function openAgent(page: Page, session: MockAgentSession): Promise<
}
export async function reloadAgent(page: Page): Promise<void> {
await page.reload({ waitUntil: "commit" });
// Reload must not reopen the card under a stationary pointer.
await page.mouse.move(0, 0);
await page.reload({ waitUntil: "domcontentloaded" });
await expectComposerVisible(page);
}
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
await expect(tooltip).toBeVisible();
return tooltip;
/** Wide screens show the meter's details in a hover card while the pointer is on it. */
export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
const card = contextWindowDetails(page);
await expect(card).toBeVisible();
await expect(card).toBeInViewport();
return card;
}
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
const box = await card.boundingBox();
if (!meter || !box) throw new Error("The meter and its hover card must be on screen.");
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
await expect(contextWindowDetails(page)).toHaveCount(0);
}
/** Compact screens open the meter's details in a sheet. */
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
const sheet = contextWindowSheet(page);
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
const sheet = contextWindowDetails(page);
await expect(sheet).toBeVisible();
return sheet;
}
export async function closeContextWindowSheet(page: Page): Promise<void> {
const sheet = contextWindowSheet(page);
const sheet = contextWindowDetails(page);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export function contextWindowSheet(page: Page): Locator {
/** The hover card on wide screens and the sheet on compact ones are both this dialog. */
export function contextWindowDetails(page: Page): Locator {
return page.getByRole("dialog", { name: "Context window" });
}
@@ -159,7 +171,7 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
export async function qaScreenshot(page: Page, name: string): Promise<void> {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let the sheet and tooltip animations settle.
// Let the sheet and hover card animations settle.
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
@@ -6,6 +6,7 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, type Locator, type Page } from "@playwright/test";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
export async function installTallUsageSource() {
@@ -194,6 +195,21 @@ export async function openUsageScreenFromIcon(page: Page): Promise<void> {
await expectOnUsageScreen(page);
}
/** Both footer entry points open Usage over the current screen on a phone. */
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Usage", exact: true }).click();
await expect(usageSheet(page)).toBeVisible();
}
export async function closeUsageSheet(page: Page): Promise<void> {
const sheet = usageSheet(page);
await waitForSettledPosition(sheet);
const bounds = await sheet.boundingBox();
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
await expect(sheet).toHaveCount(0);
}
function summaryInSidebarSwitch(page: Page): Locator {
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
}
@@ -208,15 +224,39 @@ export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
await openUsageOptions(page);
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
await closeUsageOptions(page);
}
/** Expand the inline Settings row when its controls are folded. */
/** Open the cog's settings popover or compact sheet. */
export async function openUsageOptions(page: Page): Promise<void> {
const toggle = visible(page, "usage-options-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
if (!(await visible(page, "usage-display-used").isVisible())) {
await visible(page, "usage-options-menu").click();
}
await expect(visible(page, "usage-display-used")).toBeVisible();
}
export function usageOptionsSurface(page: Page): Locator {
return page
.locator(
'[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible',
)
.first();
}
export async function expectUsageOptionsFitContent(page: Page): Promise<void> {
await waitForSettledPosition(usageOptionsSurface(page));
const surface = await usageOptionsSurface(page).boundingBox();
const fields = await page.getByTestId("usage-options-fields").boundingBox();
if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit.");
expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10);
}
export async function closeUsageOptions(page: Page): Promise<void> {
await waitForSettledPosition(usageOptionsSurface(page));
await page.mouse.click(0, 0);
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
}
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
await openUsageOptions(page);
await visible(page, `usage-display-${displayAs}`).click();
@@ -224,6 +264,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
"aria-selected",
"true",
);
await closeUsageOptions(page);
}
export async function refreshAllUsage(page: Page): Promise<void> {
@@ -7,26 +7,26 @@ import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowDetailsProps {
serverId: string;
agentId: string;
percentage: number;
usedTokens: number;
maxTokens: number;
context: {
percentage: number;
usedTokens: number;
maxTokens: number;
} | null;
sessionCost: string | null;
/** The tooltip titles itself; the sheet's header carries the title instead. */
/** The hover card and tooltip title themselves; the sheet's header carries the title instead. */
showTitle: boolean;
/** Only the sheet can be pressed, so only its usage cards have a Refresh button. */
/** Whether the usage cards have a Refresh button; without one they show their freshness. */
refreshable: boolean;
}
/**
* What the context window meter opens: how full the window is, then the usage of the agent's
* account. The wide tooltip and the compact sheet both render it.
* account. The desktop hover card, the native wide tooltip, and the compact sheet all render it.
*/
export function ContextWindowDetails({
serverId,
agentId,
percentage,
usedTokens,
maxTokens,
context,
sessionCost,
showTitle,
refreshable,
@@ -36,13 +36,21 @@ export function ContextWindowDetails({
<>
<View style={styles.summary}>
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
<Text style={styles.text}>{t("contextWindow.used", { percentage })}</Text>
<Text style={styles.detail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{context ? (
<>
<Text style={styles.text}>
{t("contextWindow.used", { percentage: context.percentage })}
</Text>
<Text style={styles.detail}>
{t("contextWindow.tokens", {
used: formatTokenCount(context.usedTokens),
max: formatTokenCount(context.maxTokens),
})}
</Text>
</>
) : (
<Text style={styles.detail}>{t("contextWindow.noData")}</Text>
)}
{sessionCost ? (
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
) : null}
@@ -1,11 +1,14 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
@@ -16,19 +19,16 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const USAGE_POPOVER_WIDTH = 300;
const COMPACT_SVG_SIZE = 12;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
@@ -59,28 +59,22 @@ function formatSessionCost(value: number): string | null {
return `$${value.toFixed(2)}`;
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
function getProgressColor(percentage: number, theme: Theme): string {
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
return theme.colors.destructive;
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
return theme.colors.palette.amber[500];
}
return { progress: theme.colors.foregroundMuted, track };
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
@@ -88,14 +82,65 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
containerStyle: styles.container,
};
}
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
const ContextWindowRing = withUnistyles(function ContextWindowRing({
size,
radius,
strokeWidth,
percentage,
trackColor,
progressColor,
}: {
size: number;
radius: number;
strokeWidth: number;
percentage: number | null;
trackColor: string;
progressColor: string;
}) {
const center = size / 2;
const circumference = 2 * Math.PI * radius;
return (
<Svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{percentage !== null ? (
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={progressColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference - (clampPercentage(percentage) / 100) * circumference}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
) : null}
</Svg>
);
});
export function ContextWindowMeter({
serverId,
agentId,
@@ -103,15 +148,13 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
const popoverWidth = Math.min(360, width - 24);
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -121,79 +164,39 @@ export function ContextWindowMeter({
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, glyphSize);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const context = useMemo(
() =>
percentage !== null && maxTokens !== null && usedTokens !== null
? { percentage: Math.round(percentage), maxTokens, usedTokens }
: null,
[percentage, maxTokens, usedTokens],
);
const meterColors = useCallback(
(theme: Theme) => ({
progressColor: getProgressColor(percentage ?? 0, theme),
trackColor: theme.colors.surface3,
}),
[percentage],
);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const ring = (
<Svg
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={strokeWidth}
/>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.progress}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
<ContextWindowRing
size={geometry.svgSize}
radius={geometry.radius}
strokeWidth={geometry.strokeWidth}
percentage={percentage}
uniProps={meterColors}
/>
);
const percentageLabel = showPercentage ? (
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
) : null;
const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage });
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
: t("contextWindow.accessibilityNoData");
if (isCompact) {
return (
@@ -212,9 +215,7 @@ export function ContextWindowMeter({
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
percentage={roundedPercentage}
usedTokens={usedTokens}
maxTokens={maxTokens}
context={context}
sessionCost={formattedSessionCost}
showTitle={false}
refreshable
@@ -224,10 +225,51 @@ export function ContextWindowMeter({
);
}
const popoverStyle = showsUsage
? [styles.usagePopover, { width: popoverWidth }]
: styles.plainPopover;
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
// takes no presses, so its usage cards have no Refresh.
if (isNative) {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
>
{ring}
{percentageLabel}
</Pressable>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
offset={8}
maxWidth={showsUsage ? popoverWidth : undefined}
style={popoverStyle}
testID="context-window-meter-tooltip"
>
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
context={context}
sessionCost={formattedSessionCost}
showTitle
refreshable={false}
/>
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
<HoverCard>
<HoverCardTrigger focusable accessibilityLabel={accessibilityLabel}>
<View
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
@@ -235,28 +277,26 @@ export function ContextWindowMeter({
>
{ring}
{percentageLabel}
</Pressable>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
</View>
</HoverCardTrigger>
<HoverCardContent
placement="top"
offset={8}
maxWidth={showsUsage ? popoverWidth : undefined}
style={showsUsage ? [styles.popover, { width: popoverWidth }] : undefined}
testID="context-window-meter-tooltip"
role="dialog"
accessibilityLabel={t("contextWindow.title")}
testID="context-window-details"
style={popoverStyle}
>
<ContextWindowDetails
serverId={serverId}
agentId={agentId}
percentage={roundedPercentage}
usedTokens={usedTokens}
maxTokens={maxTokens}
context={context}
sessionCost={formattedSessionCost}
showTitle
refreshable={false}
refreshable
/>
</TooltipContent>
</Tooltip>
</HoverCardContent>
</HoverCard>
);
}
@@ -281,11 +321,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.base,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
popover: { padding: theme.spacing[4], gap: theme.spacing[4] },
// Plain details use a small inset; account usage cards have their own content density.
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
}));
+51 -38
View File
@@ -53,7 +53,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -291,7 +296,7 @@ function FooterIconButton({
buttonRef?: RefObject<View | null>;
}) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.xl : theme.iconSize.md;
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return (
<Tooltip delayDuration={300}>
@@ -418,46 +423,54 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const openUsageScreen = useOpenUsageScreen();
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
return (
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={openUsageScreen}
testID="sidebar-usage-icon"
label={labels.usage}
icon={CircleGauge}
theme={theme}
/>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
</View>
</UsageSidebarRoot>
);
}
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
onPress={openUsage}
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
@@ -113,7 +113,7 @@ export function SidebarHelpMenu() {
>
{({ hovered }) => (
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.xl : ICON_SIZE.md}
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -55,6 +55,18 @@ describe("computePosition", () => {
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
});
it("lets hover cards flip horizontally when the opposite side has room", () => {
const result = position({
triggerRect: { ...TRIGGER, x: 850 },
placement: "right",
flipHorizontal: true,
});
expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" });
expect(
position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement,
).toBe("right");
});
it("keeps the surface inside a display area that does not start at the origin", () => {
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
@@ -1,10 +1,8 @@
import type { View } from "react-native";
/**
* Where a floating menu surface goes relative to the thing that opened it.
*
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
* Submenus need a third caller, so it lives here now and the menus import it.
* Where a floating surface goes relative to the thing that opened it. Menus and hover cards
* place themselves with it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
@@ -38,14 +36,15 @@ export function measureElement(element: View): Promise<Rect> {
* there is more room above. Both conditions matter: flipping into a side that is equally
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
*
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
* caller choosing its side up front, because flipping it mid-open would move the surface out
* from under the pointer that is travelling toward it.
* Horizontal flipping is opt-in for hover cards. Submenus choose their direction up front so
* they do not move out from under the pointer travelling toward them.
*/
function flipPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentHeight: number;
contentWidth: number;
flipHorizontal: boolean;
displayArea: Rect;
}): Placement {
const { placement, triggerRect, contentHeight, displayArea } = input;
@@ -58,6 +57,14 @@ function flipPlacement(input: {
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
return "bottom";
}
if (input.flipHorizontal) {
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
if (placement === "right" && spaceRight < input.contentWidth && spaceLeft > spaceRight)
return "left";
if (placement === "left" && spaceLeft < input.contentWidth && spaceRight > spaceLeft)
return "right";
}
return placement;
}
@@ -120,6 +127,7 @@ export function computePosition({
placement,
alignment,
offset,
flipHorizontal = false,
}: {
triggerRect: Rect;
contentSize: Size;
@@ -127,11 +135,15 @@ export function computePosition({
placement: Placement;
alignment: Alignment;
offset: number;
/** Hover cards can choose either side; submenu direction is owned by the menu. */
flipHorizontal?: boolean;
}): { x: number; y: number; actualPlacement: Placement } {
const actualPlacement = flipPlacement({
placement,
triggerRect,
contentHeight: contentSize.height,
contentWidth: contentSize.width,
flipHorizontal,
displayArea,
});
const anchored = anchorToPlacement({
@@ -0,0 +1,405 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import {
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import {
computePosition,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "./anchor";
const CLOSE_GRACE_MS = 100;
interface HoverCardContextValue {
open: boolean;
layer: number;
setTriggerRef: (node: View | null) => void;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
openNow: () => void;
scheduleClose: () => void;
}
const HoverCardContext = createContext<HoverCardContextValue | null>(null);
/**
* A card that opens while the pointer is on its trigger and stays open while the pointer crosses
* onto it, so its content can be pressed. Hover only exists on web; elsewhere the trigger renders
* alone and the content never mounts. See docs/hover.md.
*
* ```tsx
* <HoverCard>
* <HoverCardTrigger>{trigger}</HoverCardTrigger>
* <HoverCardContent placement="top">{details}</HoverCardContent>
* </HoverCard>
* ```
*/
export function HoverCard({
disabled = false,
children,
}: {
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
disabled?: boolean;
children: ReactNode;
}): ReactNode {
if (!isWeb) return children;
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
}
function WebHoverCard({
disabled,
children,
}: {
disabled: boolean;
children: ReactNode;
}): ReactElement {
const layer = useOverlayLayer("floating");
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current);
graceTimerRef.current = null;
}
}, []);
const focusInside = useCallback(() => {
if (!isWeb) return false;
const active = document.activeElement;
return [triggerRef.current, contentRef.current].some((view) =>
(view as unknown as HTMLElement | null)?.contains(active),
);
}, []);
const scheduleClose = useCallback(() => {
if (focusInside()) return;
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
setOpen(false);
}, CLOSE_GRACE_MS);
}, [focusInside]);
const openNow = useCallback(() => {
clearGraceTimer();
if (!disabled) {
setOpen(true);
}
}, [clearGraceTimer, disabled]);
const keyPressed = useCallback(
(event: KeyboardEvent): boolean => {
if (!isWeb) return false;
const trigger = triggerRef.current as unknown as HTMLElement | null;
if (!trigger) return false;
if (event.key === "Escape" && open) {
event.preventDefault();
if (focusInside()) trigger.focus();
clearGraceTimer();
setOpen(false);
return true;
} else if (
focusInside() &&
event.target === trigger &&
["ArrowDown", "Enter", " "].includes(event.key)
) {
event.preventDefault();
openNow();
requestAnimationFrame(() => {
(contentRef.current as unknown as HTMLElement | null)
?.querySelector<HTMLElement>(
'button, [role="button"][tabindex="0"], a[href], input, [tabindex="0"]',
)
?.focus();
});
return true;
}
return false;
},
[clearGraceTimer, focusInside, open, openNow],
);
const setOverlayScope = useWebOverlayRegistration({
active: !disabled && open,
layer,
onKeyDown: keyPressed,
manageFocus: false,
});
const setTriggerRef = useCallback(
(node: View | null) => {
triggerRef.current = node;
setOverlayScope(node);
},
[setOverlayScope],
);
useEffect(() => {
if (!isWeb) return;
const trigger = triggerRef.current as unknown as HTMLElement | null;
if (!trigger) return;
const close = () => {
clearGraceTimer();
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Node;
if (trigger.contains(target)) {
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
};
const focusLeft = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (
!trigger.contains(next) &&
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
) {
scheduleClose();
}
};
const activateClosedTrigger = (event: KeyboardEvent) => {
if (
!open &&
!event.defaultPrevented &&
!event.isComposing &&
["ArrowDown", "Enter", " "].includes(event.key)
)
keyPressed(event);
};
const scrolled = (event: Event) => {
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
close();
};
document.addEventListener("focusin", focusEntered);
document.addEventListener("focusout", focusLeft);
trigger.addEventListener("keydown", activateClosedTrigger);
document.addEventListener("scroll", scrolled, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("focusin", focusEntered);
document.removeEventListener("focusout", focusLeft);
trigger.removeEventListener("keydown", activateClosedTrigger);
document.removeEventListener("scroll", scrolled, true);
window.removeEventListener("resize", close);
};
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering
// it (including the bridge) cancels the pending close.
useHoverSafeZone({
enabled: open,
triggerRef,
contentRef,
onEnterSafeZone: clearGraceTimer,
onLeaveSafeZone: scheduleClose,
});
useEffect(() => {
if (disabled) {
clearGraceTimer();
setOpen(false);
}
}, [clearGraceTimer, disabled]);
useEffect(() => clearGraceTimer, [clearGraceTimer]);
const value = useMemo<HoverCardContextValue>(
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
[open, layer, setTriggerRef, openNow, scheduleClose],
);
return (
<OverlayLayerProvider layer={layer}>
<HoverCardContext.Provider value={value}>{children}</HoverCardContext.Provider>
</OverlayLayerProvider>
);
}
export function HoverCardTrigger({
children,
focusable = false,
accessibilityLabel,
}: {
children: ReactNode;
focusable?: boolean;
accessibilityLabel?: string;
}): ReactNode {
const ctx = useContext(HoverCardContext);
if (!ctx) return children;
return (
<View
ref={ctx.setTriggerRef}
tabIndex={focusable ? 0 : undefined}
accessibilityRole={focusable ? "button" : undefined}
accessibilityLabel={accessibilityLabel}
collapsable={false}
onPointerEnter={ctx.openNow}
onPointerLeave={ctx.scheduleClose}
>
{children}
</View>
);
}
interface HoverCardContentProps {
placement: Placement;
alignment?: Alignment;
offset?: number;
/** Layered over the default popover surface. */
style?: StyleProp<ViewStyle>;
role?: Role;
accessibilityLabel?: string;
testID?: string;
/** Mounted only while the card is open, so whatever it fetches runs only then. */
children: ReactNode;
}
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
const ctx = useContext(HoverCardContext);
if (!ctx?.open) return null;
return (
<HoverCardSurface
{...props}
layer={ctx.layer}
triggerRef={ctx.triggerRef}
contentRef={ctx.contentRef}
/>
);
}
function HoverCardSurface({
layer,
triggerRef,
contentRef,
placement,
alignment = "center",
offset = 4,
style,
role,
accessibilityLabel,
testID,
children,
}: HoverCardContentProps & {
layer: number;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
}): ReactElement {
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect(rect);
return undefined;
});
return () => {
cancelled = true;
};
}, [triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
triggerRect,
contentSize,
displayArea: { x: 0, y: 0, width, height },
placement,
flipHorizontal: true,
alignment,
offset,
});
setPosition({ x, y });
}, [alignment, contentSize, offset, placement, triggerRect]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
role={role}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
surface: {
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.xl,
...theme.shadow.md,
zIndex: 1000,
},
}));
+1 -1
View File
@@ -35,4 +35,4 @@ export {
type MenuPresentation,
} from "./menu-context";
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
export type { Alignment, Placement, Rect, Size } from "../anchor";
@@ -9,7 +9,7 @@ import {
openSubPage,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "./menu-anchor";
import type { Rect } from "../anchor";
/**
* How a menu draws itself once it is open.
@@ -38,7 +38,7 @@ import {
type Placement,
type Rect,
type Size,
} from "./menu-anchor";
} from "../anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
const CONTENT_ENTERING_DURATION_MS = 150;
@@ -26,7 +26,7 @@ import { MenuPage } from "./menu-item";
import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import { getMenuSheetBottomPadding } from "./menu-sheet-layout";
import type { Alignment, Placement } from "./menu-anchor";
import type { Alignment, Placement } from "../anchor";
import type { KeyboardFocusScope } from "@/keyboard/actions";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
@@ -1,16 +1,12 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Check,
@@ -27,17 +23,13 @@ import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { useHosts } from "@/runtime/host-runtime";
import {
COUNTED_CHECK_PRESENTATIONS,
@@ -48,52 +40,6 @@ import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-cop
import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view";
import { buildForgeChecksUrl } from "@/git/forge-url";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
offset: number;
}): { x: number; y: number } {
let x = triggerRect.x + triggerRect.width + offset;
let y = triggerRect.y;
// If it overflows right, try left
if (x + contentSize.width > displayArea.width - 8) {
x = triggerRect.x - contentSize.width - offset;
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
);
return { x, y };
}
const HOVER_GRACE_MS = 100;
const HOVER_CARD_WIDTH = 260;
interface WorkspaceHoverCardProps {
@@ -110,237 +56,80 @@ export function WorkspaceHoverCard({
disabled = false,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
if (!isWeb || isCompact) {
if (isCompact) {
return children;
}
return (
<WorkspaceHoverCardDesktop
workspace={workspace}
prHint={prHint}
isDragging={isDragging}
disabled={disabled}
>
{children}
</WorkspaceHoverCardDesktop>
);
}
function WorkspaceHoverCardDesktop({
workspace,
prHint,
isDragging,
disabled = false,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerHoveredRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current);
graceTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
setOpen(false);
}, HOVER_GRACE_MS);
}, []);
const handleTriggerEnter = useCallback(() => {
triggerHoveredRef.current = true;
clearGraceTimer();
if (!isDragging && !disabled) {
setOpen(true);
}
}, [clearGraceTimer, disabled, isDragging]);
const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false;
scheduleClose();
}, [scheduleClose]);
// While open, the safe zone covers trigger + content + the bridge between
// them. Close only fires when the pointer leaves the safe zone; re-entering
// it (including the bridge) cancels the pending close.
useHoverSafeZone({
enabled: open,
triggerRef,
contentRef,
onEnterSafeZone: clearGraceTimer,
onLeaveSafeZone: scheduleClose,
});
// Close while another row interaction owns attention.
useEffect(() => {
if (isDragging || disabled) {
clearGraceTimer();
setOpen(false);
}
}, [clearGraceTimer, disabled, isDragging]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearGraceTimer();
};
}, [clearGraceTimer]);
return (
<View
ref={triggerRef}
collapsable={false}
onPointerEnter={handleTriggerEnter}
onPointerLeave={handleTriggerLeave}
>
{children}
{open ? (
<WorkspaceHoverCardContent
workspace={workspace}
prHint={prHint}
triggerRef={triggerRef}
contentRef={contentRef}
/>
) : null}
</View>
<HoverCard disabled={isDragging || disabled}>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCardContent
placement="right"
role="menu"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
>
<WorkspaceHoverCardContent workspace={workspace} prHint={prHint} />
</HoverCardContent>
</HoverCard>
);
}
function WorkspaceHoverCardContent({
workspace,
prHint,
triggerRef,
contentRef,
}: {
workspace: SidebarWorkspaceEntry;
prHint: PrHint | null;
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
}): ReactElement {
const { t } = useTranslation();
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
// Measure trigger — same pattern as tooltip.tsx
useEffect(() => {
if (!triggerRef.current) return;
let cancelled = false;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect(rect);
return;
});
return () => {
cancelled = true;
};
}, [triggerRef]);
// Compute position when both measurements are available
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computeHoverCardPosition({
triggerRect,
contentSize,
displayArea,
offset: 4,
});
setPosition(result);
}, [triggerRect, contentSize]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
accessibilityRole="menu"
accessibilityLabel={t("workspace.hoverCard.scriptsAccessibility")}
testID="workspace-hover-card"
style={styles.card}
frameStyle={frameStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
{workspace.diffStat ? (
<View style={styles.cardInfoRow}>
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
</View>
) : null}
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
value={workspace.currentBranch}
copyValue={workspace.currentBranch}
copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch"
/>
) : null}
{workspace.workspaceDirectoryLabel ? (
<CopyableInfoRow
icon={ThemedFolder}
value={workspace.workspaceDirectoryLabel}
copyValue={workspace.workspaceDirectory}
copyLabel={t("workspace.hoverCard.copyPath")}
testID="hover-card-workspace-cwd"
/>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable
checks={prHint.checks}
url={prHint.url}
forge={prHint.forge}
/>
</>
) : null}
</FloatingSurface>
<>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} testID="hover-card-workspace-name">
{workspace.name}
</Text>
</View>
</Portal>
{prHint ? <PrBadge hint={prHint} style={styles.cardInfoRow} /> : null}
{workspace.diffStat ? (
<View style={styles.cardInfoRow}>
<ThemedFileDiff size={12} uniProps={foregroundMutedColorMapping} />
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
</View>
) : null}
<HostRow serverId={workspace.serverId} />
{workspace.currentBranch ? (
<CopyableInfoRow
icon={ThemedGitBranch}
value={workspace.currentBranch}
copyValue={workspace.currentBranch}
copyLabel={t("workspace.hoverCard.copyBranchName")}
testID="hover-card-workspace-branch"
/>
) : null}
{workspace.workspaceDirectoryLabel ? (
<CopyableInfoRow
icon={ThemedFolder}
value={workspace.workspaceDirectoryLabel}
copyValue={workspace.workspaceDirectory}
copyLabel={t("workspace.hoverCard.copyPath")}
testID="hover-card-workspace-cwd"
/>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} forge={prHint.forge} />
</>
) : null}
</>
);
}
@@ -561,14 +350,6 @@ function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; ho
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
card: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
@@ -581,7 +362,6 @@ const styles = StyleSheet.create((theme) => ({
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
zIndex: 1000,
},
cardHeader: {
flexDirection: "row",
+20 -72
View File
@@ -22,11 +22,11 @@ import {
useImperativeHandle,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
@@ -278,41 +278,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
};
}
function renderContextWindowMeter(
serverId: string,
agentId: string,
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
pending: boolean,
glyphSize: number,
): ReactElement | null {
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
if (!hasData && !pending) {
return null;
}
return (
<ContextWindowMeter
serverId={serverId}
agentId={agentId}
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
pending={pending}
glyphSize={glyphSize}
/>
);
}
function resolveContextWindowPlacement(
meter: ReactElement | null,
reserveSlot: boolean,
): ReactNode {
return reserveSlot ? <View style={styles.contextWindowMeterSlot}>{meter}</View> : null;
}
interface RenderLeftContentArgs {
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
@@ -1006,16 +971,6 @@ interface ComposerProps {
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
rawMax: number | null,
rawUsed: number | null,
): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } {
if (typeof rawMax === "number" && typeof rawUsed === "number") {
return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed };
}
return { contextWindowMaxTokens: null, contextWindowUsedTokens: null };
}
interface ComposerAutocompleteHandle {
onKeyPress: (event: ComposerKeyPressEvent) => boolean;
}
@@ -1312,6 +1267,7 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
const hasText = useSyncExternalStore(
@@ -2069,40 +2025,31 @@ function ComposerContentImpl({
],
);
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
);
const contextWindowPending = agentState.status === "initializing" || isAgentRunning;
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const contextWindowMeter = useMemo(
const beforeVoiceContent = useMemo(
() =>
renderContextWindowMeter(
serverId,
agentId,
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
contextWindowPending,
contextWindowMeterGlyphSize,
),
hasAgent ? (
<View style={styles.contextWindowMeterSlot}>
<ContextWindowMeter
serverId={serverId}
agentId={agentId}
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={contextWindowMeterGlyphSize}
/>
</View>
) : null,
[
hasAgent,
serverId,
agentId,
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
() => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}</>,
[contextWindowMeter, hasAgent],
);
const hasGithubAttachment = useMemo(
() =>
@@ -2383,7 +2330,8 @@ function ComposerContentImpl({
{ disabled: isSubmitLoadingVisible },
);
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
// Focusing the composer on a touch screen raises the on-screen keyboard over the conversation.
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
() =>
@@ -1,98 +0,0 @@
import { EditorState, type Transaction } from "@codemirror/state";
import { toggleComment } from "@codemirror/commands";
import { getLanguageForFile } from "@getpaseo/highlight";
import { expect, test } from "vitest";
test.each([
["source.ts", "const value = 1;", "// const value = 1;"],
["source.py", "value = 1", "# value = 1"],
["source.css", "body {}", "/* body {} */"],
["source.html", "<p>text</p>", "<!-- <p>text</p> -->"],
["source.md", "text", "<!-- text -->"],
["source.mdx", "text", "{/* text */}"],
["source.ex", "value = 1", "# value = 1"],
["source.cs", "var value = 1;", "// var value = 1;"],
["source.nix", "1", "# 1"],
])("toggles the comment and restores the source in %s", (filename, doc, commented) => {
const language = getLanguageForFile(filename);
if (!language) throw new Error(`Missing language for ${filename}`);
const target = editorTarget(EditorState.create({ doc, extensions: [language] }));
expect(toggleComment(target)).toBe(true);
expect(target.state.doc.toString()).toBe(commented);
expect(toggleComment(target)).toBe(true);
expect(target.state.doc.toString()).toBe(doc);
});
const jsxView = [
"const view = (",
" <div",
' className="x"',
" onClick={() => go()}",
" >",
" text",
" {value}",
" <span />",
"{flag}",
"<br />",
" <Foo",
' a="1"',
" />",
" {items.map(() => {",
" const item = 1;",
" })}",
" </div>",
");",
].join("\n");
test.each([
["view.jsx", "text", "{/* text */}"],
["view.tsx", "text", "{/* text */}"],
["view.tsx", "{value}", "{/* {value} */}"],
["view.tsx", "<span />", "{/* <span /> */}"],
["view.tsx", "{flag}", "{/* {flag} */}"],
["view.tsx", "<br />", "{/* <br /> */}"],
["view.tsx", 'className="x"', '// className="x"'],
["view.tsx", "onClick={() => go()}", "// onClick={() => go()}"],
["view.tsx", 'a="1"', '// a="1"'],
["view.tsx", "const item = 1;", "// const item = 1;"],
])("toggles %s line %s as %s", (filename, line, commented) => {
const language = getLanguageForFile(filename);
if (!language) throw new Error(`Missing language for ${filename}`);
const target = editorTarget(
EditorState.create({
doc: jsxView,
extensions: [language],
selection: { anchor: jsxView.indexOf(line) },
}),
);
expect(toggleComment(target)).toBe(true);
expect(target.state.doc.toString()).toBe(jsxView.replace(line, commented));
expect(toggleComment(target)).toBe(true);
expect(target.state.doc.toString()).toBe(jsxView);
});
function editorTarget(initial: EditorState) {
let state = initial;
return {
get state() {
return state;
},
dispatch(transaction: Transaction) {
state = transaction.state;
},
};
}
test("keeps JSON comment-free", () => {
const language = getLanguageForFile("data.json");
if (!language) throw new Error("Missing JSON language");
const state = EditorState.create({ doc: "{}", extensions: [language] });
expect(
toggleComment({
state,
dispatch() {
throw new Error("JSON must not be edited");
},
}),
).toBe(false);
});
@@ -94,14 +94,30 @@ describe("hover safe-zone tracker", () => {
expect(handle.leaves).toBe(1);
});
it("bridges the gap to content above or below the trigger", () => {
const handle = createHandle({
trigger: { left: 100, right: 130, top: 60, bottom: 90 },
content: { left: 0, right: 300, top: 0, bottom: 52 },
});
// In the gap between the card and the trigger below it.
handle.pointerMoved(115, 56);
expect(handle.enters).toBe(1);
expect(handle.leaves).toBe(0);
// Beside the trigger, past the gap — outside.
handle.pointerMoved(200, 75);
expect(handle.leaves).toBe(1);
});
it("treats overlapping trigger and content as having no bridge", () => {
const handle = createHandle({
trigger: { left: 0, right: 200, top: 0, bottom: 50 },
content: { left: 100, right: 300, top: 60, bottom: 100 },
content: { left: 100, right: 300, top: 40, bottom: 100 },
});
// Outside both rects, in what would be a bridge — should be outside.
handle.pointerMoved(150, 55);
// Outside both rects, in the corner neither of them covers — should be outside.
handle.pointerMoved(250, 20);
expect(handle.enters).toBe(0);
expect(handle.leaves).toBe(1);
});
@@ -62,9 +62,20 @@ function isInsideSafeZone(
if (isInsideRect(trigger, x, y)) return true;
if (isInsideRect(content, x, y)) return true;
if (!trigger || !content) return false;
return (
isInsideHorizontalBridge(trigger, content, x, y) ||
isInsideVerticalBridge(trigger, content, x, y)
);
}
// Bridge: the horizontal strip connecting trigger and content, stretched
// vertically to span both. If they overlap horizontally there's no bridge.
// A card beside its trigger: the strip between them, stretched vertically to
// span both. If they overlap horizontally there's no horizontal bridge.
function isInsideHorizontalBridge(
trigger: RectLike,
content: RectLike,
x: number,
y: number,
): boolean {
const bridgeLeft = Math.min(trigger.right, content.right);
const bridgeRight = Math.max(trigger.left, content.left);
if (bridgeLeft >= bridgeRight) return false;
@@ -72,3 +83,19 @@ function isInsideSafeZone(
const bridgeBottom = Math.max(trigger.bottom, content.bottom);
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
}
// A card above or below its trigger: the strip between them, stretched
// horizontally to span both. If they overlap vertically there's no vertical bridge.
function isInsideVerticalBridge(
trigger: RectLike,
content: RectLike,
x: number,
y: number,
): boolean {
const bridgeTop = Math.min(trigger.bottom, content.bottom);
const bridgeBottom = Math.max(trigger.top, content.top);
if (bridgeTop >= bridgeBottom) return false;
const bridgeLeft = Math.min(trigger.left, content.left);
const bridgeRight = Math.max(trigger.right, content.right);
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
}
@@ -150,7 +150,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
contentMaxWidth: null,
syntaxTheme: "one",
workspaceTitleSource: "title",
sidebarWorkspaceTrailing: "diff",
sidebarWorkspaceTrailing: "timestamp",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarNavItems: [],
@@ -242,7 +242,7 @@ const StoredAppSettingsSchema = z
.catch(null),
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"),
sidebarRowItems: SidebarRowItemsSchema,
sidebarChecksDisplay: z
.enum(["iconAndText", "icon", "none"])
+2
View File
@@ -1931,6 +1931,8 @@ export const ar: TranslationResources = {
dismiss: "رفض",
},
contextWindow: {
noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
title: "نافذة السياق",
used: "تم استخدام{{percentage}}%",
tokens: "رموز{{used}}/{{max}}",
+2
View File
@@ -1956,6 +1956,8 @@ export const en = {
dismiss: "Dismiss",
},
contextWindow: {
noData: "No context data",
accessibilityNoData: "Context window: No context data",
title: "Context window",
used: "{{percentage}}% used",
tokens: "{{used}} / {{max}} tokens",
+2
View File
@@ -1979,6 +1979,8 @@ export const es: TranslationResources = {
dismiss: "Despedir",
},
contextWindow: {
noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
title: "ventana contextual",
used: "{{percentage}}% utilizado",
tokens: "Fichas{{used}}/{{max}}",
+2
View File
@@ -1984,6 +1984,8 @@ export const fr: TranslationResources = {
dismiss: "Rejeter",
},
contextWindow: {
noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
title: "Fenêtre contextuelle",
used: "{{percentage}}% utilisé",
tokens: "Jetons{{used}}/{{max}}",
+2
View File
@@ -1951,6 +1951,8 @@ export const ja: TranslationResources = {
dismiss: "閉じる",
},
contextWindow: {
noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
title: "コンテキストウィンドウ",
used: "{{percentage}}%使用",
tokens: "{{used}} / {{max}}トークン",
+2
View File
@@ -1941,6 +1941,8 @@ export const ko: TranslationResources = {
dismiss: "닫기",
},
contextWindow: {
noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
title: "컨텍스트 윈도우",
used: "{{percentage}}% 사용됨",
tokens: "{{used}} / {{max}} 토큰",
+2
View File
@@ -1964,6 +1964,8 @@ export const ptBR: TranslationResources = {
dismiss: "Dispensar",
},
contextWindow: {
noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto",
title: "Janela de contexto",
used: "{{percentage}}% usado",
tokens: "{{used}} / {{max}} tokens",
+2
View File
@@ -1964,6 +1964,8 @@ export const ru: TranslationResources = {
dismiss: "Закрыть",
},
contextWindow: {
noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте",
title: "Контекстное окно",
used: "Использовано: {{percentage}}%",
tokens: "Токены: {{used}} / {{max}}",
+2
View File
@@ -1909,6 +1909,8 @@ export const zhCN: TranslationResources = {
dismiss: "关闭",
},
contextWindow: {
noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据",
title: "上下文窗口",
used: "已使用 {{percentage}}%",
tokens: "{{used}} / {{max}} tokens",
+21 -8
View File
@@ -88,6 +88,7 @@ interface WebOverlayEntry {
getScope: () => HTMLElement | null;
getKeyHandler: () => WebOverlayKeyHandler;
restoreFocus: HTMLElement | null;
manageFocus: boolean;
}
const webOverlayEntries: WebOverlayEntry[] = [];
@@ -140,7 +141,7 @@ function focusFirstElement(scope: HTMLElement): void {
function handleWebOverlayFocus(event: FocusEvent): void {
const top = getTopWebOverlay();
const scope = top?.getScope();
if (!scope || scope.contains(event.target as Node)) return;
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
if (webOverlayFocusCheckQueued) return;
// React can autofocus a child before its parent scope ref attaches. Defer
@@ -149,8 +150,10 @@ function handleWebOverlayFocus(event: FocusEvent): void {
webOverlayFocusCheckQueued = true;
queueMicrotask(() => {
webOverlayFocusCheckQueued = false;
const currentScope = getTopWebOverlay()?.getScope();
if (!currentScope || currentScope.contains(document.activeElement)) return;
const currentTop = getTopWebOverlay();
const currentScope = currentTop?.getScope();
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
return;
focusFirstElement(currentScope);
});
}
@@ -169,7 +172,7 @@ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean {
const scope = top?.getScope();
if (!top || !scope) return false;
if (event.key === "Tab") {
if (top.manageFocus && event.key === "Tab") {
const focusable = getFocusableElements(scope);
const first = focusable[0];
const last = focusable[focusable.length - 1];
@@ -214,7 +217,12 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
const focusFrame = window.requestAnimationFrame(() => {
const scope = entry.getScope();
if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) {
if (
entry.manageFocus &&
getTopWebOverlay() === entry &&
scope &&
!scope.contains(document.activeElement)
) {
focusFirstElement(scope);
}
});
@@ -225,6 +233,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
if (index !== -1) webOverlayEntries.splice(index, 1);
detachWebOverlayListeners();
if (
entry.manageFocus &&
options?.restoreFocus !== false &&
entry.restoreFocus &&
document.contains(entry.restoreFocus)
@@ -239,17 +248,20 @@ interface WebOverlayRegistration {
layer: number;
onKeyDown: WebOverlayKeyHandler;
restoreFocusRef?: React.RefObject<unknown>;
/** Hover surfaces claim overlay keys while preserving the user's existing focus. */
manageFocus?: boolean;
}
/**
* Registers a focus scope in the same relative layer model used for painting.
* Only the highest painted overlay receives keyboard input; focus is trapped
* there and restored to the opener when that overlay closes.
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
*/
export function useWebOverlayRegistration({
active,
layer,
onKeyDown,
manageFocus = true,
restoreFocusRef: preferredRestoreFocusRef,
}: WebOverlayRegistration) {
const idRef = useRef(Symbol("web-overlay"));
@@ -305,10 +317,11 @@ export function useWebOverlayRegistration({
getScope: () => scopeRef.current,
getKeyHandler: () => keyHandlerRef.current,
restoreFocus: capturedRestoreFocusRef.current,
manageFocus,
};
removeEntryRef.current = addWebOverlay(entry);
registeredRef.current = true;
}, []);
}, [manageFocus]);
const setScope = useCallback(
(node: unknown) => {
-1
View File
@@ -595,7 +595,6 @@ export const ICON_SIZE = {
sm: 14,
md: 16,
lg: 20,
xl: 24,
} as const;
export const ICON_STROKE_WIDTH = 1.5;
+6 -2
View File
@@ -3,6 +3,7 @@ import { StyleSheet } from "react-native-unistyles";
import { HostFilter } from "@/components/hosts/host-filter";
import { useMemo, type ReactElement } from "react";
import { UsageRefreshButton } from "./refresh-button";
import { UsageOptions } from "./options";
import type { UsageHost } from "./model";
import { useHostUsage } from "./queries";
import type { UsageView } from "./types";
@@ -16,7 +17,7 @@ export interface UsageHostSelection {
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, and Refresh all. A host that cannot report usage keeps only the host filter.
* host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter.
*/
export function UsageControls({
view,
@@ -41,7 +42,10 @@ export function UsageControls({
/>
) : null}
{view.kind === "unavailable" ? null : (
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<>
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
</>
)}
</View>
);
+6 -1
View File
@@ -2,4 +2,9 @@ export { AgentUsage } from "./agent-usage";
export { HostUsageSection } from "./host-usage-section";
export { useHostReportsUsage } from "./queries";
export { UsageScreen } from "./usage-screen";
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
export {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
+47 -27
View File
@@ -1,14 +1,15 @@
import { View } from "react-native";
import {
SettingsCard,
SettingsCollapsibleRow,
SettingsRow,
SettingsSwitch,
} from "@/components/settings";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { settingsStyles } from "@/styles/settings";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
@@ -17,16 +18,33 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
/**
* The usage options, folded above the reports: whether the sidebar shows the summary (the same
* switch as Settings > Sidebar) and whether percents read as used or remaining.
*/
export function UsageOptions({ display }: { display: UsageDisplay }) {
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<View style={settingsStyles.section}>
<SettingsCard>
<SettingsCollapsibleRow label={usageCopy.options} testID="usage-options">
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
<ThemedSettings
size={paneContentToolbarIconSize(compact)}
uniProps={extraMutedIconColorMapping}
/>
</ToolbarButton>
<DropdownMenuContent
align="end"
width={340}
sheetTitle={usageCopy.options}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
@@ -34,17 +52,19 @@ export function UsageOptions({ display }: { display: UsageDisplay }) {
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
testID="usage-display-as"
/>
</SettingsRow>
</SettingsCollapsibleRow>
</SettingsCard>
</View>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
testID="usage-display-as"
/>
</SettingsRow>
</View>
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
+42 -41
View File
@@ -1,5 +1,13 @@
import { router } from "expo-router";
import { Fragment, useCallback, useMemo, useState } from "react";
import {
createContext,
Fragment,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
@@ -12,7 +20,6 @@ import { StyleSheet } from "react-native-unistyles";
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";
@@ -29,7 +36,6 @@ import {
type PinnedUsageSource,
} from "./pinned";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody } from "./usage-section";
/** Each summary window with data on the usage host, under its source; empty while none has. */
@@ -50,55 +56,51 @@ export function useHasUsageSummary(): boolean {
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
*/
export function UsageSidebarItem() {
const { display } = useUsagePreferences();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <UsageEntry sources={sources} display={display} />;
}
/** Opens the Usage screen, over the sidebar on compact layouts. */
export function useOpenUsageScreen(): () => void {
const isCompact = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
return useCallback(() => {
if (isCompact) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompact, showMobileAgent]);
}
function UsageEntry({
sources,
display,
}: {
sources: readonly PinnedUsageSource[];
display: UsageDisplay;
}) {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { display } = useUsagePreferences();
const isCompact = useIsCompactFormFactor();
const openUsageScreen = useOpenUsageScreen();
const [open, setOpen] = useState(false);
// The sheet mounts on first open; the summary already owns the report query.
const [sheetMounted, setSheetMounted] = useState(false);
const handlePress = useCallback(() => {
if (!isCompact) {
openUsageScreen();
const openUsage = useCallback(() => {
if (isCompact) {
setSheetMounted(true);
setOpen(true);
return;
}
setSheetMounted(true);
setOpen(true);
}, [isCompact, openUsageScreen]);
router.push(buildUsageRoute());
}, [isCompact]);
const trigger = <PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />;
if (!isCompact) return trigger;
return (
<SidebarPopoverRoot open={open} onOpenChange={setOpen}>
{trigger}
{sheetMounted ? <UsageSheet title={label} display={display} /> : null}
</SidebarPopoverRoot>
<OpenSidebarUsageContext.Provider value={openUsage}>
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
{children}
{isCompact && sheetMounted ? (
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
) : null}
</SidebarPopoverRoot>
</OpenSidebarUsageContext.Provider>
);
}
/** Both sidebar entry points send the same open command. */
export function useOpenSidebarUsage(): () => void {
const openUsage = useContext(OpenSidebarUsageContext);
if (!openUsage) throw new Error("Sidebar Usage must be inside UsageSidebarRoot.");
return openUsage;
}
/**
* The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls
* in its title row.
@@ -144,7 +146,6 @@ function HostUsageSheet({
testID="sidebar-usage-sheet"
>
<View style={styles.sheetBody} testID="usage-expanded">
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</SidebarPopoverSurface>
-2
View File
@@ -8,7 +8,6 @@ import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody, UsageMessage } from "./usage-section";
// The screen is reachable by URL, so there may be no history to go back to.
@@ -66,7 +65,6 @@ function HostUsage({
return (
<UsagePage actions={controls}>
<View testID={`usage-host-${serverId}`}>
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</UsagePage>
+1 -1
View File
@@ -218,7 +218,7 @@ export function createPluginCommand(): Command {
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.argument(
"<source>",
"Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
+38 -90
View File
@@ -1,11 +1,4 @@
import {
defineLanguageFacet,
Language,
languageDataProp,
StreamLanguage,
sublanguageProp,
type Sublanguage,
} from "@codemirror/language";
import { defineLanguageFacet, Language, StreamLanguage } from "@codemirror/language";
import { dart } from "@codemirror/legacy-modes/mode/clike";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { parser as jsParser } from "@lezer/javascript";
@@ -16,113 +9,68 @@ import { parser as goParser } from "@lezer/go";
import { parser as htmlParser } from "@lezer/html";
import { parser as javaParser } from "@lezer/java";
import { parser as pythonParser } from "@lezer/python";
import { parser as markdownParser, type MarkdownParser } from "@lezer/markdown";
import { parser as markdownParser } from "@lezer/markdown";
import { parser as phpParser } from "@lezer/php";
import { parser as rustParser } from "@lezer/rust";
import { parser as xmlParser } from "@lezer/xml";
import { parser as yamlParser } from "@lezer/yaml";
import { parser as elixirParser } from "lezer-elixir";
import type { Parser, SyntaxNode } from "@lezer/common";
import type { LRParser } from "@lezer/lr";
import type { Parser } from "@lezer/common";
import { csharpLanguage } from "./csharp/language.js";
import { astroParser } from "./astro/parser.js";
import { nixLanguage } from "./nix/language.js";
import { parser as svelteBaseParser } from "./svelte/parser.js";
import { configureNesting, defaultNesting } from "./svelte/nesting.js";
const cStyleComments = { commentTokens: { line: "//", block: { open: "/*", close: "*/" } } };
const hashComments = { commentTokens: { line: "#" } };
const cssComments = { commentTokens: { block: { open: "/*", close: "*/" } } };
const markupComments = { commentTokens: { block: { open: "<!--", close: "-->" } } };
const jsxChildComments = { commentTokens: { block: { open: "{/*", close: "*/}" } } };
// JSX children are text, so a `//` there renders on the page instead of commenting the line out.
// Attribute lines of a multi-line tag are not children and keep `//`.
const jsxComments: Sublanguage = {
test: isJsxChild,
facet: defineLanguageFacet(jsxChildComments),
};
// A line starts on a child as text, as the `{` of an escape, or as the `<` of a nested element.
function isJsxChild(node: SyntaxNode): boolean {
return childAt(node)?.parent?.name === "JSXElement";
}
function childAt(node: SyntaxNode): SyntaxNode | null {
if (node.name === "JSXText") return node;
if (node.name === "{") return node.parent;
if (node.name === "JSXStartTag") return node.parent?.parent ?? null;
return null;
}
function language(
parser: LRParser | MarkdownParser,
languageData: Parameters<typeof defineLanguageFacet>[0] = {},
sublanguages: Sublanguage[] = [],
): Language {
const data = defineLanguageFacet(languageData);
return new Language(
data,
parser.configure({
props: [
languageDataProp.add((node) => (node.isTop ? data : undefined)),
sublanguageProp.add((node) =>
node.isTop && sublanguages.length ? sublanguages : undefined,
),
],
}),
);
function language(parser: Parser): Language {
return new Language(defineLanguageFacet(), parser);
}
const languagesByExtension: Record<string, Language> = {
// JavaScript/TypeScript
js: language(jsParser, cStyleComments),
jsx: language(jsParser.configure({ dialect: "jsx" }), cStyleComments, [jsxComments]),
ts: language(jsParser.configure({ dialect: "ts" }), cStyleComments),
tsx: language(jsParser.configure({ dialect: "ts jsx" }), cStyleComments, [jsxComments]),
mjs: language(jsParser, cStyleComments),
cjs: language(jsParser, cStyleComments),
js: language(jsParser),
jsx: language(jsParser.configure({ dialect: "jsx" })),
ts: language(jsParser.configure({ dialect: "ts" })),
tsx: language(jsParser.configure({ dialect: "ts jsx" })),
mjs: language(jsParser),
cjs: language(jsParser),
// C / C++ / Objective-C
c: language(cppParser, cStyleComments),
h: language(cppParser, cStyleComments),
cc: language(cppParser, cStyleComments),
cpp: language(cppParser, cStyleComments),
cxx: language(cppParser, cStyleComments),
hpp: language(cppParser, cStyleComments),
hxx: language(cppParser, cStyleComments),
m: language(cppParser, cStyleComments),
mm: language(cppParser, cStyleComments),
c: language(cppParser),
h: language(cppParser),
cc: language(cppParser),
cpp: language(cppParser),
cxx: language(cppParser),
hpp: language(cppParser),
hxx: language(cppParser),
m: language(cppParser),
mm: language(cppParser),
// JSON
json: language(jsonParser),
// CSS
css: language(cssParser, cssComments),
scss: language(cssParser, cStyleComments),
css: language(cssParser),
scss: language(cssParser),
// HTML
html: language(htmlParser, markupComments),
htm: language(htmlParser, markupComments),
html: language(htmlParser),
htm: language(htmlParser),
// Svelte
svelte: language(
svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) }),
markupComments,
),
svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })),
// Astro
astro: new Language(defineLanguageFacet(), astroParser),
astro: language(astroParser),
// XML
xml: language(xmlParser, markupComments),
xml: language(xmlParser),
// Java
java: language(javaParser, cStyleComments),
java: language(javaParser),
// Python
py: language(pythonParser, hashComments),
py: language(pythonParser),
// Go
go: language(goParser, cStyleComments),
go: language(goParser),
// PHP
php: language(phpParser, cStyleComments),
php: language(phpParser),
// YAML
yaml: language(yamlParser, hashComments),
yml: language(yamlParser, hashComments),
yaml: language(yamlParser),
yml: language(yamlParser),
// Rust
rs: language(rustParser, cStyleComments),
rs: language(rustParser),
// Swift
swift: StreamLanguage.define(swift),
// Dart
@@ -132,11 +80,11 @@ const languagesByExtension: Record<string, Language> = {
// Nix
nix: nixLanguage,
// Elixir
ex: language(elixirParser, hashComments),
exs: language(elixirParser, hashComments),
ex: language(elixirParser),
exs: language(elixirParser),
// Markdown
md: language(markdownParser, markupComments),
mdx: language(markdownParser, jsxChildComments),
md: language(markdownParser),
mdx: language(markdownParser),
};
export function getLanguageForFile(filename: string): Language | null {
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
describe("agent timeline content", () => {
test("limits terminal input to the tool-call content budget", () => {
test("marks truncated plain-text tool content within the content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
const item = limitAgentTimelineItemContent({
@@ -19,6 +19,7 @@ describe("agent timeline content", () => {
},
});
expect(limitAgentTimelineItemContent(item)).toEqual(item);
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
@@ -27,7 +28,7 @@ describe("agent timeline content", () => {
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024),
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
icon: "square_terminal",
},
});
@@ -42,11 +42,12 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
) {
return item;
}
const notice = "\n\n[Content truncated]";
return {
...item,
detail: {
...item.detail,
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
},
};
}
@@ -2455,10 +2455,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
this.cancelPendingPermissions();
if (this.activeForegroundTurnId) {
await this.connection.cancel({ sessionId: this.sessionId });
@@ -2474,10 +2471,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
this.cancelPendingPermissions();
if (this.connection && this.sessionId) {
try {
@@ -3298,6 +3292,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3306,6 +3305,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
if (!this.bootstrapThreadEventPending || !this.sessionId) {
return;
@@ -314,6 +314,127 @@ describe("background Claude subagents", () => {
});
});
test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => {
// Claude Code answers a background Agent call at once ("Agent launched") and only then streams
// the child's frames, so they arrive after the parent's tool call has its result.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
},
],
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const rows: AgentTimelineRow[] = events
.filter((event) => event.type === "timeline")
.map((event, index) => ({
seq: index + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(),
item: event.item,
}));
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
},
});
});
test.each([
{
source: "system task protocol",
@@ -3069,6 +3069,44 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("reports the plan mode Claude enters on its own with EnterPlanMode", async () => {
// Claude Code announces a mode it switched to itself as a status message
// carrying the new permissionMode, right after the EnterPlanMode tool call.
const client = new ClaudeAgentClient({
logger,
queryFactory: createQueryFactoryForTurns([
[
{ ...createInitMessage(), permissionMode: "acceptEdits" },
{
type: "system",
subtype: "status",
status: null,
permissionMode: "plan",
session_id: "session-1",
},
createSuccessResult(),
],
]),
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "acceptEdits",
});
try {
const events = await collectStreamEvents(session, "enter plan mode");
expect(events).toContainEqual(
expect.objectContaining({ type: "mode_changed", currentModeId: "plan" }),
);
expect(await session.getCurrentMode()).toBe("plan");
} finally {
await session.close();
}
});
test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => {
// The first turn starts compacting and then ends without ever reaching a
// compact_boundary, so the marker it opened is never resolved.
@@ -2092,7 +2092,11 @@ class ClaudeAgentSession implements AgentSession {
// identity and status from frames for them. Detecting the capability beats comparing version
// strings: it reacts to what this session actually does.
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// The parent's tool call owns its card once its result arrives. A background child streams
// its frames after Claude has already answered the call, and re-emitting the card from them
// would replace the settled card with an unlabeled, running one.
needsSyntheticParentToolCard: (toolUseId) =>
this.toolUseCache.has(toolUseId) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
});
private persistedHistory: PersistedTimelineEntry[] = [];
@@ -4317,6 +4321,19 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (message.subtype === "status") {
// Claude Code reports a mode it switched to on its own, such as entering
// plan mode with the EnterPlanMode tool, through the status message.
if (
isPermissionMode(message.permissionMode) &&
this.observePermissionMode(message.permissionMode)
) {
events.push({
type: "mode_changed",
provider: "claude",
currentModeId: this.currentMode,
availableModes: this.availableModes,
});
}
const status = toObjectRecord(message)?.status;
if (status === "compacting") {
this.compacting = true;
@@ -4628,10 +4645,7 @@ class ClaudeAgentSession implements AgentSession {
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
}
this.availableModes = DEFAULT_MODES;
this.currentMode = message.permissionMode;
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
this.observePermissionMode(message.permissionMode);
this.persistence = null;
if (message.model) {
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
@@ -4650,6 +4664,16 @@ class ClaudeAgentSession implements AgentSession {
return { threadStartedSessionId, notice };
}
/** Records a mode Claude Code reports, returning whether it differs from the current one. */
private observePermissionMode(mode: PermissionMode): boolean {
const changed = this.currentMode !== mode;
this.currentMode = mode;
if (mode !== "plan") {
this.planResumeMode = mode;
}
return changed;
}
private readMissingResumedConversationError(message: SDKMessage): string | null {
if (message.type !== "result" || message.subtype !== "error_during_execution") {
return null;
@@ -0,0 +1,26 @@
import type { AgentTimelineItem } from "../agent-sdk-types.js";
/** Preserve extension context as an expandable tool row instead of assistant speech. */
export function mapCustomMessageToToolCall(
message: { role: "custom"; customType?: unknown; details?: unknown },
text: string,
callId: string,
): Extract<AgentTimelineItem, { type: "tool_call" }> {
const customType =
typeof message.customType === "string" && message.customType.trim()
? message.customType
: "custom-message";
return {
type: "tool_call",
callId,
name: customType,
status: "completed",
detail: { type: "plain_text", text },
metadata: {
synthetic: true,
customType,
...(message.details === undefined ? {} : { details: message.details }),
},
error: null,
};
}
@@ -0,0 +1,165 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient permission requests when a turn ends early", () => {
test.each([
{ ending: "fail", terminalEvent: "turn_failed" },
{ ending: "cancel", terminalEvent: "turn_canceled" },
] as const)(
"answers a permission request left open by a $terminalEvent turn with cancelled",
async ({ ending, terminalEvent }) => {
await withFakeACPAgent(ending, async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForEvent(events, terminalEvent);
expect(hasEvent(events, "permission_requested")).toBe(true);
expect(session.getPendingPermissions()).toEqual([]);
const replyStart = events.length;
await session.startTurn("turn two");
await waitForEvent(events, "turn_completed", replyStart);
expect(assistantText(events.slice(replyStart))).toBe(
'perm-1 answered: {"outcome":{"outcome":"cancelled"}}',
);
} finally {
await session.close();
}
});
},
);
});
function collectEvents(session: AgentSession): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
return events;
}
function hasEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
fromIndex = 0,
): boolean {
return events.slice(fromIndex).some((event) => event.type === type);
}
async function waitForEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
fromIndex = 0,
): Promise<void> {
const deadline = Date.now() + 5_000;
while (!hasEvent(events, type, fromIndex)) {
if (Date.now() > deadline) {
throw new Error(`Timed out waiting for ${type}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function assistantText(events: AgentStreamEvent[]): string {
return events
.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
)
.join("");
}
async function withFakeACPAgent(
ending: "fail" | "cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run([process.execPath, scriptPath, ending], testDir);
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
const ending = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
let promptCount = 0;
let permissionAnswer = null;
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
write({ id: message.id, result: { protocolVersion: 1, agentCapabilities: {} } });
return;
}
if (message.method === "session/new") {
write({ id: message.id, result: { sessionId: "session-1" } });
return;
}
if (message.method === "session/prompt") {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: "perm-1",
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
setTimeout(() => {
if (ending === "cancel") {
write({ id: message.id, result: { stopReason: "cancelled" } });
} else {
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
}
}, 50);
return;
}
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
return;
}
if (message.id !== undefined) {
write({ id: message.id, result: {} });
}
});
`;
@@ -737,6 +737,8 @@ export class MockLoadTestAgentSession implements AgentSession {
readonly capabilities = CAPABILITIES;
readonly features: AgentFeature[] = [];
readonly id: string;
private readonly usageSessionKey = randomUUID();
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history: AgentStreamEvent[] = [];
private readonly logger?: Logger;
@@ -792,6 +794,15 @@ export class MockLoadTestAgentSession implements AgentSession {
);
}
usageSession() {
return {
provider: this.provider,
model: this.modelId ?? undefined,
env: {},
sessionKey: this.usageSessionKey,
};
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const { turnId } = await this.startTurn(prompt, options);
const turn = this.activeTurn;
@@ -662,6 +662,46 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("keeps custom context in separate tools while a turn continues", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("Explain the project");
omp.runtime().beginTurn();
for (const display of [true, false, true]) {
omp.emit({
type: "message_end",
message: {
role: "custom",
customType: "project-context",
content: [{ type: "text", text: "Project instructions" }],
details: { project: "example" },
display,
},
});
}
const items = omp.timeline();
expect(items).toEqual(
[1, 2].map(() => ({
type: "tool_call",
callId: expect.stringMatching(/^omp-custom-/),
name: "project-context",
status: "completed",
detail: { type: "plain_text", text: "Project instructions" },
metadata: {
synthetic: true,
customType: "project-context",
details: { project: "example" },
},
error: null,
})),
);
expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2);
expect(omp.completedTurnCount()).toBe(0);
omp.runtime().finishTurn();
await waitForImmediate();
expect(omp.completedTurnCount()).toBe(1);
});
test("omits live custom messages when display is false", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -705,11 +745,19 @@ describe("OMP agent client and session", () => {
message: "Background job DocsSmokeTwo completed",
},
]);
// Non-notice custom messages still fall through as assistant messages with
// their own id so the stream coalescer never glues them onto the open reply.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([
expect(
omp.timeline().filter((item) => item.type !== "notification" && item.type !== "user_message"),
).toEqual([
{ type: "assistant_message", text: "done", messageId: "omp-assistant-1" },
{ type: "assistant_message", text: "plain custom status text", messageId: "omp-custom-1" },
{
type: "tool_call",
callId: expect.stringMatching(/^omp-custom-/),
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "plain custom status text" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
]);
});
@@ -1,3 +1,4 @@
import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
@@ -69,11 +70,7 @@ import {
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import {
ompCustomMessageId,
ompSkillPromptUserText,
shouldDisplayOmpCustomMessage,
} from "./custom-message.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
@@ -720,7 +717,6 @@ export class OmpAgentSession implements AgentSession {
private closed = false;
private live: boolean;
private readonly emittedUserMessageIds = new Set<string>();
private customMessageIndex = 0;
private readonly usageSessionKey = randomUUID();
@@ -2000,14 +1996,8 @@ export class OmpAgentSession implements AgentSession {
type: "timeline",
provider: this.provider,
turnId,
item: item ?? {
type: "assistant_message",
text,
messageId: ompCustomMessageId(event.message, () => {
this.customMessageIndex += 1;
return this.customMessageIndex;
}),
},
item:
item ?? mapCustomMessageToToolCall(event.message, text, `omp-custom-${randomUUID()}`),
});
}
}
@@ -27,6 +27,40 @@ async function collectHistory(
}
describe("OMP history mapper", () => {
test("renders visible custom messages as completed tools with their type and content", async () => {
const events = await collectHistory([
{
role: "custom",
customType: "project-context",
content: "Project instructions",
display: true,
},
{
role: "custom",
customType: "private-context",
content: "Hidden instructions",
display: false,
},
{
role: "custom",
customType: "project-context",
content: "Project instructions",
display: true,
},
]);
expect(events.map((event) => event.item)).toEqual(
[1, 2].map((index) => ({
type: "tool_call",
callId: `omp-custom-${index}`,
name: "project-context",
status: "completed",
detail: { type: "plain_text", text: "Project instructions" },
metadata: { synthetic: true, customType: "project-context" },
error: null,
})),
);
});
test("replays a web search details error as failed when OMP sets isError false", async () => {
const events = await collectHistory([
{
@@ -204,9 +238,16 @@ describe("OMP history mapper", () => {
events.push(event);
expect(events.map((event) => event.item)).toEqual([
{
type: "assistant_message",
text: "[developer] External instruction",
messageId: "omp-custom-1",
type: "tool_call",
callId: "omp-custom-1",
name: "custom-message",
status: "completed",
detail: {
type: "plain_text",
text: "[developer] External instruction",
},
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
]);
});
@@ -408,18 +449,26 @@ describe("OMP history mapper", () => {
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: "visible explicit custom",
messageId: "omp-custom-1",
type: "tool_call",
callId: "omp-custom-1",
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "visible explicit custom" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: "visible legacy custom",
messageId: "omp-custom-2",
type: "tool_call",
callId: "omp-custom-2",
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "visible legacy custom" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
},
{
@@ -651,11 +700,26 @@ describe("OMP history mapper", () => {
expect(events.map((event) => event.item)).toEqual([
{ type: "user_message", text: "active branch", messageId: "user-active" },
{
type: "assistant_message",
text: "[future_control] Unsupported history record",
messageId: "omp-custom-1",
type: "tool_call",
callId: "omp-custom-1",
name: "custom-message",
status: "completed",
detail: {
type: "plain_text",
text: "[future_control] Unsupported history record",
},
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
{
type: "tool_call",
callId: "omp-custom-2",
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "[developer] developer note" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
{ type: "assistant_message", text: "[developer] developer note", messageId: "omp-custom-2" },
]);
const omp = new FakeOmp();
@@ -758,11 +822,30 @@ describe("OMP history mapper", () => {
expect(events.map((event) => event.item)).toEqual([
{ type: "assistant_message", text: "Done.", messageId: "resp-1" },
{ type: "user_message", text: "/skill:commit", messageId: "omp-custom-skill-1-user" },
{ type: "assistant_message", text: ircMessage, messageId: "omp-custom-irc-1" },
{
type: "assistant_message",
text: "visible without display flag",
messageId: "omp-custom-legacy-1",
type: "tool_call",
callId: "omp-custom-irc-1",
name: "irc:incoming",
status: "completed",
detail: { type: "plain_text", text: ircMessage },
metadata: {
synthetic: true,
customType: "irc:incoming",
details: { from: "worker-1", message: "ready for review" },
},
error: null,
},
{
type: "tool_call",
callId: "omp-custom-legacy-1",
name: "legacy-no-display",
status: "completed",
detail: {
type: "plain_text",
text: "visible without display flag",
},
metadata: { synthetic: true, customType: "legacy-no-display" },
error: null,
},
]);
});
@@ -821,18 +904,44 @@ describe("OMP history mapper", () => {
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: '[IMPORTANT: Agent invoked the "improve" skill.]',
messageId: "omp-custom-skill-agent",
type: "tool_call",
callId: "omp-custom-skill-agent",
name: "skill-prompt",
status: "completed",
detail: {
type: "plain_text",
text: '[IMPORTANT: Agent invoked the "improve" skill.]',
},
metadata: {
synthetic: true,
customType: "skill-prompt",
details: {
name: "improve",
path: "/home/me/.agents/skills/improve/SKILL.md",
lineCount: 9,
},
},
error: null,
},
},
{
type: "timeline",
provider: "omp",
item: {
type: "assistant_message",
text: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
messageId: "omp-custom-irc-user",
type: "tool_call",
callId: "omp-custom-irc-user",
name: "irc:incoming",
status: "completed",
detail: {
type: "plain_text",
text: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>",
},
metadata: {
synthetic: true,
customType: "irc:incoming",
details: { from: "worker-1", message: "hi" },
},
error: null,
},
},
]);
@@ -1,3 +1,4 @@
import { mapCustomMessageToToolCall } from "../custom-message.js";
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js";
import type { OmpBridgedToolIdentity } from "./mcp-bridge.js";
@@ -155,7 +156,7 @@ export class OmpHistoryMapper {
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text, messageId },
item: mapCustomMessageToToolCall(message, text, messageId),
},
];
}
@@ -15,29 +15,63 @@ import { OpenCodeServerManager } from "./opencode/server-manager.js";
test("dispatches ascending OpenCode message identifiers through the public provider path", async () => {
const upstream = await createRecoveryUpstream();
const fixture = await createPublicRecoverySession(upstream, new RecoveryTiming());
const observed: AgentStreamEvent[] = [];
fixture.session.subscribe((event) => observed.push(event));
await upstream.connected(1);
upstream.send(0, connectedRecord());
await fixture.session.startTurn("first");
await upstream.dispatched(1);
upstream.send(0, idleRecord());
await eventually(() =>
expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1),
const client = new OpenCodeAgentClient(
createTestLogger(),
{
command: {
mode: "replace",
argv: [
process.execPath,
"-e",
`setInterval(() => {}, 1000); fetch(${JSON.stringify(
`${upstream.url}/test/listening`,
)}).then(() => console.log("listening on test server"));`,
"--",
],
},
},
{
resolveHomeDir: () => process.cwd(),
createClient: ({ directory }) => createOpencodeClient({ baseUrl: upstream.url, directory }),
},
);
await fixture.session.startTurn("second");
const ids = await upstream.dispatched(2);
try {
const creating = client.createSession({
provider: "opencode",
cwd: "/workspace",
});
void creating.catch(() => undefined);
await upstream.awaitingListening();
// The process is alive but has not reported listening, so even a reachable endpoint must stay untouched.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(upstream.connectionCount()).toBe(0);
upstream.reportListening();
const session = await creating;
const observed: AgentStreamEvent[] = [];
session.subscribe((event) => observed.push(event));
await upstream.connected(1);
upstream.send(0, connectedRecord());
expect(ids).toHaveLength(2);
expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
expect((ids[0] as string) < (ids[1] as string)).toBe(true);
await session.startTurn("first");
await upstream.dispatched(1);
upstream.send(0, idleRecord());
await eventually(() =>
expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1),
);
await session.startTurn("second");
const ids = await upstream.dispatched(2);
await fixture.session.close();
await fixture.manager.shutdown();
await upstream.close();
expect(ids).toHaveLength(2);
expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
expect((ids[0] as string) < (ids[1] as string)).toBe(true);
await session.close();
} finally {
upstream.reportListening();
await client.shutdown();
await upstream.close();
}
});
test("recovers missed output after EOF without failing the active turn", async () => {
@@ -306,7 +340,10 @@ async function createPublicRecoverySession(
serverProcess?.exit();
return "terminated";
},
createEventSource: (options) => new OpenCodeEventConsumer({ ...options, timing }),
createEventSource: (options) => {
expect(options.listening).toBeInstanceOf(Promise);
return new OpenCodeEventConsumer({ ...options, timing });
},
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: manager,
@@ -338,6 +375,7 @@ class RecoveryServerProcess extends EventEmitter {
async function createRecoveryUpstream() {
const streams: ServerResponse[] = [];
let listeningResponse: ServerResponse | null = null;
const dispatchIds: string[] = [];
const mcpAddNames: string[] = [];
let messages: unknown[] = [];
@@ -345,6 +383,10 @@ async function createRecoveryUpstream() {
let status: "busy" | "idle" = "idle";
const server = createServer(async (request, response) => {
const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
if (pathname === "/test/listening") {
listeningResponse = response;
return;
}
if (request.url?.startsWith("/global/event")) {
response.writeHead(200, { "content-type": "text/event-stream" });
response.flushHeaders();
@@ -378,6 +420,10 @@ async function createRecoveryUpstream() {
return {
port: address.port,
url: `http://127.0.0.1:${address.port}`,
awaitingListening: async () =>
expect.poll(() => listeningResponse, { timeout: 3_000 }).not.toBeNull(),
reportListening: () => listeningResponse?.end(),
connectionCount: () => streams.length,
connected: async (count: number) => eventually(() => expect(streams).toHaveLength(count)),
send(index: number, value: unknown) {
streams[index]?.write(`data: ${JSON.stringify(value)}\n\n`);
@@ -1429,10 +1429,11 @@ export class OpenCodeAgentClient implements AgentClient {
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
resolveHomeDir: deps.resolveHomeDir,
createEventSource: ({ serverUrl, processExit, logger: eventLogger }) =>
createEventSource: ({ serverUrl, processExit, listening, logger: eventLogger }) =>
new OpenCodeEventConsumer({
serverUrl,
processExit,
listening,
logger: eventLogger,
createClient: (baseUrl) => this.createOpenCodeClient({ baseUrl, directory: "" }),
}),
@@ -49,9 +49,11 @@ describe("OpenCodeEventConsumer", () => {
const upstream = await createSseUpstream();
const timing = new ControlledTiming();
const processExit = deferred<Error>();
const listening = deferred<void>();
const consumer = new OpenCodeEventConsumer({
serverUrl: upstream.url,
processExit: processExit.promise,
listening: listening.promise,
logger: createRecordingLogger(),
timing,
});
@@ -62,6 +64,10 @@ describe("OpenCodeEventConsumer", () => {
const inputs: OpenCodeEventSourceInput[] = [];
consumer.subscribe((input) => inputs.push(input));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(upstream.requests).toHaveLength(0);
expect(consumer.diagnostics().attempt).toBe(0);
listening.resolve();
await upstream.connected(1);
expect(await promiseState(consumer.ready())).toBe("pending");
upstream.send(0, arbitraryRecord("/one"));
@@ -106,10 +112,7 @@ describe("OpenCodeEventConsumer", () => {
await consumer.ready();
timing.expireWatchdog();
expect(timing.watchdogDelays).toEqual([
EXPECTED_STREAM_WATCHDOG_MS,
EXPECTED_STREAM_WATCHDOG_MS,
]);
expect(timing.watchdogDelays).toEqual([5_000, EXPECTED_STREAM_WATCHDOG_MS]);
await timing.waiting();
timing.advanceWait();
await upstream.connected(2);
@@ -120,6 +123,7 @@ describe("OpenCodeEventConsumer", () => {
test("retries a first-record watchdog and exposes the recovery attempt", async () => {
const upstream = await createSseUpstream();
upstream.stallNext(1);
const timing = new ControlledTiming();
const logger = createRecordingLogger();
const consumer = new OpenCodeEventConsumer({
@@ -127,6 +131,12 @@ describe("OpenCodeEventConsumer", () => {
processExit: new Promise<Error>(() => undefined),
logger,
timing,
createClient: (baseUrl) =>
createOpencodeClient({
baseUrl,
// Keep the abandoned socket open to reproduce a fetch that does not unwind on abort.
fetch: (request) => fetch(new Request(request, { signal: null })),
}),
});
cleanups.push(async () => {
await consumer.close();
@@ -134,6 +144,7 @@ describe("OpenCodeEventConsumer", () => {
});
await upstream.connected(1);
expect(timing.watchdogDelays).toEqual([5_000]);
timing.expireWatchdog();
await timing.waiting();
expect(consumer.diagnostics()).toMatchObject({
@@ -156,7 +167,18 @@ describe("OpenCodeEventConsumer", () => {
await upstream.connected(2);
upstream.send(1, connectedRecord("/recovered"));
await consumer.ready();
expect(consumer.diagnostics()).toMatchObject({ attempt: 2, phase: "stream" });
expect(consumer.diagnostics()).toMatchObject({
attempt: 2,
phase: "stream",
});
expect(upstream.isOpen(0)).toBe(true);
upstream.resume(0);
upstream.send(0, connectedRecord("/abandoned"));
upstream.send(0, arbitraryRecord("/abandoned"));
const inputs: OpenCodeEventSourceInput[] = [];
consumer.subscribe((input) => inputs.push(input));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(inputs).toEqual([]);
});
test("publishes one terminal and stops reconnecting on process exit", async () => {
@@ -422,6 +444,16 @@ describe("OpenCodeEventConsumer", () => {
await consumer.close();
expect(inputs).toEqual([]);
const waitingConsumer = new OpenCodeEventConsumer({
serverUrl: upstream.url,
processExit: new Promise<Error>(() => undefined),
listening: new Promise<void>(() => undefined),
logger: createRecordingLogger(),
});
await waitingConsumer.close();
await expect(waitingConsumer.ready()).rejects.toThrow("OpenCode event source closed");
expect(upstream.requests).toHaveLength(1);
});
});
@@ -499,8 +531,14 @@ async function createSseUpstream() {
const responses: ServerResponse[] = [];
const requests: Array<{ url: string | undefined }> = [];
let failuresRemaining = 0;
let stallsRemaining = 0;
const server = createServer((request, response) => {
requests.push({ url: request.url });
if (stallsRemaining > 0) {
stallsRemaining -= 1;
responses.push(response);
return;
}
if (failuresRemaining > 0) {
failuresRemaining -= 1;
response.writeHead(503).end();
@@ -533,7 +571,21 @@ async function createSseUpstream() {
failNext(count: number) {
failuresRemaining = count;
},
close: async () => new Promise<void>((resolve) => server.close(() => resolve())),
stallNext(count: number) {
stallsRemaining = count;
},
isOpen(index: number) {
return responses[index]?.socket?.destroyed === false;
},
resume(index: number) {
responses[index]?.writeHead(200, { "content-type": "text/event-stream" });
responses[index]?.flushHeaders();
},
close: async () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
}),
};
}
@@ -29,11 +29,13 @@ export interface OpenCodeEventConsumerTiming {
export interface OpenCodeEventConsumerOptions {
serverUrl: string;
processExit: Promise<Error>;
listening?: Promise<void>;
logger: Pick<Logger, "debug" | "warn">;
createClient?: (baseUrl: string) => OpencodeClient;
timing?: OpenCodeEventConsumerTiming;
}
const FIRST_RECORD_WATCHDOG_MS = 5_000;
const WATCHDOG_MS = 30_000;
const MAX_BACKOFF_MS = 5_000;
const FAILURE_WARNING_ATTEMPT = 4;
@@ -98,7 +100,7 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource {
this.rejectReady = reject;
});
void this.readyPromise.catch(() => undefined);
this.connectionTask = this.consume(options.processExit);
this.connectionTask = this.consume(options.processExit, options.listening);
void this.connectionTask.catch(() => undefined);
}
@@ -131,8 +133,23 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource {
await this.connectionTask.catch(() => undefined);
}
private async consume(processExit: Promise<Error>): Promise<void> {
private async consume(processExit: Promise<Error>, listening?: Promise<void>): Promise<void> {
void processExit.then((error) => this.exit(error));
if (listening) {
const signal = this.connectionAbort.signal;
let stopWaiting!: () => void;
const stopped = new Promise<void>((resolve) => {
stopWaiting = resolve;
});
signal.addEventListener("abort", stopWaiting, { once: true });
try {
await Promise.race([listening, stopped]);
} catch {
return;
} finally {
signal.removeEventListener("abort", stopWaiting);
}
}
let reconnectAttempt = 0;
while (!this.closed) {
this.attempt += 1;
@@ -150,62 +167,83 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource {
private async consumeConnection(signal: AbortSignal): Promise<OpenCodeConnectionResult> {
const requestAbort = new AbortController();
const abortRequest = () => requestAbort.abort(signal.reason);
const attemptToken = { active: true };
let resolveAbandoned!: (result: OpenCodeConnectionResult) => void;
const abandoned = new Promise<OpenCodeConnectionResult>((resolve) => {
resolveAbandoned = resolve;
});
const abortRequest = () => {
attemptToken.active = false;
resolveAbandoned({ delivered, phase, outcome: "ended" });
requestAbort.abort(signal.reason);
};
signal.addEventListener("abort", abortRequest, { once: true });
let cancelWatchdog: () => void = () => undefined;
let delivered = false;
let phase: OpenCodeEventStreamPhase = "first-record";
let watchdogPhase: OpenCodeEventStreamPhase | null = null;
let sseError: unknown;
const armWatchdog = () => {
cancelWatchdog();
cancelWatchdog = this.timing.arm(WATCHDOG_MS, () => {
watchdogPhase = phase;
requestAbort.abort(new Error(`OpenCode event stream ${phase} watchdog expired`));
});
};
try {
const result = await this.client.global.event({
signal: requestAbort.signal,
sseMaxRetryAttempts: 0,
onSseError: (error) => {
sseError = error;
cancelWatchdog = this.timing.arm(
this.connected ? WATCHDOG_MS : FIRST_RECORD_WATCHDOG_MS,
() => {
const error = new Error(`OpenCode event stream ${phase} watchdog expired`);
// Abandon a stalled request even when fetch ignores abort, for example during OpenCode startup.
attemptToken.active = false;
resolveAbandoned({ delivered, phase, outcome: "watchdog", error });
requestAbort.abort(error);
},
});
armWatchdog();
for await (const event of result.stream) {
if (this.closed) {
return { delivered, phase, outcome: "ended" };
);
};
const consumeStream = async (): Promise<OpenCodeConnectionResult> => {
try {
const result = await this.client.global.event({
signal: requestAbort.signal,
sseMaxRetryAttempts: 0,
onSseError: (error) => {
sseError = error;
},
});
for await (const event of result.stream) {
// Ignore late records from abandoned requests, for example a delayed server.connected event.
if (this.closed || !attemptToken.active) {
return { delivered, phase, outcome: "ended" };
}
delivered = true;
phase = "stream";
this.phase = phase;
if (!this.connected && event.payload.type === "server.connected") {
this.connected = true;
this.resolveReady();
armWatchdog();
continue;
}
armWatchdog();
this.logPluginFailure(event);
this.publish(event);
}
armWatchdog();
delivered = true;
phase = "stream";
this.phase = phase;
if (!this.connected && event.payload.type === "server.connected") {
this.connected = true;
this.resolveReady();
continue;
}
this.logPluginFailure(event);
this.publish(event);
let outcome: OpenCodeConnectionOutcome = "ended";
if (sseError !== undefined) outcome = "error";
return {
delivered,
phase,
outcome,
...(sseError === undefined ? {} : { error: sseError }),
};
} catch (error) {
return {
delivered,
phase,
outcome: "error",
error,
};
}
let outcome: OpenCodeConnectionOutcome = "ended";
if (watchdogPhase) outcome = "watchdog";
else if (sseError !== undefined) outcome = "error";
return {
delivered,
phase: watchdogPhase ?? phase,
outcome,
...(sseError === undefined ? {} : { error: sseError }),
};
} catch (error) {
return {
delivered,
phase: watchdogPhase ?? phase,
outcome: watchdogPhase ? "watchdog" : "error",
error,
};
};
armWatchdog();
try {
return await Promise.race([consumeStream(), abandoned]);
} finally {
attemptToken.active = false;
cancelWatchdog();
signal.removeEventListener("abort", abortRequest);
requestAbort.abort();
@@ -285,5 +323,5 @@ function containsPluginError(error: unknown): boolean {
}
export type OpenCodeEventConsumerFactory = (
options: Pick<OpenCodeEventConsumerOptions, "serverUrl" | "processExit" | "logger">,
options: Pick<OpenCodeEventConsumerOptions, "serverUrl" | "processExit" | "logger" | "listening">,
) => OpenCodeEventConsumer;
@@ -209,3 +209,25 @@ test.each([
},
10000,
);
test("selects v2 for an OpenCode v2 binary that answers --version after 7 s", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), "opencode-slow-version-"));
const script = join(root, "version.cjs");
await writeFile(script, 'setTimeout(() => console.log("opencode v2.0.22"), 7000)');
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
});
try {
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto-accept",
);
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 20_000);
@@ -356,6 +356,12 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
const processExit = new Promise<Error>((resolve) => {
resolveProcessExit = resolve;
});
let resolveListening!: () => void;
let rejectListening!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveListening = resolve;
rejectListening = reject;
});
const server: OpenCodeServerGeneration = {
environment,
process: serverProcess,
@@ -364,7 +370,12 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
refCount: 0,
retired: false,
ready: Promise.resolve(),
events: this.createEventSource({ serverUrl: url, processExit, logger: this.logger }),
events: this.createEventSource({
serverUrl: url,
processExit,
logger: this.logger,
listening: ready,
}),
managedProcessRecord,
};
this.logger.info(
@@ -403,7 +414,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
return sections.join("\n");
};
const ready = new Promise<void>((resolve, reject) => {
{
let timeout: ReturnType<typeof setTimeout>;
const failStartup = (error: Error) => {
if (settled) {
@@ -411,7 +422,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
settled = true;
clearTimeout(timeout);
reject(error);
rejectListening(error);
};
timeout = setTimeout(() => {
if (!started) {
@@ -426,7 +437,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
started = true;
settled = true;
clearTimeout(timeout);
resolve();
resolveListening();
}
});
@@ -462,7 +473,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
});
});
}
server.ready = ready.catch(async (error) => {
await this.killServer(server);
@@ -1036,6 +1036,47 @@ describe("PiRpcAgentSession", () => {
]);
});
test("keeps custom context in separate tools while a turn continues", async () => {
const { pi, session, events } = await createSession();
const runtime = pi.latestSession();
await session.startTurn("Explain the project");
runtime.emit({ type: "agent_start" });
runtime.emit({ type: "turn_start" });
for (const display of [true, false, true]) {
runtime.emit({
type: "message_end",
message: {
role: "custom",
customType: "project-context",
content: [{ type: "text", text: "Project instructions" }],
details: { project: "example" },
display,
},
});
}
const items = events.timelineItems();
expect(items).toEqual(
[1, 2].map(() => ({
type: "tool_call",
callId: expect.stringMatching(/^pi-custom-/),
name: "project-context",
status: "completed",
detail: { type: "plain_text", text: "Project instructions" },
metadata: {
synthetic: true,
customType: "project-context",
details: { project: "example" },
},
error: null,
})),
);
expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2);
expect(events.turnCompletedEvents()).toHaveLength(0);
runtime.finishTurn();
await events.nextTurnCompletion();
await session.close();
});
test("surfaces Pi extension command messages and completes when no agent turn starts", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -1052,7 +1093,15 @@ describe("PiRpcAgentSession", () => {
expect(events.timelineAndCompletionEvents()).toEqual([
{
type: "timeline",
item: { type: "assistant_message", text: "Extension command output" },
item: {
type: "tool_call",
callId: expect.stringMatching(/^pi-custom-/),
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "Extension command output" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
},
{ type: "turn_completed" },
]);
@@ -1081,7 +1130,18 @@ describe("PiRpcAgentSession", () => {
});
expect(events.timelineItems()).toEqual([
{ type: "assistant_message", text: "Background process completed" },
{
type: "tool_call",
callId: expect.stringMatching(/^pi-custom-/),
name: "custom-message",
status: "completed",
detail: {
type: "plain_text",
text: "Background process completed",
},
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
]);
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]);
@@ -1,3 +1,4 @@
import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
@@ -2368,12 +2369,16 @@ export class PiRpcAgentSession implements AgentSession {
const customMapping = this.extensionHost.mapCustomMessage(event.message);
this.emitExtensionOutput(customMapping, turnId);
const text = getUserMessageText(event.message.content);
if (text) {
if (event.message.display !== false && text) {
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text },
item: mapCustomMessageToToolCall(
event.message,
text,
`${this.provider}-custom-${randomUUID()}`,
),
});
}
if (!this.activeTurnStarted) {
@@ -129,15 +129,28 @@ describe("@gotgenes/pi-subagents adapter", () => {
expect(new Set(upserts.map((event) => event?.id)).size).toBe(1);
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
});
test("keeps claimed notification text visible in history", async () => {
test("keeps claimed notification text in a tool row in history", async () => {
const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
const text = fixture.messages.find((message) => message.role === "custom")?.content;
const notification = fixture.messages.find((message) => message.role === "custom");
const text = notification?.content;
const events = [];
for await (const event of streamPiHistory("pi", fixture.messages)) events.push(event);
expect(events).toContainEqual({
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text },
item: {
type: "tool_call",
callId: "pi-custom-1",
name: notification?.customType,
status: "completed",
detail: { type: "plain_text", text },
metadata: {
synthetic: true,
customType: notification?.customType,
details: notification?.details,
},
error: null,
},
});
expect(events.some((event) => event.type === "provider_subagent")).toBe(true);
});
@@ -65,16 +65,46 @@ export async function verifySubagentFixture(
});
for (const message of fixture.messages) {
if (message.role !== "custom" || typeof message.content !== "string") continue;
const assistantText = {
type: "assistant_message" as const,
text: message.content,
};
expect(live).toContainEqual(
expect.objectContaining({ type: "timeline", item: assistantText }),
);
expect(replay).toContainEqual(
expect.objectContaining({ type: "timeline", item: assistantText }),
);
for (const events of [live, replay]) {
const tools = events.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.detail.type === "plain_text" &&
event.item.detail.text === message.content
? [event.item]
: [],
);
expect(tools).toEqual(
message.display === false
? []
: [
{
type: "tool_call",
callId: expect.stringMatching(/^pi-custom-/),
name: message.customType ?? "custom-message",
status: "completed",
detail: {
type: "plain_text",
text: message.content,
},
metadata: {
synthetic: true,
customType: message.customType ?? "custom-message",
...(message.details === undefined ? {} : { details: message.details }),
},
error: null,
},
],
);
expect(
events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text === message.content,
),
).toEqual([]);
}
}
const liveSubagents = live.filter((event) => event.type === "provider_subagent");
const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline");
@@ -16,6 +16,35 @@ async function collectHistory(
}
describe("Pi history mapper", () => {
test("renders visible custom messages as completed tools and hides private context", async () => {
const events = await collectHistory([
{
role: "custom",
customType: "project-context",
content: "Project instructions",
display: true,
},
{
role: "custom",
customType: "private-context",
content: "Hidden instructions",
display: false,
},
{ role: "custom", customType: "project-context", content: "Project instructions" },
]);
expect(events.map((event) => event.item)).toEqual(
[1, 2].map((index) => ({
type: "tool_call",
callId: `pi-custom-${index}`,
name: "project-context",
status: "completed",
detail: { type: "plain_text", text: "Project instructions" },
metadata: { synthetic: true, customType: "project-context" },
error: null,
})),
);
});
test("replays user, assistant, reasoning, and completed tool calls", async () => {
await expect(
collectHistory([
@@ -128,14 +157,22 @@ describe("Pi history mapper", () => {
]);
});
test("replays non-notice custom messages as assistant text, matching the live path", async () => {
test("replays custom messages as completed tools, matching the live path", async () => {
await expect(
collectHistory([{ role: "custom", content: "Extension command output" }]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "Extension command output" },
item: {
type: "tool_call",
callId: "pi-custom-1",
name: "custom-message",
status: "completed",
detail: { type: "plain_text", text: "Extension command output" },
metadata: { synthetic: true, customType: "custom-message" },
error: null,
},
},
]);
});
@@ -1,3 +1,4 @@
import { mapCustomMessageToToolCall } from "../custom-message.js";
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import {
createPiExtensionHost,
@@ -61,6 +62,7 @@ export class PiHistoryMapper {
private readonly hydrations: Promise<AgentStreamEvent[]>[] = [];
private userIndex = 0;
private assistantIndex = 0;
private customIndex = 0;
constructor(
private readonly provider: string,
@@ -125,6 +127,7 @@ export class PiHistoryMapper {
): AgentStreamEvent[] {
const extensionMapping = this.extensionHost.mapCustomMessage(message);
const extensionEvents = this.extensionEvents(extensionMapping);
if (message.display === false) return extensionEvents;
const text = getUserMessageText(message.content);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
@@ -137,7 +140,11 @@ export class PiHistoryMapper {
{
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text },
item: mapCustomMessageToToolCall(
message,
text,
`${this.provider}-custom-${++this.customIndex}`,
),
} as AgentStreamEvent,
]
: []),
+2
View File
@@ -414,6 +414,7 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -624,6 +625,7 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,6 +30,7 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
@@ -211,6 +211,15 @@ test("loads private plugin registry settings through the configuration boundary"
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
} finally {
await rm(home, { recursive: true, force: true });
}
+4
View File
@@ -628,6 +628,10 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
@@ -36,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise<string> {
return stdout.trim();
}
/** Points https://github.com/fixture/repository.git at a local repository for the callback. */
async function withGitHubFixture(repository: string, run: () => Promise<void>): Promise<void> {
const overlay = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
};
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
Object.assign(process.env, overlay);
try {
await run();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
describe("managed Git plugin sources", () => {
it("does not expose Git URL credentials when cloning fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
@@ -52,14 +71,7 @@ describe("managed Git plugin sources", () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-"));
roots.push(home);
const overlay = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
};
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
Object.assign(process.env, overlay);
try {
await withGitHubFixture(repository, async () => {
const sources = new ManagedPluginSources(home);
for (const source of ["github:fixture/repository", "git:fixture/repository"]) {
const candidate = await sources.prepareInstall({ source });
@@ -69,12 +81,7 @@ describe("managed Git plugin sources", () => {
});
await sources.discard(candidate);
}
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
}, 30_000);
it("offers current default HEAD after installing a tag", async () => {
@@ -273,6 +280,37 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((req, res) => {
requests.push(req.url);
res.statusCode = 404;
res.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
try {
await withGitHubFixture(repository, async () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
});
expect(requests).toEqual([]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("installs a bare id from a private registry and polls its pin without install intent", async () => {
const repository = await createRepository();
await mkdir(path.join(repository, "packages/example"), { recursive: true });
@@ -331,6 +369,7 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -405,7 +444,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,7 +147,9 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
if (registry) {
if (input.ref || input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
@@ -173,7 +175,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source);
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -225,7 +227,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (current.identity.registry) {
if (this.registryOptions.enabled && current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -302,7 +304,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (identity.kind !== "directory" && identity.registry) {
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -434,8 +436,10 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
function normalizeGitSource(source: string): string {
const explicit = source.startsWith("github:") || source.startsWith("git:");
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -446,7 +450,7 @@ function normalizeGitSource(source: string): string {
source = source.slice(4);
}
const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
if (github && explicit) {
if (github && expandsShorthand) {
const repository = github[2].replace(/\.git$/, "");
return `https://github.com/${github[1]}/${repository}.git`;
}
@@ -6,6 +6,8 @@ import {
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
@@ -150,6 +150,28 @@ test("discovery failures log once while other sources still report", async () =>
expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]);
});
test("a failed fetch is logged as well as reported", async () => {
const warnings: unknown[][] = [];
const registry = new UsageSourceRegistry(Date.now, 300_000, {
warn: (...args: unknown[]) => {
warnings.push(args);
},
});
const err = new Error("Rate limited by Claude. Try again in 38m.");
registry.register(
source({
id: "claude",
discover: async () => [{ key: "work", input: {} }],
fetch: async () => {
throw err;
},
}),
);
const [report] = await registry.listReports();
expect(report?.report).toEqual({ status: "error", error: err.message });
expect(warnings).toEqual([[{ sourceId: "claude", err }, "Usage fetch failed"]]);
});
test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => {
const registry = new UsageSourceRegistry(() => 1000);
registry.register(
@@ -275,6 +275,7 @@ export class UsageSourceRegistry {
try {
return UsageReportSchema.parse(await source.fetch(input));
} catch (error) {
this.logger.warn({ sourceId: source.id, err: error }, "Usage fetch failed");
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
}
@@ -1,11 +1,16 @@
import { describe, expect, test } from "vitest";
import path from "node:path";
import {
DaemonSelfUpdateInProgressError,
DaemonSelfUpdater,
type DaemonSelfUpdateRuntime,
type DaemonSelfUpdatePhase,
} from "./daemon-self-updater.js";
import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js";
import {
DefaultNpmGlobalPaseoCli,
type CommandResult,
type NpmGlobalPaseoInstall,
} from "./npm-global-cli.js";
interface TestLogger {
errors: Array<{ obj: object; msg?: string }>;
@@ -100,6 +105,45 @@ async function runUpdate(input: {
}
describe("DaemonSelfUpdater", () => {
test("probes and updates the running install's prefix when npm defaults elsewhere", async () => {
const prefix = path.resolve("custom npm prefix");
const root = process.platform === "win32" ? prefix : path.join(prefix, "lib");
const packagePath = path.join(root, "node_modules", "@getpaseo", "cli");
const commands: string[][] = [];
let version = "0.1.15";
const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => {
commands.push(args);
if (args[args.indexOf("--prefix") + 1] !== prefix) {
return { exitCode: 1, stdout: "{}", stderr: "No CLI in npm's default prefix" };
}
if (args.includes("install")) version = "0.1.96";
return {
exitCode: 0,
stderr: "",
stdout: JSON.stringify({
path: root,
dependencies: { "@getpaseo/cli": { version, path: packagePath } },
}),
};
});
const { result } = await runUpdate({
runtime: {
npm,
installOrigin: {
resolveCurrentServerPackageRoot: () =>
path.join(packagePath, "node_modules", "@getpaseo", "server"),
},
},
});
expect(result).toEqual({ success: true, error: null, newVersion: "0.1.96" });
expect(commands).toHaveLength(3);
expect(commands.map((args) => args.slice(-2))).toEqual([
["--prefix", prefix],
["--prefix", prefix],
["--prefix", prefix],
]);
});
test("refuses a Desktop-managed daemon without touching npm", async () => {
const calls: RuntimeCall[] = [];
const runtime = createRuntime({ calls, inspections: [] });
@@ -1,6 +1,7 @@
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
daemonInstallOriginRuntime,
resolveNpmGlobalPrefix,
validateDaemonInstallOrigin,
type DaemonInstallOriginRuntime,
} from "./install-origin.js";
@@ -63,7 +64,12 @@ export class DaemonSelfUpdater {
this.inProgress = true;
try {
input.onProgress("starting");
const install = await this.runtime.npm.inspect();
const npmOptions = {
prefix: resolveNpmGlobalPrefix(
this.runtime.installOrigin.resolveCurrentServerPackageRoot(),
),
};
const install = await this.runtime.npm.inspect(npmOptions);
const unsupportedReason = validateDaemonInstallOrigin(
install,
input.daemonVersion,
@@ -76,7 +82,7 @@ export class DaemonSelfUpdater {
input.onProgress("downloading");
input.onProgress("installing");
const result = await this.runtime.npm.installLatest();
const result = await this.runtime.npm.installLatest(npmOptions);
if (result.exitCode !== 0) {
const error =
result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`;
@@ -87,7 +93,7 @@ export class DaemonSelfUpdater {
return { success: false, error, newVersion: null };
}
const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => {
const updatedInstall = await this.runtime.npm.inspect(npmOptions).catch((error: unknown) => {
input.logger.warn({ err: error }, "Unable to read updated npm package version");
return null;
});
@@ -15,6 +15,21 @@ export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = {
resolveCurrentServerPackageRoot,
};
export function resolveNpmGlobalPrefix(serverPackageRoot: string | null): string | undefined {
if (!serverPackageRoot) return undefined;
let directory = serverPackageRoot;
let prefix: string | undefined;
while (path.dirname(directory) !== directory) {
if (path.basename(directory) === "node_modules") {
const root = path.dirname(directory);
if (process.platform === "win32") prefix = root;
else if (path.basename(root) === "lib") prefix = path.dirname(root);
}
directory = path.dirname(directory);
}
return prefix;
}
export function validateDaemonInstallOrigin(
install: NpmGlobalPaseoInstall,
daemonVersion: string | null,
@@ -50,8 +50,12 @@ export interface NpmGlobalPaseoInstall {
}
export interface NpmGlobalPaseoCli {
inspect(): Promise<NpmGlobalPaseoInstall>;
installLatest(): Promise<CommandResult>;
inspect(options?: NpmGlobalOptions): Promise<NpmGlobalPaseoInstall>;
installLatest(options?: NpmGlobalOptions): Promise<CommandResult>;
}
interface NpmGlobalOptions {
prefix?: string;
}
export type CommandRunner = (
@@ -115,10 +119,11 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul
export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
constructor(private readonly runCommand: CommandRunner = runExternalCommand) {}
async inspect(): Promise<NpmGlobalPaseoInstall> {
async inspect(options: NpmGlobalOptions = {}): Promise<NpmGlobalPaseoInstall> {
const prefixArgs = options.prefix ? ["--prefix", options.prefix] : [];
const result = await this.runCommand(
"npm",
["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"],
["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long", ...prefixArgs],
{
timeout: NPM_PROBE_TIMEOUT_MS,
maxBuffer: NPM_MAX_BUFFER_BYTES,
@@ -136,8 +141,9 @@ export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli {
return install;
}
installLatest(): Promise<CommandResult> {
return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], {
installLatest(options: NpmGlobalOptions = {}): Promise<CommandResult> {
const prefixArgs = options.prefix ? ["--prefix", options.prefix] : [];
return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`, ...prefixArgs], {
timeout: NPM_INSTALL_TIMEOUT_MS,
maxBuffer: NPM_MAX_BUFFER_BYTES,
});
+156 -73
View File
@@ -2,9 +2,8 @@ import { expect, test, type Page } from "playwright/test";
import { CATEGORIES } from "../src/plugins/categories";
async function openPlugins(page: Page) {
// Wait for hydration so typing reaches React rather than the server-rendered input.
await page.goto("/plugins", { waitUntil: "networkidle" });
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await page.goto("/plugins");
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
}
test("browses from the directory into a category, a plugin, and its author", async ({
@@ -25,88 +24,138 @@ test("browses from the directory into a category, a plugin, and its author", asy
});
await openPlugins(page);
const themes = page.getByRole("region", { name: "Themes" });
await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible();
await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click();
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await browseCategory(page, "Git");
await expect(page).toHaveURL(/category=/);
await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1);
await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0);
await openPlugin(page, /Fresh Worktrees/);
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
await expect(
page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }),
).toBeVisible();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
).toBeVisible();
await copyInstallCommand(page);
await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied))
.toBe("paseo plugin install omercnet/fresh-worktrees");
await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute(
"href",
"/plugins/category/git",
);
const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" });
await expect(breadcrumbs).toContainText("Plugins");
await expect(breadcrumbs).toContainText("Git");
await expect(breadcrumbs).toContainText("Fresh Worktrees");
await openAuthor(page, "Omer Cohen");
await page.getByRole("link", { name: "Omer Cohen" }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet$/);
await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible();
await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0);
});
test("filters by search and clears to the full directory", async ({ page }) => {
test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => {
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute(
"aria-current",
"page",
);
await page.getByRole("link", { name: "This month" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?window=month$/);
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
"true",
);
await page.getByRole("link", { name: "Newest" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=new$/);
await expect(page.getByRole("navigation", { name: "Time window" })).toHaveCount(0);
await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText(
"ago",
);
});
test("filters by search and clears to all plugins", async ({ page }) => {
await page.goto("/");
await openPlugins(page);
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/q=graphite/);
await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
).toBeVisible();
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await searchPlugins(page, "zzzz-nothing");
await expect(page.getByText("No plugins match.")).toBeVisible();
await page.getByRole("link", { name: "Clear filters" }).click();
await expect(page).toHaveURL(/\/plugins\/?$/);
await expect(page.getByRole("region", { name: "Themes" })).toBeVisible();
await expect(page).toHaveURL(/\/plugins\/all$/);
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("");
});
test("keeps the directory's ranking window when searching", async ({ page }) => {
await page.goto("/plugins?window=month");
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
"true",
);
});
test("clears the search with the clear button", async ({ page }) => {
await page.goto("/plugins/all");
const searchbox = page.getByRole("searchbox", { name: "Search plugins" });
const clear = page.getByRole("button", { name: "Clear search" });
await expect(clear).toHaveCount(0);
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await clear.click();
await expect(page).toHaveURL(/\/plugins\/all$/);
await expect(searchbox).toHaveValue("");
await expect(searchbox).toBeFocused();
await expect(clear).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
});
test("replaces history while typing a search", async ({ page }) => {
await page.goto("/");
await page.goto("/plugins/all");
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
test("keeps old category links working", async ({ page }) => {
await page.goto("/plugins?category=git&sort=new");
await expect(page).toHaveURL(/\/plugins\/category\/git\?sort=new$/);
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
});
test("explains a plugin that is not listed", async ({ page }) => {
await page.goto("/plugins/acme/does-not-exist");
await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible();
await page.getByRole("link", { name: "Browse all plugins" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
});
async function copyInstallCommand(page: Page) {
await page.getByRole("button", { name: "Copy to clipboard" }).first().click();
}
async function browseCategory(page: Page, category: string) {
await page.getByRole("link", { name: category, exact: true }).click();
}
async function openPlugin(page: Page, name: RegExp) {
await page.getByRole("link", { name }).first().click();
}
async function openAuthor(page: Page, name: string) {
await page.getByRole("link", { name }).first().click();
}
async function searchPlugins(page: Page, query: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(query);
}
test.describe("search engine visits without JavaScript", () => {
test.use({ javaScriptEnabled: false });
test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => {
test("reads directory, category, author, and complete plugin HTML with page metadata", async ({
page,
}) => {
await openPlugins(page);
await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible();
await expect(page.getByRole("link", { name: /Base2Tone/ }).first()).toBeVisible();
await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins");
await page.goto("/plugins/category/git");
await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible();
await expectPageMetadata(page, "Git – Paseo plugins", "/plugins/category/git");
await page.goto("/plugins/omercnet");
await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible();
await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible();
@@ -143,7 +192,27 @@ test.describe("search engine visits without JavaScript", () => {
);
});
test("returns real 404 pages for unknown plugins and authors", async ({ page }) => {
test("renders search results and submits the search form", async ({ page }) => {
await page.goto("/plugins/all?q=graphite");
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
).toBeVisible();
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await page.goto("/plugins/category/git");
await searchPlugins(page, "fresh");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/plugins\/category\/git\?q=fresh$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “fresh” in Git/ }),
).toBeVisible();
await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible();
await page.getByRole("link", { name: "Clear", exact: true }).click();
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
});
test("returns real 404 pages for unknown plugins, authors, and categories", async ({ page }) => {
const plugin = await page.goto("/plugins/acme/does-not-exist");
expect(plugin?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible();
@@ -152,9 +221,24 @@ test.describe("search engine visits without JavaScript", () => {
expect(author?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible();
await expect(page).toHaveTitle("Author not found – Paseo");
const category = await page.goto("/plugins/category/does-not-exist");
expect(category?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Category not found" })).toBeVisible();
await expect(page).toHaveTitle("Category not found – Paseo");
});
test("discovers plugin and author URLs through robots and the sitemap index", async ({
test("redirects old category links permanently", async ({ request }) => {
const response = await request.get("/plugins?category=git", { maxRedirects: 0 });
expect(response.status()).toBe(301);
expect(response.headers().location).toMatch(/\/plugins\/category\/git$/);
const search = await request.get("/plugins?q=graphite", { maxRedirects: 0 });
expect(search.status()).toBe(301);
expect(search.headers().location).toMatch(/\/plugins\/all\?q=graphite$/);
const windowed = await request.get("/plugins?q=graphite&window=month", { maxRedirects: 0 });
expect(windowed.headers().location).toMatch(/\/plugins\/all\?q=graphite&window=month$/);
});
test("discovers plugin, category, and author URLs through robots and the sitemap index", async ({
request,
}) => {
const robots = await request.get("/robots.txt");
@@ -164,13 +248,18 @@ test.describe("search engine visits without JavaScript", () => {
const plugins = await request.get("/sitemap-plugins.xml");
expect(plugins.status()).toBe(200);
expect(plugins.headers()["content-type"]).toContain("application/xml");
expect(await plugins.text()).toContain("<loc>https://paseo.sh/plugins/omercnet</loc>");
expect(await plugins.text()).toContain(
"<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>",
);
const sitemap = await plugins.text();
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/all</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/category/git</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>");
});
});
async function searchPlugins(page: Page, term: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
}
async function expectPageMetadata(page: Page, title: string, path: string) {
await expect(page).toHaveTitle(title);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
@@ -210,30 +299,24 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
await expect(page.locator('a[href="/plugins"]')).toHaveCount(0);
const response = await page.goto("/plugins");
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
});
// External deployments own their registry contents; these assertions use the local fixture.
test.describe("registry category layout", () => {
test.describe("registry fixture layout", () => {
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
test("orders populated categories and hides the empty Extras category", async ({ page }) => {
test("lists the nine categories in order with counts, and the newest plugins first", async ({
page,
}) => {
await openPlugins(page);
await expect(page.getByRole("heading", { level: 2 })).toHaveText([
"Daemon management",
"Themes",
"Providers",
"Orchestration",
"Git",
"Workspaces",
"Sidebar",
"Utils",
]);
await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0);
for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) {
await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText(
category.description,
);
}
const categories = page.getByRole("region", { name: "Categories" });
await expect(categories.getByRole("link")).toHaveText(
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
);
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
await expect(
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
});
});
@@ -3,12 +3,17 @@ import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
interface SiteFooterProps {
width?: "default" | "prose";
width?: "default" | "prose" | "wide";
}
const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
prose: "max-w-prose p-6 md:p-12 md:pt-0",
default: "max-w-5xl p-6 md:p-20 md:pt-0",
wide: "max-w-7xl p-6 md:p-20 md:pt-0",
};
export function SiteFooter({ width = "default" }: SiteFooterProps) {
const widthClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0";
const widthClasses = WIDTH_CLASSES[width];
const alternatives = getAlternativePages();
return (
<footer className={`${widthClasses} mx-auto`}>
@@ -4,12 +4,17 @@ import { SiteHeader } from "~/components/site-header";
interface SiteShellProps {
children: ReactNode;
width: "default" | "prose";
width: "default" | "prose" | "wide";
}
const MAIN_CLASSES: Record<SiteShellProps["width"], string> = {
prose: "max-w-prose p-6 md:p-12 mx-auto",
default: "max-w-5xl p-6 md:p-20 mx-auto",
wide: "max-w-7xl p-6 md:p-20 mx-auto",
};
export function SiteShell({ children, width }: SiteShellProps) {
const mainClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto";
const mainClasses = MAIN_CLASSES[width];
return (
<div className="min-h-screen bg-background">
<main className={mainClasses}>
@@ -0,0 +1,214 @@
import { useMemo } from "react";
import { SiteShell } from "~/components/site-shell";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import {
addedAgo,
CATEGORIES,
type CategorySlug,
getCategory,
getPluginsInCategory,
mostInstalled,
newestFirst,
type Plugin,
searchPlugins,
} from "./registry";
import { WindowSwitch } from "./window-switch";
const NAV_BASE =
"flex items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-sm transition-colors";
const NAV_ON = `${NAV_BASE} bg-white/[0.06] text-white`;
const NAV_OFF = `${NAV_BASE} text-muted-foreground hover:text-foreground`;
const CHIP_BASE =
"inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors";
const CHIP_ON = `${CHIP_BASE} border-white/20 bg-white/[0.07] text-white`;
const CHIP_OFF = `${CHIP_BASE} border-white/10 text-muted-foreground`;
const TAB_BASE = "-mb-px border-b pb-2 text-sm transition-colors";
const TAB_ON = `${TAB_BASE} border-white text-white`;
const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text-foreground`;
const CLEAR_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const CONTRIBUTE_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
now,
query,
}: {
plugins: Plugin[];
installs: Record<string, InstallCounts>;
now: string;
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
const results =
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
const title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
return (
<SiteShell width="wide">
<div className="lg:flex lg:gap-12">
<aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0">
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
<CategoryNav plugins={matches} query={query} />
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-baseline justify-between gap-4">
<h1 className="text-3xl font-medium tracking-tight">
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{scoped.length}
</span>
</h1>
{query.q && (
<a href={clearHref} className={CLEAR_CLASS}>
Clear
</a>
)}
</div>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={`mt-8 ${PLUGIN_GRID_CLASS}`}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
)}
</div>
</div>
</SiteShell>
);
}
interface NavEntry {
href: string;
label: string;
chip: string;
/** Absent for all plugins. */
slug?: CategorySlug;
count: number;
}
/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */
function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) {
const entries: NavEntry[] = [
{
href: browseHref({ ...query, category: undefined }),
label: "All plugins",
chip: "All",
count: plugins.length,
},
...CATEGORIES.map((category) => ({
href: browseHref({ ...query, category: category.slug }),
label: category.label,
chip: category.label,
slug: category.slug,
count: getPluginsInCategory(plugins, category.slug).length,
})),
];
return (
<>
<nav aria-label="Categories" className="-ml-2.5 hidden lg:block">
{entries.map((entry, index) => (
<div key={entry.label}>
<a
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? NAV_ON : NAV_OFF}
>
{entry.label}
<span className="text-xs tabular-nums text-extra-muted-foreground">
{entry.count}
</span>
</a>
{index === 0 && <div className="my-3 h-px bg-white/10" />}
</div>
))}
<div className="my-3 h-px bg-white/10" />
<div className="flex flex-col gap-1.5 px-2.5">
<ContributeLinks className={CONTRIBUTE_CLASS} />
</div>
</nav>
<nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden">
{entries.map((entry) => (
<a
key={entry.label}
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? CHIP_ON : CHIP_OFF}
>
{entry.chip}
</a>
))}
</nav>
</>
);
}
function SortTabs({ query }: { query: BrowseQuery }) {
const windowHrefs = useMemo(
() => ({
week: browseHref({ ...query, window: "week" }),
month: browseHref({ ...query, window: "month" }),
all: browseHref({ ...query, window: "all" }),
}),
[query],
);
const windowSwitch = query.sort === "installs" && (
<WindowSwitch current={query.window} hrefs={windowHrefs} />
);
return (
<>
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
href={browseHref({ ...query, sort: "installs" })}
aria-current={query.sort === "installs" ? "page" : undefined}
className={query.sort === "installs" ? TAB_ON : TAB_OFF}
>
Most installed
</a>
<a
href={browseHref({ ...query, sort: "new" })}
aria-current={query.sort === "new" ? "page" : undefined}
className={query.sort === "new" ? TAB_ON : TAB_OFF}
>
Newest
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
</>
);
}
@@ -1,36 +0,0 @@
import { Link } from "@tanstack/react-router";
import { type ReactNode, useMemo } from "react";
import type { CategorySlug, PluginSort } from "./registry";
interface CategoryLinkProps {
/** `null` links to the unfiltered list. */
category: CategorySlug | null;
q?: string;
sort?: PluginSort;
replace?: boolean;
className?: string;
children: ReactNode;
}
/** Link into the plugin list filtered by category, keeping any search and sort. */
export function CategoryLink({
category,
q,
sort,
replace,
className,
children,
}: CategoryLinkProps) {
const search = useMemo(() => {
const next: { category?: CategorySlug; q?: string; sort?: PluginSort } = {};
if (category) next.category = category;
if (q) next.q = q;
if (sort === "new") next.sort = sort;
return next;
}, [category, q, sort]);
return (
<Link to="/plugins" search={search} replace={replace} className={className}>
{children}
</Link>
);
}
@@ -0,0 +1,17 @@
import { BUILD_URL, SUBMIT_URL } from "./links";
const LINK_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
/** "Build a plugin" and "Submit a plugin", as two quiet links. */
export function ContributeLinks({ className = LINK_CLASS }: { className?: string }) {
return (
<>
<a href={BUILD_URL} className={className}>
Build a plugin
</a>
<a href={SUBMIT_URL} className={className}>
Submit a plugin
</a>
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
// Install counts live in the website KV. The all-time counter predates the daily record and
// stays the "All time" number; the daily record keeps the last 60 days for the shorter windows.
export type InstallWindow = "week" | "month" | "all";
export type InstallCounts = Record<InstallWindow, number>;
/** UTC day (YYYY-MM-DD) to installs that day. */
export type DailyInstalls = Record<string, number>;
const DAY_MS = 24 * 60 * 60 * 1000;
const DAYS_KEPT = 60;
const WINDOW_DAYS = { week: 7, month: 30 } as const;
const totalKey = (id: string) => `plugin-installs:${id}`;
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
await Promise.all([
cache.put(totalKey(id), String(Number(total ?? 0) + 1)),
cache.put(dailyKey(id), JSON.stringify(addInstall(daily, now))),
]);
}
export async function readInstallCounts(
cache: KVNamespace | null,
ids: string[],
now: Date,
): Promise<Record<string, InstallCounts>> {
const entries = await Promise.all(
ids.map(async (id) => {
if (!cache) return [id, countInstalls({}, 0, now)] as const;
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
return [id, countInstalls(daily, Number(total ?? 0), now)] as const;
}),
);
return Object.fromEntries(entries);
}
export function addInstall(daily: DailyInstalls, now: Date): DailyInstalls {
const oldest = utcDay(now, DAYS_KEPT - 1);
const kept = Object.fromEntries(Object.entries(daily).filter(([day]) => day >= oldest));
const today = utcDay(now, 0);
kept[today] = (kept[today] ?? 0) + 1;
return kept;
}
export function countInstalls(daily: DailyInstalls, total: number, now: Date): InstallCounts {
return {
week: installsSince(daily, utcDay(now, WINDOW_DAYS.week - 1)),
month: installsSince(daily, utcDay(now, WINDOW_DAYS.month - 1)),
all: total,
};
}
function installsSince(daily: DailyInstalls, oldest: string): number {
return Object.entries(daily).reduce(
(sum, [day, count]) => (day >= oldest ? sum + count : sum),
0,
);
}
function utcDay(now: Date, daysBefore: number): string {
return new Date(now.getTime() - daysBefore * DAY_MS).toISOString().slice(0, 10);
}
async function readDaily(cache: KVNamespace, id: string): Promise<DailyInstalls> {
const value: unknown = await cache.get(dailyKey(id), { type: "json" });
if (typeof value !== "object" || value === null) return {};
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { CategorySlug } from "./categories";
import type { InstallWindow } from "./installs";
export const BUILD_URL = "/docs/plugins";
export const SUBMIT_URL =
"https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml";
export type BrowseSort = "installs" | "new";
export interface BrowseQuery {
/** Absent for all plugins. */
category?: CategorySlug;
sort: BrowseSort;
window: InstallWindow;
/** Search term; absent when not searching. */
q?: string;
}
export const DEFAULT_WINDOW: InstallWindow = "week";
export function parseWindow(value: unknown): InstallWindow | undefined {
return value === "month" || value === "all" ? value : undefined;
}
export function parseSearchTerm(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function parseSort(value: unknown): BrowseSort | undefined {
return value === "new" ? value : undefined;
}
export function pluginHref(id: string): string {
return `/plugins/${id}`;
}
export function categoryHref(category: CategorySlug): string {
return `/plugins/category/${category}`;
}
export function browseHref({ category, sort, window, q }: BrowseQuery): string {
const path = category ? categoryHref(category) : "/plugins/all";
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort === "new") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
}
/** The directory's Most installed section in a time window. */
export function mostInstalledHref(window: InstallWindow): string {
return `/plugins${window === DEFAULT_WINDOW ? "" : `?window=${window}`}#most-installed`;
}

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