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.
This commit is contained in:
Mohamed Boudra
2026-10-05 01:59:15 +01:00
committed by GitHub
parent d517b3314e
commit fa0f04058d
46 changed files with 1359 additions and 704 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 -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,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();
});
+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 () => {
@@ -63,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] },
}));
+50 -37
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";
@@ -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}
/>
);
}
@@ -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",
+16 -71
View File
@@ -22,7 +22,6 @@ import {
useImperativeHandle,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -279,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;
@@ -1007,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;
}
@@ -2071,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(
() =>
@@ -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;
}
+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) => {
+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>
@@ -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;
@@ -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) };
}
}
@@ -50,10 +50,10 @@ function mockFetch(handlers: Map<string, () => Response>): typeof fetch {
}) as unknown as typeof fetch;
}
function jsonResponse(body: unknown, status = 200): Response {
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
});
}
@@ -997,6 +997,35 @@ it.each([
},
);
it("says when a rate-limited login can retry and does not call the API before then", async () => {
let now = Date.parse("2026-10-04T21:00:00Z");
const lookup = { ...tokenLookup("rate-limited-token"), now: () => now };
const input = credentialInput(lookup.claudeHome);
let usageCalls = 0;
const api: typeof fetch = async () => {
usageCalls += 1;
if (usageCalls > 1) return jsonResponse(makeClaudeResponse());
return jsonResponse(
{ error: { type: "rate_limit_error", message: "Rate limited. Please try again later." } },
429,
{ "Retry-After": "2240" },
);
};
await expect(fetchUsage(input, api, lookup)).rejects.toThrow(
"Rate limited by Claude. Try again in 38m.",
);
now += 30 * 60_000;
await expect(fetchUsage(input, api, lookup)).rejects.toThrow(
"Rate limited by Claude. Try again in 8m.",
);
expect(usageCalls).toBe(1);
now += 8 * 60_000;
expect((await fetchUsage(input, api, lookup)).status).toBe("available");
expect(usageCalls).toBe(2);
});
it("re-reads a refreshed login at the original locator", async () => {
const lookup = tokenLookup("expiring-token");
writeFileSync(
+38 -2
View File
@@ -521,7 +521,16 @@ export async function fetchUsage(
return parsed;
}
async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | number> {
async function callClaudeApi(token: string, now: number): Promise<ClaudeUsageResponse | number> {
for (const [key, until] of rateLimitedUntil) {
if (until <= now) rateLimitedUntil.delete(key);
}
const tokenKey = hashAccountKey(token);
const limitedUntil = rateLimitedUntil.get(tokenKey);
if (limitedUntil !== undefined && limitedUntil > now)
throw rateLimitedError(limitedUntil - now);
rateLimitedUntil.delete(tokenKey);
const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", {
signal: AbortSignal.timeout(15_000),
headers: {
@@ -531,6 +540,11 @@ export async function fetchUsage(
},
});
if (res.status === 401 || res.status === 403) return res.status;
if (res.status === 429) {
const waitMs = retryAfterMs(res.headers.get("retry-after"), now);
if (waitMs !== null) rateLimitedUntil.set(tokenKey, now + waitMs);
throw rateLimitedError(waitMs);
}
if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`);
return ClaudeUsageResponseSchema.parse(await res.json());
}
@@ -559,7 +573,7 @@ export async function fetchUsage(
const { oauth } = credentials;
const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier);
const resp = await callClaudeApi(oauth.accessToken);
const resp = await callClaudeApi(oauth.accessToken, (credentialLookup.now ?? Date.now)());
if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy });
@@ -595,6 +609,28 @@ export async function fetchUsage(
};
}
// A 429 from the usage endpoint carries Retry-After, observed in the tens of minutes. Until it
// passes, a fetch (including a forced Refresh) answers from here instead of calling again.
const rateLimitedUntil = new Map<string, number>();
function retryAfterMs(header: string | null, now: number): number | null {
if (!header) return null;
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const at = Date.parse(header);
return Number.isNaN(at) ? null : Math.max(0, at - now);
}
function rateLimitedError(waitMs: number | null): Error {
const when = waitMs === null ? "later" : `in ${formatWait(waitMs)}`;
return new Error(`Rate limited by Claude. Try again ${when}.`);
}
function formatWait(ms: number): string {
const minutes = Math.max(1, Math.ceil(ms / 60_000));
return minutes < 60 ? `${minutes}m` : `${Math.ceil(minutes / 60)}h`;
}
// The OAuth usage endpoint meters the active organization selected by the token.
type ClaudeIdentity = { key: string; label?: string } | { status: number };
const PROFILE_TTL_MS = 300_000;