From e939dd4c7a5cb3f6da73006dc3eee9c90bcbdf3d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 1 Oct 2026 04:55:17 +0100 Subject: [PATCH] Streamline sidebar usage and stacked sheet interactions (#5805) * feat(app): streamline sidebar usage and stacked sheets * fix(app): restore compact sidebar navigation clicks The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript. * fix(app): cap menu sheets at eighty percent height Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing. --- docs/floating-panels.md | 16 ++ docs/glossary.md | 2 +- docs/menus.md | 2 + docs/plugins.md | 2 +- .../e2e/browser/bottom-sheet-reopen.spec.ts | 14 ++ .../e2e/browser/import-session-flow.spec.ts | 9 +- .../e2e/browser/sidebar-nav-settings.spec.ts | 4 +- packages/app/e2e/browser/usage-screen.spec.ts | 6 +- .../app/e2e/browser/usage-settings.spec.ts | 5 +- .../e2e/browser/usage-sidebar-item.spec.ts | 110 +++++++-- .../app/e2e/support/helpers/import-session.ts | 33 ++- .../app/e2e/support/helpers/new-workspace.ts | 3 +- .../support/helpers/sidebar-nav-settings.ts | 38 ++-- .../app/e2e/support/helpers/startup-dsl.ts | 1 - .../e2e/support/helpers/usage-sidebar-item.ts | 112 +++++++++- .../app/src/command-center/command-center.tsx | 14 +- .../src/components/adaptive-modal-sheet.tsx | 10 +- packages/app/src/components/left-sidebar.tsx | 178 ++++----------- .../components/sidebar/sidebar-header-row.tsx | 13 +- .../app/src/components/tool-call-sheet.tsx | 11 +- packages/app/src/components/ui/combobox.tsx | 20 +- .../ui/isolated-bottom-sheet-modal/index.tsx | 42 +++- .../sheet-backdrop.tsx | 5 + .../sheet-backdrop.web.tsx | 36 +++ .../src/components/ui/menu/menu-surface.tsx | 21 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 24 +- .../new-workspace/import-session-button.tsx | 50 +++++ packages/app/src/sidebar-nav/model.test.ts | 42 +++- packages/app/src/sidebar-nav/model.ts | 21 +- .../src/sidebar-nav/use-sidebar-nav-items.ts | 12 +- .../detail-level/overview/sheet.tsx | 15 +- packages/app/src/usage/balance-bar.tsx | 15 +- packages/app/src/usage/controls.tsx | 36 +-- packages/app/src/usage/copy.ts | 3 + packages/app/src/usage/display-toggle.tsx | 22 -- packages/app/src/usage/format.test.ts | 15 ++ packages/app/src/usage/format.ts | 7 +- packages/app/src/usage/index.ts | 2 +- packages/app/src/usage/meter.tsx | 74 ++++++ packages/app/src/usage/options-menu.tsx | 103 +++++++++ packages/app/src/usage/pinned.test.ts | 139 +++++++++--- packages/app/src/usage/pinned.ts | 112 +++++++--- packages/app/src/usage/sidebar-item.tsx | 210 +++++++++++++++--- packages/app/src/usage/tone.ts | 8 +- packages/app/src/usage/window-bar.tsx | 64 +----- .../app/test-stubs/gorhom-bottom-sheet.ts | 4 + packages/plugin/src/server/usage.ts | 11 + packages/protocol/src/messages.ts | 7 + .../claude-usage-source/server/usage.test.ts | 32 +++ plugins/claude-usage-source/server/usage.ts | 9 +- .../codex-usage-source/server/usage.test.ts | 26 ++- plugins/codex-usage-source/server/usage.ts | 23 +- plugins/grok-usage-source/server/usage.ts | 1 + plugins/muse-provider/server/usage.ts | 4 + .../server/usage.test.ts | 2 + .../opencode-go-usage-source/server/usage.ts | 10 +- 65 files changed, 1292 insertions(+), 527 deletions(-) create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx create mode 100644 packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx create mode 100644 packages/app/src/screens/new-workspace/import-session-button.tsx delete mode 100644 packages/app/src/usage/display-toggle.tsx create mode 100644 packages/app/src/usage/format.test.ts create mode 100644 packages/app/src/usage/meter.tsx create mode 100644 packages/app/src/usage/options-menu.tsx diff --git a/docs/floating-panels.md b/docs/floating-panels.md index b0c16b3e4a..9c4e793fb8 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This +includes native centered dialogs: separate React Native Modals cannot present a root-owned +sibling dialog over an existing iOS presenter. + +The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary +but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, +and restoration then silently do nothing. Callers may use callback refs on the wrapper, which +forwards commands to the current Gorhom handle. + +Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, +Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated +pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop +keeps its press target until portal teardown and asks the provider to dismiss the top modal. +Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native +retains Gorhom's gesture backdrop and the same push/replace presentation semantics. + ## Gotcha 7 — A sheet cannot read context from its call site React cannot copy contexts reflectively, so the only way across the teleport in diff --git a/docs/glossary.md b/docs/glossary.md index 03b65db022..512068ece8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. -- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. +- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/docs/menus.md b/docs/menus.md index 3a6829d738..6791d28e71 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -191,6 +191,8 @@ its own. [floating-panels.md](floating-panels.md). - **One overlay per menu.** Submenus render inside their parent's layer and paint no second backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. +- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only + request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes 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 diff --git a/docs/plugins.md b/docs/plugins.md index 91c0841ffe..b4471d10e7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -478,7 +478,7 @@ one section's order from built-ins, plugin groups, and the section's preference - The item's `Component` renders directly in the section, with no wrapper, so a fragment or array of rows lays out like separate items while Settings keeps one entry for the block. Footer items - are rows between Add project and the footer's icon row. The icon row (Hosts, Import session, + are rows between Add project and the footer's icon row. The icon row (Hosts, Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon button. - `openPopover` opens through the same `PluginPopoverSurface` as header buttons diff --git a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 967462f05b..89ee10af22 100644 --- a/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -84,6 +84,18 @@ async function openModelSelector(page: Page) { await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 }); } +async function dismissStackedModelPickerWithBackdrop(page: Page) { + await page + .getByTestId("agent-controls-settings-list") + .getByRole("button", { name: /Select model/ }) + .click(); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible(); + await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24); + await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible(); + await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible(); + await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible(); +} + async function openAndCloseTabSwitcherTwice(page: Page) { await openTabSwitcher(page); await closeBottomSheetWithBackdrop(page); @@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => { await openModelSelector(page); const sheet = page.getByTestId("agent-controls-model-sheet"); + await dismissStackedModelPickerWithBackdrop(page); + await page.getByTestId("model-search-all-input").click(); const model = page.getByRole("button", { name: /^Ten second stream/ }); await expect(model).toBeVisible({ diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 592d31c330..788a6f93ec 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => { await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 }); const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await flow.revealMobileEntryPoint(); await flow.openGlobally(); const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`); @@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); - await test.step("the mobile sidebar exposes import in its footer", async () => { - await flow.revealMobileEntryPoint(); - await capture(page, testInfo, "01-mobile-sidebar-footer.png"); + await test.step("the new workspace screen offers import at the top", async () => { + await flow.revealNewWorkspaceEntryPoint(); + await capture(page, testInfo, "01-mobile-new-workspace-import.png"); }); await test.step("the host-wide sheet is newest first and fits its provider filter", async () => { - await flow.openGlobally(); + await flow.openFromNewWorkspace(); await flow.expectScope("Sessions on", false); await flow.expectRows({ first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"], diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 038ea63f95..ae7919e952 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -31,13 +31,13 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; -test("fixed footer line keeps the labeled Add project button and four icons", async ({ page }) => { +test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]); await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await expectFooterIconRow(page); await expectFooterSeparator(page, true); await footerScreenshot(page, "footer-desktop-with-rows"); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 78deb7835f..c7de5261aa 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -215,7 +215,7 @@ test.describe("usage screen", () => { }); // Nothing picked and no workspace open: the first host. - await expectPinnedUsage(page, ["31%"]); + await expectPinnedUsage(page, ["31% Weekly"]); await usageItem(page).click(); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); @@ -226,7 +226,7 @@ test.describe("usage screen", () => { ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); await qaScreenshot(page, "usage-screen-picked-host"); - await expectPinnedUsage(page, ["12%"]); + await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. await addConnectedHostAndReload(page, { @@ -234,7 +234,7 @@ test.describe("usage screen", () => { label: "Secondary box", port: secondary.port, }); - await expectPinnedUsage(page, ["12%"]); + await expectPinnedUsage(page, ["12% Weekly"]); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 1c590c38be..5ad817aeb1 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -7,6 +7,7 @@ import { installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; +import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; const ICON = ''; @@ -89,7 +90,7 @@ test.describe("usage settings", () => { await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); - await card.getByTestId("usage-display-remaining").click(); + await showUsageAs(page, "remaining"); await expect(card.getByText("30% left")).toBeVisible(); await expect(card.getByText("93% left")).toBeVisible(); }); @@ -117,7 +118,7 @@ test.describe("usage settings", () => { const card = page.getByTestId("usage-card"); await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 }); - await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await refreshAllUsage(page); await expect.poll(() => forcedRefreshCount(usage)).toBe(1); await expect(card.getByText("64%")).toBeVisible(); }); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index e09e4d22c6..124eaca5f6 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -4,7 +4,11 @@ import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; -import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings"; +import { + leaveSettings, + openSidebarNavSettings, + setFooterItemVisible, +} from "../support/helpers/sidebar-nav-settings"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, @@ -13,11 +17,15 @@ import { leaveUsageScreen, openCompactSidebar, pinRow, + openUsageOptions, showUsageAs, togglePin, usageItem, usageSheet, + installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { waitForSettledPosition } from "../support/helpers/sheet-layout"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -47,6 +55,32 @@ async function footerClip(page: Page) { } test.describe("Usage item", () => { + test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + page, + }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(COMPACT); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await openCompactSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await usageItem(page).click(); + const content = page.getByTestId("sidebar-usage-sheet-content"); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + const bounds = (await content.boundingBox())!; + expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); + await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + } finally { + await source.cleanup(); + } + }); test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -59,15 +93,20 @@ test.describe("Usage item", () => { await test.step("a fresh device shows default windows, which opens the Usage screen", async () => { await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); await openSidebarNavSettings(page); await qaScreenshot(page, "desktop-settings-sidebar-footer"); await page.setViewportSize(COMPACT); + // Phones start with the Usage item off; turning it on stores the choice. + await expect( + page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"), + ).toHaveAttribute("aria-checked", "false"); + await setFooterItemVisible(page, "usage", true); await qaScreenshot(page, "compact-settings-sidebar-footer"); await gotoAppShell(page); await openCompactSidebar(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "compact-footer-defaults"); await page.setViewportSize(WIDE); await gotoAppShell(page); @@ -82,12 +121,12 @@ test.describe("Usage item", () => { 0, ); await togglePin(screen, "Claude", "Session"); - await expectPinnedUsage(page, ["31%"]); + await expectPinnedUsage(page, ["31% 5h"]); await togglePin(screen, "Codex", "Weekly"); - await expectPinnedUsage(page, ["31%", "12%"]); + await expectPinnedUsage(page, ["31% 5h", "12% wk"]); await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); - await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Usage options", exact: true })).toBeVisible(); await qaScreenshot(page, "usage-screen-header", { kind: "element", locator: page.getByTestId("page-title").locator(".."), @@ -100,7 +139,7 @@ test.describe("Usage item", () => { await test.step("remaining flips the Usage item and the Usage screen", async () => { await showUsageAs(page, "remaining"); - await expectPinnedUsage(page, ["69%", "88%"]); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); await expect( screen.getByTestId("usage-report-claude:default").getByText("69% left"), @@ -123,7 +162,7 @@ test.describe("Usage item", () => { await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); - await expectPinnedUsage(page, ["69%", "88%"]); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); await usageItem(page).click(); const sheet = usageSheet(page); @@ -136,7 +175,18 @@ test.describe("Usage item", () => { /^Pin Claude Session, \d+% left( · .+)?$/, ); // The sheet carries the Usage screen's controls. - await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); + await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible(); + // The options open as a sheet of their own over the usage sheet. + await openUsageOptions(page); + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-checked", + "true", + ); + await qaScreenshot(page, "compact-options-sheet"); + // Tap the backdrop above both sheets; only the options sheet closes. + await page.mouse.click(COMPACT.width / 2, 40); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); + await expect(sheet).toBeVisible(); await expect(page).not.toHaveURL(/\/usage$/); const sheetBox = (await sheet.boundingBox())!; expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3); @@ -153,10 +203,14 @@ test.describe("Usage item", () => { await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); - await expectPinnedUsage(page, ["69%", "88%"]); - await expect( - page.locator('[data-testid="usage-display-remaining"]:visible').first(), - ).toHaveAttribute("aria-selected", "true"); + await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageOptions(page); + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( + "aria-checked", + "true", + ); + await qaScreenshot(page, "usage-options-menu"); + await page.keyboard.press("Escape"); }); await test.step("the Settings usage section shares the pins and the toggle", async () => { @@ -173,10 +227,10 @@ test.describe("Usage item", () => { await test.step("unpinning both brings back default windows, and that survives a reload", async () => { await togglePin(screen, "Claude", "Session"); await togglePin(screen, "Codex", "Weekly"); - await expectPinnedUsage(page, ["69%", "93%"]); + await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); await page.reload(); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); - await expectPinnedUsage(page, ["69%", "93%"]); + await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]); await gotoAppShell(page); await usageItem(page).click(); await expectOnUsageScreen(page); @@ -194,6 +248,28 @@ test.describe("Usage item", () => { }); }); +test("the Usage options show and hide the sidebar Usage item", async ({ page }) => { + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); + await usageItem(page).click(); + await expectOnUsageScreen(page); + + await openUsageOptions(page); + const toggle = page.getByTestId("usage-show-in-sidebar"); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + await toggle.click(); + await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0); + + await page.reload(); + await expectOnUsageScreen(page); + await openUsageOptions(page); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await toggle.click(); + await expect(usageItem(page)).toBeVisible(); +}); + test("released hosts supply source logos through the client conversion", async ({ page }) => { await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()], @@ -201,12 +277,12 @@ test("released hosts supply source logos through the client conversion", async ( }); await page.setViewportSize(WIDE); await gotoAppShell(page); - await expectPinnedUsage(page, ["31%", "7%"]); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); // Source logos are decorative SVGs with no accessible role. Their path data distinguishes // the source artwork from the fallback gauge. const claudePath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!; const codexPath = /]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!; - const summary = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + const summary = usageItem(page).getByTestId("sidebar-usage-source"); await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 0bf2fc16e9..718b0d5965 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { gotoAppShell } from "./app"; import { openCommandCenter } from "./command-center"; +import { openGlobalNewWorkspaceComposer } from "./new-workspace"; import { getServerId } from "./server-id"; import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar"; @@ -18,16 +19,24 @@ export class ImportSessionFlow { timeout: 30_000, }); } - async revealMobileEntryPoint() { - await openMobileAgentSidebar(this.page); - await expectMobileAgentSidebarVisible(this.page); - const button = this.page.getByTestId("sidebar-import-session"); + /** The new workspace screen's Import session button, reached from the sidebar. */ + async revealNewWorkspaceEntryPoint() { + const compact = (this.page.viewportSize()?.width ?? 0) < 768; + if (compact) { + await openMobileAgentSidebar(this.page); + await expectMobileAgentSidebarVisible(this.page); + } + await openGlobalNewWorkspaceComposer(this.page); + const button = this.page.getByTestId("new-workspace-import-session"); await expect(button).toHaveAccessibleName("Import session"); await expect(button).toBeInViewport(); } async openGlobally() { - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); - await this.page.getByTestId("sidebar-import-session").click(); + await this.revealNewWorkspaceEntryPoint(); + await this.openFromNewWorkspace(); + } + async openFromNewWorkspace() { + await this.page.getByTestId("new-workspace-import-session").click(); await this.expectSheetReady(); } async openFromWorkspaceHeader() { @@ -108,8 +117,12 @@ export class ImportSessionFlow { }); } async expectTranscript(userText: string, assistantText: string) { - await expect(this.page.getByTestId("user-message")).toContainText(userText); - await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText); + await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); + await expect( + this.page.getByTestId("assistant-message").filter({ visible: true }), + ).toContainText(assistantText); } async showAll() { await this.page.getByTestId("import-session-show-all").click(); @@ -120,7 +133,9 @@ export class ImportSessionFlow { timeout: 30_000, }); const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`); - await expect(workspace.getByTestId("user-message")).toContainText(userText); + await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText( + userText, + ); } async close() { await this.page.keyboard.press("Escape"); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index dace580491..63034d52a3 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer( } export async function openGlobalNewWorkspaceComposer(page: Page): Promise { - await page.getByTestId("sidebar-global-new-workspace").click(); + // The shell keeps a hidden copy of the other layout's sidebar mounted. + await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click(); await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000, diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b17b7b6bfb..6631d839cb 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -220,32 +220,32 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise { - const boxes = await Promise.all( - FOOTER_ICON_TEST_IDS.map((testID) => - page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), - ), - ); - const [first, ...rest] = boxes.map((box) => box!); - expect(first.width).toBeGreaterThan(rest[0]!.width); - await expect( - page - .locator('[data-testid="sidebar-add-project"]:visible') - .getByText("Add project", { exact: true }), - ).toBeVisible(); - let previous = first; - for (const box of rest) { - expect(Math.abs(box.y + box.height / 2 - first.y - first.height / 2)).toBeLessThan(2); - expect(box.x).toBeGreaterThan(previous.x); - previous = box; + const boxes = ( + await Promise.all( + FOOTER_ICON_TEST_IDS.map((testID) => + page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), + ), + ) + ).map((box) => box!); + const [first] = boxes; + for (const box of boxes) { + expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2); + expect(box.width).toBe(first!.width); } + const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width)); + expect(gaps).toEqual([0, 0, expect.any(Number), 0]); + expect(gaps[2]).toBeGreaterThan(first!.width); } export async function expectFooterSeparator(page: Page, shown: boolean): Promise { diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index dc65b63e08..19dbeecdd0 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -163,7 +163,6 @@ class StartupAssertions { await expect(hostRow).toBeVisible({ timeout: 15_000 }); await expect(hostRow).toContainText(input.label); await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible(); - await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); await expect(this.page.getByTestId("sidebar-settings")).toBeVisible(); await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0); return this; diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 342045cd1d..56d4455099 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -1,7 +1,59 @@ import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; 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"; + +/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +export async function installTallUsageSource() { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const cleanup = async () => { + try { + await client.removePlugin("tall-usage"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "tall-usage", + requirements: pluginRequirements, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { z } from "zod"; +export default function contribute(server) { + server.registerUsageSource({ + id: "tall-usage", label: "Scrolling account", input: z.object({}), + discover: async () => [{}], + identify: async () => ({ key: "scrolling-account" }), + fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({ + id: String(i), label: "Window " + (i + 1), usedPct: 25, + })) }), + }); + return () => {}; +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: directory }); + return { cleanup }; + } catch (error) { + await cleanup(); + throw error; + } +} const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins"); @@ -28,8 +80,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] { status: "available", planLabel: "Max", windows: [ - { id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() }, - { id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() }, + { + id: "five_hour", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 31, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 54, + resetsAt: inOneDay(), + }, ], }, }, @@ -44,8 +110,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] { status: "available", planLabel: "Pro", windows: [ - { id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() }, - { id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() }, + { + id: "session", + label: "Session", + shortLabel: "5h", + summary: true, + usedPct: 7, + resetsAt: inOneDay(), + }, + { + id: "weekly", + label: "Weekly", + shortLabel: "wk", + summary: true, + usedPct: 12, + resetsAt: inOneDay(), + }, ], }, }, @@ -71,9 +151,10 @@ export async function expectOnUsageScreen(page: Page): Promise { await expect(page).toHaveURL(/\/usage$/); } -export async function expectPinnedUsage(page: Page, percents: string[]): Promise { - const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window"); - await expect(windows).toHaveText(percents); +/** Each pinned window as it reads: its percent and short label, "31% 5h". */ +export async function expectPinnedUsage(page: Page, windows: string[]): Promise { + const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window"); + await expect(pinned).toHaveText(windows); } /** Without pinned windows the Usage item is a plain row that reads "Usage". */ @@ -95,8 +176,21 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") { - await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click(); +/** The usage title row's options menu: Refresh and Used/Remaining. */ +export async function openUsageOptions(page: Page): Promise { + await page.locator('[data-testid="usage-options-menu"]:visible').first().click(); + await expect(page.getByTestId("usage-display-used")).toBeVisible(); +} + +export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { + await openUsageOptions(page); + await page.getByTestId(`usage-display-${displayAs}`).click(); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); +} + +export async function refreshAllUsage(page: Page): Promise { + await openUsageOptions(page); + await page.getByRole("menuitem", { name: "Refresh", exact: true }).click(); } /** Opens the compact sidebar drawer. */ diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index e909bf28e5..607f24c214 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - BottomSheetBackdrop, - BottomSheetFlatList, - type BottomSheetFlatListMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -718,12 +714,6 @@ export function CommandCenter() { layer: modalLayer, onKeyDown: handleWebOverlayKeyDown, }); - const backdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); if (showBottomSheet) { return ( @@ -735,7 +725,7 @@ export function CommandCenter() { enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={backdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundStyle={styles.sheetBackground} handleIndicatorStyle={styles.sheetHandle} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 78cdfd2e7a..4ceb43d917 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -13,7 +13,6 @@ import { useWebOverlayRegistration, } from "../lib/overlay-root"; import { - BottomSheetBackdrop, KEYBOARD_STATUS, useBottomSheetInternal, type BottomSheetBackgroundProps, @@ -550,13 +549,6 @@ export function AdaptiveModalSheet({ onDismiss?.(); }, [handleSheetDismiss, onDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const desktopCardStyle = useMemo( () => [ styles.desktopCard, @@ -649,7 +641,7 @@ export function AdaptiveModalSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} handleIndicatorStyle={handleIndicatorStyle} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index c2bff3febc..8095497ec7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; +import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -8,7 +8,6 @@ import { Text, useWindowDimensions, View, - type PressableStateCallbackType, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -45,6 +44,7 @@ import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useHosts } from "@/runtime/host-runtime"; import { PluginSidebarItem } from "@/plugins/sidebar-items"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; @@ -52,7 +52,7 @@ 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 } from "@/usage"; +import { UsageSidebarItem, useOpenUsageScreen } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -89,9 +89,9 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; closeSidebar: string; } @@ -196,9 +196,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), hosts: t("sidebar.actions.hosts"), - importSession: t("importSession.title"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), + usage: t(builtinSidebarNavLabelKey("usage")), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], @@ -283,6 +283,7 @@ function FooterIconButton({ testID: string; label: string; icon: typeof FolderPlus; + /** Only for a glyph that reads larger than the others at the same size. */ iconSize?: number; shortcutKeys?: ReturnType; theme: SidebarTheme; @@ -317,64 +318,6 @@ function FooterIconButton({ ); } -function footerAddProjectButtonStyle({ - hovered, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered]; -} - -function FooterAddProjectButton({ - onPress, - label, - shortcutKeys, - theme, -}: { - onPress: () => void; - label: string; - shortcutKeys: ReturnType; - theme: SidebarTheme; -}) { - return ( - - - - {({ hovered }) => { - const isHovered = Boolean(hovered); - return ( - <> - - - {label} - - - ); - }} - - - - - - - ); -} - function SidebarHostPicker({ theme, label, @@ -423,7 +366,8 @@ function SidebarHostPicker({ testID="sidebar-hosts-trigger" label={label} icon={Server} - iconSize={theme.iconSize.sm} + // Server's two boxes fill more of the square than the other glyphs. + iconSize={theme.iconSize.md - 1} theme={theme} /> @@ -448,7 +392,6 @@ function IconTooltipContent({ function SidebarFooter({ theme, handleOpenProject, - handleImportSession, handleSettings, labels, handleAddHost, @@ -457,14 +400,13 @@ function SidebarFooter({ }: { theme: SidebarTheme; handleOpenProject: () => void; - handleImportSession: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; - importSession: string; settings: string; searchHosts: string; + usage: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; @@ -472,41 +414,44 @@ 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 ( - - - - - - - + + + + + ); @@ -593,7 +538,11 @@ function MobileSidebar({ - + ({ right: 0, zIndex: 2, alignItems: "flex-end", - pointerEvents: "box-none", }, mobileCloseButton: { // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. @@ -971,48 +917,22 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: 1, borderTopColor: theme.colors.border, }, + // The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs. sidebarFooter: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], - }, - footerIconRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - footerAddProjectButton: { - minWidth: 0, - minHeight: 32, - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], paddingVertical: theme.spacing[1.5], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, }, - footerAddProjectButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, + // Pushes Help and Settings to the end of the icon line. + footerSpacer: { + flex: 1, }, - footerAddProjectLabel: { - minWidth: 0, - flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - footerAddProjectLabelHovered: { - color: theme.colors.foreground, - }, - // Usage and plugin rows sit above the restored footer line. + // Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows. footerRows: { paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[3], - gap: theme.spacing[1], + paddingVertical: theme.spacing[1.5], + gap: 2, }, footerIconButton: { width: 28, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index f59672d388..4519f6d4ff 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -89,7 +89,7 @@ export function SidebarHeaderRow({ accessibilityLabel={accessibilityLabel ?? label} accessibilityState={isActive ? SELECTED_STATE : undefined} aria-selected={isActive} - style={variant === "inline" ? styles.buttonInline : styles.button} + style={styles.button} > {ThemedIcon ? ( ({ // edge with the list below. paddingHorizontal: theme.spacing[2], }, - // Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button. - buttonInline: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 28, - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1.5], - }, iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, label: { diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 022a9bc7c2..33009ee376 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { @@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) setSheetData(null); }, [handleSheetDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const contextValue = useMemo( () => ({ openToolCall, closeToolCall }), [openToolCall, closeToolCall], @@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleToolCallSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > {sheetData && } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 74748e423a..736ca3bfa5 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,11 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - BottomSheetScrollView, - BottomSheetBackdrop, - BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; @@ -948,18 +944,6 @@ interface MobileBodyProps { } function MobileComboboxBody(props: MobileBodyProps): ReactElement { - const renderBackdrop = useCallback( - (backdropProps: React.ComponentProps) => ( - - ), - [], - ); - const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], @@ -991,7 +975,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { enableDynamicSizing={false} onChange={props.handleSheetChange} onDismiss={props.handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={ComboboxSheetBackground} handleIndicatorStyle={props.handleIndicatorStyle} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index f7869823b8..3f9c9113d0 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -1,9 +1,10 @@ import { BottomSheetModal as GorhomBottomSheetModal, type BottomSheetModalProps, + type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import React from "react"; -import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import type { ElementRef, ReactNode } from "react"; import { systemBackPress } from "./back-press"; import { @@ -11,6 +12,7 @@ import { createBottomSheetVisibilityTracker, } from "./visibility-tracker"; import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; +import { SheetBackdrop } from "./sheet-backdrop"; type GorhomBottomSheetModalMethods = ElementRef; @@ -36,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode; type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, - "enableDismissOnClose" | "stackBehavior" | "children" + "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge @@ -44,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit< */ children?: ReactNode; presentation?: "push" | "replace"; + /** Show a dismissible backdrop. Its interaction belongs to the modal stack. */ + backdropOpacity?: number; /** * Required, and `null` is a real answer: a sheet that needs nothing from its call site should * have to say so. The failure it prevents is invisible until someone adds a `useContext` deep @@ -58,13 +62,43 @@ export const IsolatedBottomSheetModal = forwardRef< IsolatedBottomSheetModalRef, IsolatedBottomSheetModalProps >(function IsolatedBottomSheetModal(props, ref) { - const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props; + // Gorhom puts this ref into its provider queue and reads `.current` for stack operations. + // A callback ref accepts the handle but leaves that queue unable to dismiss or restore it. + const modalRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + present: (...args) => modalRef.current?.present(...args), + dismiss: (...args) => modalRef.current?.dismiss(...args), + snapToIndex: (...args) => modalRef.current?.snapToIndex(...args), + snapToPosition: (...args) => modalRef.current?.snapToPosition(...args), + expand: (...args) => modalRef.current?.expand(...args), + collapse: (...args) => modalRef.current?.collapse(...args), + close: (...args) => modalRef.current?.close(...args), + forceClose: (...args) => modalRef.current?.forceClose(...args), + }), + [], + ); + const { + children, + presentation = "push", + contextBridge, + backdropOpacity, + ...bottomSheetProps + } = props; + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [backdropOpacity], + ); const modal = ( {contextBridge ? contextBridge(children) : children} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx new file mode 100644 index 0000000000..1232566462 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -0,0 +1,5 @@ +import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + return ; +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx new file mode 100644 index 0000000000..ba1e397a9e --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -0,0 +1,36 @@ +import { + BottomSheetBackdrop, + useBottomSheetModal, + type BottomSheetBackdropProps, +} from "@gorhom/bottom-sheet"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, StyleSheet, View } from "react-native"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + const { dismiss } = useBottomSheetModal(); + const { t } = useTranslation(); + const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + + return ( + + + + + ); +} diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3a2729ad8e..0b5a6a77ac 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -5,14 +5,13 @@ import { useEffect, useMemo, useRef, - type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -345,6 +344,7 @@ function MenuSheetSurface({ testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { + const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); const safeAreaInsets = useSafeAreaInsets(); @@ -370,18 +370,6 @@ function MenuSheetSurface({ onClose: handleClose, }); - const renderBackdrop = useCallback( - (backdropProps: ComponentProps) => ( - - ), - [], - ); - const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; @@ -405,9 +393,10 @@ function MenuSheetSurface({ // Content-sized rather than fixed snap points: a pushed page is rarely the same height // as the page it replaced, and a fixed sheet would either clip it or leave dead space. enableDynamicSizing + maxDynamicContentSize={windowHeight * 0.8} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose // `interactive` rather than `extend`, which is what every other sheet in the app uses. // `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 07f3a6d00d..d29de2b33d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -26,6 +26,7 @@ export const ar: TranslationResources = { total: "{{total}} تطابقات", }, common: { + bottomSheetBackdrop: "خلفية اللوحة السفلية", back: "خلف", loading: "تحميل...", actions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9230ef3155..1e2d4c3f10 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -22,6 +22,7 @@ export const en = { total: "{{total}} matches", }, common: { + bottomSheetBackdrop: "Bottom sheet backdrop", back: "Back", loading: "Loading...", actions: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c61778633d..cc37432b5b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -27,6 +27,7 @@ export const es: TranslationResources = { total: "{{total}} coincidencias", }, common: { + bottomSheetBackdrop: "Fondo del panel inferior", back: "Atrás", loading: "Cargando...", actions: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6e6999e5f8..aaf2608f9e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -27,6 +27,7 @@ export const fr: TranslationResources = { total: "{{total}} résultats", }, common: { + bottomSheetBackdrop: "Arrière-plan du panneau inférieur", back: "Dos", loading: "Chargement...", actions: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 8e59c63234..3491b507a2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -27,6 +27,7 @@ export const ja: TranslationResources = { total: "{{total}} 件の一致", }, common: { + bottomSheetBackdrop: "ボトムシートの背景", back: "戻る", loading: "読み込み中...", actions: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0f9437720d..7afe54b158 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -26,6 +26,7 @@ export const ko: TranslationResources = { total: "일치 항목 {{total}}개", }, common: { + bottomSheetBackdrop: "하단 시트 배경", back: "뒤로", loading: "불러오는 중...", actions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 75249797a2..a08088b2e4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -27,6 +27,7 @@ export const ptBR: TranslationResources = { total: "{{total}} correspondências", }, common: { + bottomSheetBackdrop: "Fundo do painel inferior", back: "Voltar", loading: "Carregando...", actions: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index af11db8bf1..4526784896 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -27,6 +27,7 @@ export const ru: TranslationResources = { total: "Совпадений: {{total}}", }, common: { + bottomSheetBackdrop: "Фон нижней панели", back: "Назад", loading: "Загрузка...", actions: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3bc8727cb4..d13007dbea 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -26,6 +26,7 @@ export const zhCN: TranslationResources = { total: "{{total}} 个匹配项", }, common: { + bottomSheetBackdrop: "底部面板背景", back: "返回", loading: "加载中...", actions: { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 9e0d25144c..c919ab476d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -122,6 +122,8 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { ImportSessionButton } from "./new-workspace/import-session-button"; +import { useImportSession } from "@/hooks/use-import-session"; import { buildTerminalsQueryKey, type ListTerminalsPayload, @@ -2367,6 +2369,7 @@ export function NewWorkspaceScreen({ }); const screenHeaderLeft = useMemo(() => , []); + const importSession = useImportSession({ serverId: selectedServerId }); const composer = isTerminalLaunch ? ( {composer} {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } @@ -2448,15 +2453,20 @@ function NewWorkspaceLayout({ isCompact, title, formStack, + onImportSession, children, }: { isCompact: boolean; title: string; formStack: ReactNode; + onImportSession: () => void; children: ReactNode; }) { + // At the top of the screen on compact layouts, under the composer otherwise. + const importSessionButton = ; const setupFields = ( <> + {isCompact ? {importSessionButton} : null} {title} @@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({ return ( {setupFields} - {children} + <> + {children} + {isCompact ? null : importSessionButton} + ); } @@ -2481,6 +2494,12 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", flex: 1, }, + // Takes the free space above the setup fields, so its button sits at the top of the screen. + // The inset puts the ghost button's icon on the setup rows' icon rail. + compactTopActions: { + flex: 1, + paddingHorizontal: theme.spacing[3], + }, composerTitleContainer: { marginBottom: theme.spacing[8], paddingLeft: theme.spacing[6], @@ -2503,7 +2522,8 @@ const styles = StyleSheet.create((theme) => ({ formStackDesktop: { flexDirection: "row", alignItems: "center", - marginBottom: theme.spacing[8], + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], // The badge adds its own left padding; offset it so the project icon's left // edge lands exactly on the "New workspace" title's left edge. The trailing // inset mirrors it so the launch chip stops on the composer's inner content diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx new file mode 100644 index 0000000000..39b55e1b2e --- /dev/null +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -0,0 +1,50 @@ +import { Import } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; + +/** + * The new workspace screen's way into Import session: a ghost button at the top on compact + * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. + */ +export function ImportSessionButton({ + compact, + onPress, +}: { + compact: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compact: { + alignItems: "flex-start", + }, + // The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's + // border lines up with the composer's border. + wide: { + alignItems: "flex-start", + paddingHorizontal: theme.spacing[4], + }, + pill: { + borderRadius: theme.borderRadius.full, + borderColor: theme.colors.borderAccent, + }, +})); diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index 4211a44410..f58acba007 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -34,6 +34,7 @@ describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban, notes], preferences: [], }); @@ -58,6 +59,7 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -79,6 +81,7 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -98,6 +101,7 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -114,10 +118,26 @@ describe("resolveSidebarNavItems", () => { }); }); +describe("resolveSidebarNavItems on compact layouts", () => { + it("hides the Usage item until it is turned on", () => { + const resolve = (compact: boolean, preferences: SidebarNavPreference[]) => + summarize( + resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }), + ); + + expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]); + expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]); + expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([ + { key: "usage", visible: true }, + ]); + }); +}); + describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban], preferences: [], }); @@ -140,6 +160,7 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: previous, }); @@ -165,6 +186,7 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [], preferences: previous, }); @@ -180,13 +202,23 @@ describe("setSidebarNavItemVisible", () => { ]); expect( summarize( - resolveSidebarNavItems({ section: "header", pluginGroups: [notes], preferences: next }), + resolveSidebarNavItems({ + section: "header", + compact: false, + pluginGroups: [notes], + preferences: next, + }), ), ).toEqual(next); }); it("returns the normalized list unchanged for an unknown key", () => { - const items = resolveSidebarNavItems({ section: "header", pluginGroups: [], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + compact: false, + pluginGroups: [], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] }); @@ -197,6 +229,7 @@ describe("setSidebarNavItemVisible", () => { describe("moveSidebarNavItem", () => { const items = resolveSidebarNavItems({ section: "header", + compact: false, pluginGroups: [kanban], preferences: [], }); @@ -274,6 +307,7 @@ describe("footer section", () => { it("resolves the Usage item first, then plugin rows", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: [], }); @@ -287,6 +321,7 @@ describe("footer section", () => { it("ignores header built-ins stored in footer preferences", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [], preferences: [{ key: "history", visible: false }], }); @@ -297,6 +332,7 @@ describe("footer section", () => { it("ignores the footer icon buttons, which are fixed and not items", () => { const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: [ { key: "add-project", visible: false }, @@ -319,6 +355,7 @@ describe("footer section", () => { const previous: SidebarNavPreference[] = [notesPreference]; const items = resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: previous, }); @@ -327,6 +364,7 @@ describe("footer section", () => { const hidden = setSidebarNavItemVisible({ items: resolveSidebarNavItems({ section: "footer", + compact: false, pluginGroups: [sync], preferences: moved, }), diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index 40e4db636f..b5da97c034 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -5,7 +5,7 @@ export type SidebarSection = PluginSidebarSection; /** * Each section's built-in items in their default order. The footer's bottom line - * (Add project and the Hosts, Import session, Help and support, Settings icons) is fixed. + * (Add project and the Hosts, Help and support, Settings icons) is fixed. */ export const BUILTIN_SIDEBAR_ITEM_IDS = { header: ["new-workspace", "history", "search", "schedules"], @@ -69,6 +69,16 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin return BUILTIN_SHORTCUT_ACTIONS[id]; } +/** + * Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer + * has no room to spare for the Usage summary. + */ +const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet = new Set(["usage"]); + +function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean { + return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id)); +} + export function pluginSidebarNavKey( group: Pick, ): string { @@ -85,6 +95,8 @@ function isBuiltinSidebarItemId
( export function resolveSidebarNavItems
(input: { section: Section; + /** Compact layouts start some builtins hidden; a stored preference always wins. */ + compact: boolean; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; }): SidebarNavItem
[] { @@ -115,7 +127,12 @@ export function resolveSidebarNavItems
(input: { for (const id of builtinIds) { if (placed.has(id)) continue; - items.push({ kind: "builtin", key: id, id, visible: true }); + items.push({ + kind: "builtin", + key: id, + id, + visible: builtinVisibleByDefault(id, input.compact), + }); } for (const [key, group] of groupsByKey) { if (placed.has(key)) continue; diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index dcebf670df..96b99a6f1b 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useAppSettings } from "@/hooks/use-settings"; import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -27,14 +28,15 @@ export function useSidebarNavItems
( section: Section, ): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); + const compact = useIsCompactFormFactor(); const { settings, updateSettings } = useAppSettings(); const field = PREFERENCE_FIELDS[section]; const preferences = settings[field]; const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => resolveSidebarNavItems({ section, pluginGroups, preferences }), - [pluginGroups, preferences, section], + () => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }), + [compact, pluginGroups, preferences, section], ); const setVisible = useCallback( @@ -43,6 +45,7 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, + compact, pluginGroups, preferences: previous, }); @@ -51,7 +54,7 @@ export function useSidebarNavItems
( }; }); }, - [field, pluginGroups, section, updateSettings], + [compact, field, pluginGroups, section, updateSettings], ); const move = useCallback( @@ -60,6 +63,7 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, + compact, pluginGroups, preferences: previous, }); @@ -68,7 +72,7 @@ export function useSidebarNavItems
( }; }); }, - [field, pluginGroups, section, updateSettings], + [compact, field, pluginGroups, section, updateSettings], ); return { items, setVisible, move }; diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index 77990b3341..b3b5bf39eb 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, - type BottomSheetScrollViewMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet"; import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({ visible, onClose, }); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); + const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 8bf3ad9d83..751193eec0 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; @@ -9,19 +10,20 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { const { used, remaining, limit, unit } = balance; + const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null; - const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—"; - return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct }; + const usedText = usedAmount != null ? format(usedAmount) : "—"; + return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null }; + return { amountText: `${format(remaining)} left`, usedPct: null }; } if (used != null) { - return { amountText: formatAmount(used, unit), usedPct: null }; + return { amountText: format(used), usedPct: null }; } return { amountText: "—", usedPct: null }; } @@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) { } export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - const { amountText, usedPct } = resolveBalance(balance); + const { i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index b7942fddd9..3fc29dd3e8 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -1,22 +1,13 @@ -import { RotateCw } from "lucide-react-native"; import { View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { HostFilter } from "@/components/hosts/host-filter"; -import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useMemo, type ReactElement } from "react"; -import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { UsageDisplayToggle } from "./display-toggle"; +import { UsageOptionsMenu } from "./options-menu"; import type { UsageHost } from "./model"; import { useHostUsage } from "./queries"; import type { UsageView } from "./types"; -const ThemedRotateCw = withUnistyles(RotateCw); -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); - /** The hosts to choose between, and which one is shown. */ export interface UsageHostSelection { hosts: UsageHost[]; @@ -26,8 +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, the used/remaining toggle and Refresh. A host that cannot report usage keeps only the - * host filter. + * host, and the options menu. A host that cannot report usage keeps only the host filter. */ export function UsageControls({ view, @@ -41,8 +31,6 @@ export function UsageControls({ hostSelection?: UsageHostSelection; }) { const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - const compact = useIsCompactFormFactor(); - const iconSize = paneContentToolbarIconSize(compact); return ( {hostSelection && hostSelection.hosts.length > 1 ? ( @@ -56,23 +44,7 @@ export function UsageControls({ /> ) : null} {view.kind === "unavailable" ? null : ( - <> - - {/* The Changes panel's refresh: an icon, with the label in its tooltip. */} - - {busy ? ( - - ) : ( - - )} - - + )} ); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 5d84805ea2..731450f30d 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -3,6 +3,7 @@ export const usageCopy = { title: "Usage", planUsage: "Plan usage", + options: "Usage options", refresh: "Refresh", refreshing: "Refreshing...", refreshFailed: "Unable to refresh usage", @@ -16,6 +17,8 @@ export const usageCopy = { clientUnavailable: "Host connection is not ready", retry: "Try again", pin: "Pin", + displayAs: "Show", displayUsed: "Used", displayRemaining: "Remaining", + showInSidebar: "Show in sidebar", } as const; diff --git a/packages/app/src/usage/display-toggle.tsx b/packages/app/src/usage/display-toggle.tsx deleted file mode 100644 index f778172b7c..0000000000 --- a/packages/app/src/usage/display-toggle.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; -import { usageCopy } from "./copy"; -import type { UsageDisplay } from "./display"; -import type { UsageDisplayAs } from "./preferences"; - -const OPTIONS: SegmentedControlOption[] = [ - { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, - { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, -]; - -/** Switches every usage surface between the share used and the share left. */ -export function UsageDisplayToggle({ display }: { display: UsageDisplay }) { - return ( - - ); -} diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts new file mode 100644 index 0000000000..88fe227ae6 --- /dev/null +++ b/packages/app/src/usage/format.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount } from "./format"; + +describe("formatAmount", () => { + it("groups thousands in the app's language", () => { + expect(formatAmount(12345, "credits", "en")).toBe("12,345"); + expect(formatAmount(12345, "requests", "en")).toBe("12,345"); + expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345"); + }); + + it("formats dollars as the language writes currency", () => { + expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); + expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); + }); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 253586c173..5adef2154d 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -34,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null return rel === "now" ? "resetting now" : `resets ${rel}`; } -export function formatAmount(value: number, unit: UsageBalanceUnit): string { +/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ +export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { switch (unit) { case "usd": - return `$${value.toFixed(2)}`; + return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": return formatTokenCount(value); default: - return value.toLocaleString(); + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index 6ab6c1447d..c94fbb01f9 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,3 +1,3 @@ export { HostUsageSection } from "./host-usage-section"; export { UsageScreen } from "./usage-screen"; -export { UsageSidebarItem } from "./sidebar-item"; +export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item"; diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx new file mode 100644 index 0000000000..37842cafb5 --- /dev/null +++ b/packages/app/src/usage/meter.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct } from "./format"; +import type { UsageTone } from "./types"; + +function fillToneStyle(tone: UsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +/** + * A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead, + * for summaries where a row of coloured bars would be noise. The caller sizes the track. + */ +export function UsageMeter({ + percent, + tone, + quiet = false, + style, +}: { + percent: number; + tone: UsageTone; + quiet?: boolean; + style?: StyleProp; +}) { + const fillWidth = clampPct(percent); + const fillStyle = useMemo>( + () => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }], + [fillWidth, quiet, tone], + ); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + // Follows the track, so a caller that sizes the track sizes the bar. + fill: { + height: "100%", + borderRadius: theme.borderRadius.full, + }, + fillQuiet: { + backgroundColor: theme.colors.surface4, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/usage/options-menu.tsx b/packages/app/src/usage/options-menu.tsx new file mode 100644 index 0000000000..05184d5d5a --- /dev/null +++ b/packages/app/src/usage/options-menu.tsx @@ -0,0 +1,103 @@ +import { Settings } from "lucide-react-native"; +import { useCallback } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; +import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; + +const ThemedSettings = withUnistyles(Settings); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +/** + * The usage title row's options: Refresh every report, whether percents read as used or + * remaining, and whether the sidebar shows the Usage item (the same switch as Settings > Sidebar). The trigger spins while a refresh is in flight, since the menu hides the one it came + * from. + */ +export function UsageOptionsMenu({ + display, + busy, + onRefresh, +}: { + display: UsageDisplay; + busy: boolean; + onRefresh: () => void; +}) { + const compact = useIsCompactFormFactor(); + const iconSize = paneContentToolbarIconSize(compact); + const { displayAs, setDisplayAs } = display; + const showUsed = useCallback(() => setDisplayAs("used"), [setDisplayAs]); + const showRemaining = useCallback(() => setDisplayAs("remaining"), [setDisplayAs]); + const sidebarItems = useSidebarNavItems("footer"); + const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible); + const { setVisible } = sidebarItems; + const toggleInSidebar = useCallback( + () => setVisible("usage", !inSidebar), + [inSidebar, setVisible], + ); + return ( + + + {busy ? ( + + ) : ( + + )} + + + + {usageCopy.refresh} + + + {usageCopy.displayAs} + + {usageCopy.displayUsed} + + + {usageCopy.displayRemaining} + + + + {usageCopy.showInSidebar} + + + + ); +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index af6d48cb12..24ba59be7a 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { UsagePreferences } from "./preferences"; -import { resolvePinnedUsage } from "./pinned"; +import { + choosePinnedUsageLayout, + resolvePinnedUsage, + type PinnedUsageSource, + type PinnedUsageWindow, +} from "./pinned"; import type { UsageReportEntry, UsageWindow } from "./types"; function report(input: { @@ -26,8 +31,8 @@ const claude = report({ sourceLabel: "Claude", icon: "", windows: [ - { id: "five-hour", label: "5-hour", usedPct: 31 }, - { id: "weekly", label: "Weekly", usedPct: 80 }, + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 }, ], }); const codex = report({ @@ -36,6 +41,10 @@ const codex = report({ windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }], }); +function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] { + return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows); +} + function preferences( pinned: UsagePreferences["pinned"], displayAs: UsagePreferences["displayAs"] = "used", @@ -43,6 +52,26 @@ function preferences( return { displayAs, pinned, serverId: null }; } +describe("choosePinnedUsageLayout", () => { + const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 }; + + it("shows meters only when every window's meter fits beside the labels", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters"); + expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels"); + }); + + it("drops the labels before it lets the line overflow", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents"); + }); + + it("shows labels until the widths are measured", () => { + expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels"); + expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe( + "labels", + ); + }); +}); + describe("resolvePinnedUsage", () => { it("defaults to the first window with a percent for each source account", () => { const work = report({ @@ -60,57 +89,113 @@ describe("resolvePinnedUsage", () => { windows: [{ id: "empty", label: "Empty" }], }); expect( - resolvePinnedUsage([claude, codex, work, empty], preferences([])).map((item) => [ + windows([claude, codex, work, empty], preferences([])).map((item) => [ item.key, item.percentText, ]), ).toEqual([ ["claude:default/five-hour", "31%"], - ["codex:default/weekly", "12%"], ["claude:work/weekly", "90%"], + ["codex:default/weekly", "12%"], ]); }); it("pins replace defaults, and removing all pins restores defaults", () => { const reports = [claude, codex]; expect( - resolvePinnedUsage(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( + windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( (item) => item.key, ), ).toEqual(["claude:default/weekly"]); - expect(resolvePinnedUsage(reports, preferences([])).map((item) => item.key)).toEqual([ + expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([ "claude:default/five-hour", "codex:default/weekly", ]); }); - it("shows pinned windows in pin order, not report order", () => { - const items = resolvePinnedUsage( - [claude, codex], + it("groups pinned windows by source in report order, not pin order", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }], + }); + const sources = resolvePinnedUsage( + [claude, codex, work], preferences([ + { sourceId: "claude", windowId: "weekly" }, { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ]), ); - expect(items).toEqual([ - { - key: "codex:default/weekly", - icon: null, - label: "Codex Weekly 12% used", - percentText: "12%", - }, - { - key: "claude:default/five-hour", - icon: "", - label: "Claude 5-hour 31% used", - percentText: "31%", - }, + const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key); + expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([ + ["claude:default", ["claude:default/five-hour", "claude:default/weekly"]], + ["claude:work", ["claude:work/weekly"]], + ["codex:default", ["codex:default/weekly"]], + ]); + expect(sources[0]).toEqual({ + key: "claude:default", + icon: "", + windows: [ + { + key: "claude:default/five-hour", + label: "Claude Session 31% used", + shortLabel: "5h", + percent: 31, + percentText: "31%", + tone: "default", + }, + { + key: "claude:default/weekly", + label: "Claude Weekly 80% used", + shortLabel: "wk", + percent: 80, + percentText: "80%", + tone: "warning", + }, + ], + }); + }); + + it("names a window by its label when the source gives no short label", () => { + const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }])); + + expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]); + }); + + it("defaults to every window the source marks as summary, when it marks any", () => { + const marked = report({ + sourceId: "claude", + sourceLabel: "Claude", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true }, + { id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 }, + ], + }); + + expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "claude:default/weekly", + "codex:default/weekly", ]); }); + it("keeps an empty short label empty, for a percent that needs no name", () => { + const opencode = report({ + sourceId: "opencode", + sourceLabel: "OpenCode Go", + windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }], + }); + const items = windows([opencode], preferences([])); + + expect(items.map((item) => item.shortLabel)).toEqual([""]); + }); + it("formats the share left when the user reads usage as remaining", () => { - const items = resolvePinnedUsage( + const items = windows( [claude, codex], preferences( [ @@ -123,7 +208,7 @@ describe("resolvePinnedUsage", () => { expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]); expect(items.map((item) => item.label)).toEqual([ - "Claude 5-hour 69% left", + "Claude Session 69% left", "Codex Weekly 88% left", ]); }); @@ -134,7 +219,7 @@ describe("resolvePinnedUsage", () => { sourceLabel: "OpenCode", windows: [{ id: "monthly", label: "Monthly" }], }); - const items = resolvePinnedUsage( + const items = windows( [claude, noPercent], preferences([ { sourceId: "claude", windowId: "monthly" }, @@ -159,7 +244,7 @@ describe("resolvePinnedUsage", () => { account: "work", windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }], }); - const items = resolvePinnedUsage( + const items = windows( [personal, codex, work], preferences([{ sourceId: "claude", windowId: "five-hour" }]), ); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts index 11cf0ecabf..003d56d4e3 100644 --- a/packages/app/src/usage/pinned.ts +++ b/packages/app/src/usage/pinned.ts @@ -1,15 +1,53 @@ import { formatPct } from "./format"; import { displayPercent } from "./model"; import type { UsagePreferences } from "./preferences"; -import type { UsageReportEntry } from "./types"; +import { windowTone } from "./tone"; +import type { UsageReportEntry, UsageTone, UsageWindow } from "./types"; /** One summary window of one account, as the sidebar Usage item shows it. */ export interface PinnedUsageWindow { key: string; - icon: string | null; /** Accessible description, including the source, account, window and percent meaning. */ label: string; + /** The window's short name ("5h"), empty for none, or its label when the source sends none. */ + shortLabel: string; + /** The share shown, under the user's used/remaining preference. */ + percent: number; percentText: string; + tone: UsageTone; +} + +/** One account's summary windows, under its source's icon. */ +export interface PinnedUsageSource { + key: string; + icon: string | null; + windows: PinnedUsageWindow[]; +} + +/** How much of each window the one-line summary has room for. */ +export type PinnedUsageLayout = "meters" | "labels" | "percents"; + +// A meter narrower than this is a sliver that reads as noise rather than a share. +export const MIN_METER_WIDTH = 16; +/** The space between a meter and its text. */ +export const METER_GAP = 4; + +/** + * The richest layout that fits on one line: every window's meter, else its short label, else its + * percent alone. Meters are all or none; a line never mixes them. Until the widths are known it + * shows labels, which never flashes meters that turn out not to fit. + */ +export function choosePinnedUsageLayout(input: { + available: number | null; + labelsWidth: number | null; + percentsWidth: number | null; + windowCount: number; +}): PinnedUsageLayout { + const { available, labelsWidth, percentsWidth, windowCount } = input; + if (available === null || labelsWidth === null || percentsWidth === null) return "labels"; + if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters"; + if (labelsWidth <= available) return "labels"; + return percentsWidth < labelsWidth ? "percents" : "labels"; } function describe(entry: UsageReportEntry, windowLabel: string): string { @@ -17,40 +55,52 @@ function describe(entry: UsageReportEntry, windowLabel: string): string { return `${entry.sourceLabel}${account} ${windowLabel}`; } -/** Pins replace defaults; without pins, summarize each account's first window with a percent. */ +/** Reports with every account of a source together, sources in the order they first appear. */ +function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] { + const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))]; + return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId)); +} + +/** + * Pins replace defaults. Without pins, an account shows the windows its source marks as summary, + * or its first window with a percent when the source marks none. + */ +function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] { + const withPercent = entry.report.windows.filter( + (window) => displayPercent(window, preferences.displayAs) !== null, + ); + if (preferences.pinned.length === 0) { + const marked = withPercent.filter((window) => window.summary); + return marked.length > 0 ? marked : withPercent.slice(0, 1); + } + return withPercent.filter((window) => + preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), + ); +} + +/** + * The sidebar Usage item's summary: one group per account, grouped by source, each with its + * windows in its report's order. Accounts with nothing to show are left out. + */ export function resolvePinnedUsage( reports: readonly UsageReportEntry[], preferences: UsagePreferences, -): PinnedUsageWindow[] { - const selections = - preferences.pinned.length === 0 - ? reports.map((entry) => ({ - entry, - window: entry.report.windows.find( - (window) => displayPercent(window, preferences.displayAs) !== null, - ), - })) - : preferences.pinned.flatMap((pin) => - reports - .filter((entry) => entry.sourceId === pin.sourceId) - .map((entry) => ({ - entry, - window: entry.report.windows.find((window) => window.id === pin.windowId), - })), - ); - return selections.flatMap(({ entry, window }) => { - if (!window) return []; - const percent = displayPercent(window, preferences.displayAs); - if (percent === null) return []; - const percentText = formatPct(percent); - const meaning = preferences.displayAs === "remaining" ? "left" : "used"; - return [ - { +): PinnedUsageSource[] { + const meaning = preferences.displayAs === "remaining" ? "left" : "used"; + return groupBySource(reports).flatMap((entry) => { + const windows = summaryWindows(entry, preferences).map((window) => { + const percent = displayPercent(window, preferences.displayAs) ?? 0; + const percentText = formatPct(percent); + return { key: `${entry.id}/${window.id}`, - icon: entry.icon ?? null, label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + shortLabel: window.shortLabel ?? window.label, + percent, percentText, - }, - ]; + tone: windowTone(window), + }; + }); + if (windows.length === 0) return []; + return [{ key: entry.id, icon: entry.icon ?? null, windows }]; }); } diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 9b9e9a80ef..ee8f6f657b 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,8 +1,14 @@ import { router } from "expo-router"; -import { Gauge } from "lucide-react-native"; -import { useCallback, useMemo, useState } from "react"; +import { CircleGauge } from "lucide-react-native"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; @@ -16,13 +22,21 @@ import { useUsageHostId, useUsageHostSelection } from "./hosts"; import type { UsagePreferences } from "./preferences"; import { useHostUsage } from "./queries"; import { UsageSourceIcon } from "./source-icon"; -import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned"; +import { UsageMeter } from "./meter"; +import { + choosePinnedUsageLayout, + METER_GAP, + MIN_METER_WIDTH, + resolvePinnedUsage, + type PinnedUsageLayout, + type PinnedUsageSource, +} from "./pinned"; import type { UsageReportEntry } from "./types"; import type { UsageHost } from "./model"; import { UsageBody } from "./usage-section"; const NO_REPORTS: UsageReportEntry[] = []; -const NO_ITEMS: PinnedUsageWindow[] = []; +const NO_SOURCES: PinnedUsageSource[] = []; /** * The sidebar footer's usage entry: each summary window's source icon and percent, or a plain @@ -33,7 +47,7 @@ export function UsageSidebarItem() { const { preferences, display } = useUsagePreferences(); const serverId = useUsageHostId(); if (!serverId) { - return ; + return ; } return ( resolvePinnedUsage(reports, preferences), [preferences, reports]); - return ; + const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]); + return ; } -function useOpenUsageScreen(): () => void { +/** 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(() => { @@ -71,11 +86,11 @@ function useOpenUsageScreen(): () => void { function UsageEntry({ serverId, - items, + sources, display, }: { serverId: string | null; - items: readonly PinnedUsageWindow[]; + sources: readonly PinnedUsageSource[]; display: UsageDisplay; }) { const { t } = useTranslation(); @@ -97,12 +112,12 @@ function UsageEntry({ }, [openUsageScreen, usesSheet]); const trigger = - items.length > 0 ? ( - + sources.length > 0 ? ( + ) : ( item.label).join(", ")}`; +function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { + const windows = sources.flatMap((source) => source.windows); + return `${label}: ${windows.map((window) => window.label).join(", ")}`; } function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger; } +function layoutWidth(event: LayoutChangeEvent): number { + return event.nativeEvent.layout.width; +} + +/** + * One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure + * the line with labels and with percents alone; the richest layout that fits is shown, and what + * it leaves out takes no space at all. + */ function PinnedUsageTrigger({ label, - items, + sources, onPress, }: { label: string; - items: readonly PinnedUsageWindow[]; + sources: readonly PinnedUsageSource[]; onPress: () => void; }) { + const [available, setAvailable] = useState(null); + const [labelsWidth, setLabelsWidth] = useState(null); + const [percentsWidth, setPercentsWidth] = useState(null); + const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => { + setAvailable(layoutWidth(event)); + }, []); + const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => { + setLabelsWidth(layoutWidth(event)); + }, []); + const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => { + setPercentsWidth(layoutWidth(event)); + }, []); + const layout = choosePinnedUsageLayout({ + available, + labelsWidth, + percentsWidth, + windowCount: sources.reduce((count, source) => count + source.windows.length, 0), + }); return ( - {items.map((item) => ( - - - - {item.percentText} - + + + + + + - ))} + + + + ); } +/** + * The line's items as direct children of one row, so every meter shares the leftover width + * equally. + */ +function PinnedUsageLine({ + sources, + layout, + measuring = false, +}: { + sources: readonly PinnedUsageSource[]; + layout: PinnedUsageLayout; + measuring?: boolean; +}) { + return sources.map((source, sourceIndex) => ( + + + + + {source.windows.map((window, windowIndex) => { + const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap; + return ( + + {layout === "meters" ? ( + + + + ) : null} + + {window.percentText} + {layout === "percents" || window.shortLabel === "" ? null : ( + {` ${window.shortLabel}`} + )} + + + ); + })} + + )); +} + const styles = StyleSheet.create((theme) => ({ trigger: { width: "100%", flexDirection: "row", - flexWrap: "wrap", alignItems: "center", - // A wrapping row packs its lines at the top; center them in the 28px row instead. - alignContent: "center", - columnGap: theme.spacing[3], - rowGap: theme.spacing[1], + overflow: "hidden", // Same row geometry and leading rail as Add project and the footer icons. minHeight: 28, paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1.5], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, triggerHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, - item: { + line: { + flex: 1, + minWidth: 0, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + overflow: "hidden", + }, + // Wide enough that nothing inside wraps or shrinks; each child reports its natural width. + measure: { + position: "absolute", + top: 0, + left: 0, + width: 10_000, + opacity: 0, + }, + measured: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + }, + sourceGap: { + marginLeft: theme.spacing[3], + }, + iconGap: { + marginLeft: theme.spacing[1.5], + }, + windowGap: { + marginLeft: theme.spacing[2], + }, + // Every slot takes an equal share of the leftover width, so the line fills the row. + meterSlot: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: MIN_METER_WIDTH, + marginRight: METER_GAP, + }, + meter: { + height: 6, + borderRadius: 3, }, percent: { - color: theme.colors.foregroundMuted, + flexShrink: 0, + color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontVariant: ["tabular-nums"], }, + windowLabel: { + color: theme.colors.foregroundMuted, + }, sheetBody: { padding: theme.spacing[3], gap: theme.spacing[3], diff --git a/packages/app/src/usage/tone.ts b/packages/app/src/usage/tone.ts index 4727853c2f..d35d836f8b 100644 --- a/packages/app/src/usage/tone.ts +++ b/packages/app/src/usage/tone.ts @@ -1,6 +1,10 @@ -import type { UsageTone } from "./types"; +import { usedPercent } from "./model"; +import type { UsageTone, UsageWindow } from "./types"; -export function deriveTone(usedPct: number | null | undefined): UsageTone { +/** The source's tone for a window, or one derived from how much of it is used. */ +export function windowTone(window: UsageWindow): UsageTone { + if (window.tone) return window.tone; + const usedPct = usedPercent(window); if (usedPct == null) return "default"; if (usedPct > 90) return "danger"; if (usedPct >= 70) return "warning"; diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index ce6e43b5e6..4412836cc4 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,25 +1,13 @@ import { useMemo } from "react"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatDisplayPct, formatResetLabel } from "./format"; -import { displayPercent, usageWindowRowLabel, usedPercent } from "./model"; +import { formatDisplayPct, formatResetLabel } from "./format"; +import { UsageMeter } from "./meter"; +import { displayPercent, usageWindowRowLabel } from "./model"; import type { UsageDisplayAs } from "./preferences"; -import { deriveTone } from "./tone"; +import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; -function fillToneStyle(tone: UsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } -} - // Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, // so a hover never reads as the selection. Pinned rows do not react to hover. function highlightStyle(pinned: boolean, hovered: boolean) { @@ -43,15 +31,8 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { - const usedPct = usedPercent(window); const shownPct = displayPercent(window, displayAs); - const tone = window.tone ?? deriveTone(usedPct); - - const fillWidth = clampPct(shownPct ?? 0); - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], - [fillWidth, tone], - ); + const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const trailing = isAtRisk @@ -80,7 +61,8 @@ export function UsageWindowBar({ value={value} trailing={trailing} isAtRisk={isAtRisk} - fillStyle={fillStyle} + percent={shownPct ?? 0} + tone={tone} /> )} @@ -93,14 +75,16 @@ function WindowRowContent({ value, trailing, isAtRisk, - fillStyle, + percent, + tone, }: { highlight: StyleProp; label: string; value: string; trailing: string | null | undefined; isAtRisk: boolean; - fillStyle: StyleProp; + percent: number; + tone: UsageTone; }) { return ( <> @@ -116,9 +100,7 @@ function WindowRowContent({ ) : null} - - - + ); } @@ -174,26 +156,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.statusDanger, fontWeight: theme.fontWeight.normal, }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, - }, })); diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 0ab1cd7215..784a7569ff 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -12,6 +12,10 @@ export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; +export function useBottomSheetModal(): never { + throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. const sharedValue = (value: unknown) => ({ get: () => value }); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index ed49db0f1b..11906dd955 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -5,6 +5,13 @@ import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { id: string; label: string; + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel?: string; + /** Shown in the usage summary until the user pins windows of their own. */ + summary?: boolean; usedPct?: number | null; remainingPct?: number | null; resetsAt?: string | null; @@ -54,6 +61,8 @@ export interface UsageSourceRegistration { export function windowFromUsedPct(input: { id: string; label: string; + shortLabel?: string; + summary?: boolean; utilizationPct: number | null | undefined; resetsAt?: string | null; tone?: UsageWindow["tone"]; @@ -66,6 +75,8 @@ export function windowFromUsedPct(input: { remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), resetsAt: input.resetsAt ?? null, }; + if (input.shortLabel !== undefined) window.shortLabel = input.shortLabel; + if (input.summary) window.summary = true; if (input.tone) window.tone = input.tone; return window; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 69595c9ad6..861ddf4a37 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6189,6 +6189,13 @@ export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "er export const ProviderUsageWindowSchema = z.object({ id: z.string(), label: z.string(), + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel: z.string().optional(), + /** Shown in the usage summary until the user pins windows of their own. */ + summary: z.boolean().optional(), usedPct: z.number().nullable().optional(), remainingPct: z.number().nullable().optional(), resetsAt: z.string().nullable().optional(), diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index d20162e93d..b9eb4b6ecf 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -307,6 +307,38 @@ describe("Claude usage source scoped weekly limits", () => { ); }); + it("summarizes the session and weekly windows by default, not scoped ones", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => [window.id, window.summary ?? false])).toEqual([ + ["five_hour", true], + ["weekly", true], + ["weekly_model_fable", false], + ]); + }); + + it("gives every window a short label for tight spaces", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => [window.id, window.shortLabel])).toEqual([ + ["five_hour", "5h"], + ["weekly", "wk"], + ["weekly_model_fable", "wk Fable"], + ]); + }); + it("renders a scoped window that is at zero and inactive", async () => { const { provider } = claudeProvider({ seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 9e280df0e5..70ba4f9273 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -112,9 +112,11 @@ const UNSCOPED_WINDOWS: ReadonlyArray<{ field: "five_hour" | "seven_day"; id: string; label: string; + shortLabel: string; + summary: boolean; }> = [ - { field: "five_hour", id: "five_hour", label: "Session" }, - { field: "seven_day", id: "weekly", label: "Weekly" }, + { field: "five_hour", id: "five_hour", label: "Session", shortLabel: "5h", summary: true }, + { field: "seven_day", id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, ]; // Scoped windows from before `limits[]` existed. Declaring the dimension here is what @@ -253,6 +255,8 @@ function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] { windowFromUsedPct({ id: spec.id, label: spec.label, + shortLabel: spec.shortLabel, + summary: spec.summary, utilizationPct: window.utilization, resetsAt: window.resets_at ?? null, tone: toneFromUsedPct(window.utilization), @@ -272,6 +276,7 @@ function scopedWindows(limits: ScopedLimit[]): UsageWindow[] { return windowFromUsedPct({ id, label: `Weekly \u00b7 ${limit.name}`, + shortLabel: `wk ${limit.name}`, utilizationPct: limit.usedPct, resetsAt: limit.resetsAt, tone: toneFromUsedPct(limit.usedPct), diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index dfa58ef603..e7572f4efe 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -103,7 +103,31 @@ test("coerces credit balance and marks a 96 percent window dangerous", async () expect.objectContaining({ id: "weekly", tone: "danger" }), ]), ); - expect(report.balances).toEqual([expect.objectContaining({ remaining: 0, tone: "danger" })]); + expect(report.balances).toEqual([ + expect.objectContaining({ remaining: 0, unit: "credits", tone: "danger" }), + ]); +}); + +test("summarizes the session and weekly windows by default, not code review", async () => { + const report = await fetchUsage( + {}, + async () => + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 12 }, + secondary_window: { used_percent: 40 }, + }, + code_review_rate_limit: { primary_window: { used_percent: 5 } }, + }), + { status: 200 }, + ), + ); + expect(report.windows.map((window) => [window.id, window.summary ?? false])).toEqual([ + ["session", true], + ["weekly", true], + ["code_review", false], + ]); }); test("HTML usage body is unavailable", async () => { diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index 3fb660ae09..8620484b6e 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -57,15 +57,13 @@ export async function readAuth( } function usageWindow( - id: string, - label: string, + spec: { id: string; label: string; shortLabel: string; summary?: boolean }, value: z.infer | null | undefined, ): UsageWindow | null { if (!value) return null; const usedPct = value.used_percent ?? 0; return windowFromUsedPct({ - id, - label, + ...spec, utilizationPct: usedPct, resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null, tone: toneFromUsedPct(usedPct), @@ -95,9 +93,18 @@ export async function fetchUsage( if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] }; const usage = responseSchema.parse(JSON.parse(text)); const windows = [ - usageWindow("session", "Session", usage.rate_limit?.primary_window), - usageWindow("weekly", "Weekly", usage.rate_limit?.secondary_window), - usageWindow("code_review", "Code review", usage.code_review_rate_limit?.primary_window), + usageWindow( + { id: "session", label: "Session", shortLabel: "5h", summary: true }, + usage.rate_limit?.primary_window, + ), + usageWindow( + { id: "weekly", label: "Weekly", shortLabel: "wk", summary: true }, + usage.rate_limit?.secondary_window, + ), + usageWindow( + { id: "code_review", label: "Code review", shortLabel: "review" }, + usage.code_review_rate_limit?.primary_window, + ), ].filter((window): window is UsageWindow => window !== null); const balance = usage.credits?.balance; return { @@ -112,7 +119,7 @@ export async function fetchUsage( id: "credits", label: "Credits", remaining: balance, - unit: "usd", + unit: "credits", tone: balanceToneFromRemaining(balance), }, ], diff --git a/plugins/grok-usage-source/server/usage.ts b/plugins/grok-usage-source/server/usage.ts index ba4ab829cf..65367070b4 100644 --- a/plugins/grok-usage-source/server/usage.ts +++ b/plugins/grok-usage-source/server/usage.ts @@ -98,6 +98,7 @@ function grokUsageWindow(response: z.infer): Usa return windowFromUsedPct({ id: weekly ? "weekly" : "monthly", label: weekly ? "Weekly" : "Monthly", + shortLabel: weekly ? "wk" : "mo", utilizationPct: percent, resetsAt: period?.end ?? null, tone: toneFromUsedPct(percent), diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a60b0a34b2..36dae811d8 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -78,6 +78,8 @@ export class Usage { windowFromUsedPct({ id: "five_hour", label: `${usage.window.windowDurationMins / 60} hours`, + shortLabel: `${usage.window.windowDurationMins / 60}h`, + summary: true, utilizationPct: usage.window.usedPercent, resetsAt: new Date(usage.window.resetsAtMs).toISOString(), tone: toneFromUsedPct(usage.window.usedPercent), @@ -85,6 +87,8 @@ export class Usage { windowFromUsedPct({ id: "weekly", label: "Weekly", + shortLabel: "wk", + summary: true, utilizationPct: usage.weekly.usedPercent, resetsAt: new Date(usage.weekly.resetsAtMs).toISOString(), tone: toneFromUsedPct(usage.weekly.usedPercent), diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts index c475d96b36..849fbe874f 100644 --- a/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -59,6 +59,8 @@ test("discovers and fetches the default key from read-only auth.json", async () ["weekly", 42, "2026-09-28T00:00:00.000Z"], ["monthly", 100, "2026-10-01T00:00:00.000Z"], ]); + // The rolling window's length is not reported, so its percent stands without a name. + expect(report.windows.map((window) => window.shortLabel)).toEqual(["", "wk", "mo"]); // The current upstream endpoint returns windows only; do not invent balances. expect(report.balances).toBeUndefined(); expect(await readFile(path, "utf8")).toBe(content); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts index dd9944544e..77e83613df 100644 --- a/plugins/opencode-go-usage-source/server/usage.ts +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -65,14 +65,16 @@ export async function fetchUsage( const data = responseSchema.parse(await response.json()); const windows = ( [ - ["rolling", "Rolling", data.usage.rolling], - ["weekly", "Weekly", data.usage.weekly], - ["monthly", "Monthly", data.usage.monthly], + // The rolling window's length is not reported, so its percent stands without a name. + ["rolling", "Rolling", "", data.usage.rolling], + ["weekly", "Weekly", "wk", data.usage.weekly], + ["monthly", "Monthly", "mo", data.usage.monthly], ] as const - ).map(([id, label, value]) => + ).map(([id, label, shortLabel, value]) => windowFromUsedPct({ id, label, + shortLabel, utilizationPct: value.percent, resetsAt: value.resetsAt, tone: toneFromUsedPct(value.percent),