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),