Streamline sidebar usage and stacked sheet interactions (#5805)

* feat(app): streamline sidebar usage and stacked sheets

* fix(app): restore compact sidebar navigation clicks

The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.

* fix(app): cap menu sheets at eighty percent height

Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
This commit is contained in:
Mohamed Boudra
2026-10-01 04:55:17 +01:00
committed by GitHub
parent 15f1431416
commit e939dd4c7a
65 changed files with 1292 additions and 527 deletions
+16
View File
@@ -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
+1 -1
View File
@@ -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).
+2
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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({
@@ -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"],
@@ -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");
@@ -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 });
@@ -7,6 +7,7 @@ import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
@@ -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();
});
@@ -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 = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
const codexPath = /<path[^>]* 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" });
@@ -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");
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
}
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
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,
@@ -220,32 +220,32 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise<v
const FOOTER_ICON_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-import-session",
"sidebar-help",
"sidebar-settings",
];
/** The labeled Add project button on the left, then the four fixed icons on the right. */
/**
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
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<void> {
@@ -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;
@@ -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<void> {
await expect(page).toHaveURL(/\/usage$/);
}
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
await openUsageOptions(page);
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
}
/** Opens the compact sidebar drawer. */
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
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}
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[],
);
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}
+49 -129
View File
@@ -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<typeof useShortcutKeys>;
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<typeof useShortcutKeys>;
theme: SidebarTheme;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={footerAddProjectButtonStyle}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => {
const isHovered = Boolean(hovered);
return (
<>
<FolderPlus
size={theme.iconSize.sm}
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
numberOfLines={1}
style={[
styles.footerAddProjectLabel,
isHovered && styles.footerAddProjectLabelHovered,
]}
>
{label}
</Text>
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8} testID="sidebar-add-project-tooltip">
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
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}
/>
</HostPicker>
@@ -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 (
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterAddProjectButton
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
<FooterIconButton
onPress={openUsageScreen}
testID="sidebar-usage-icon"
label={labels.usage}
icon={CircleGauge}
theme={theme}
/>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
);
@@ -593,7 +538,11 @@ function MobileSidebar({
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<WindowChromeSafeArea
placement="inline"
pointerEvents="box-none"
style={styles.mobileCloseButtonRow}
>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -642,7 +591,6 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -820,7 +768,6 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -919,7 +866,6 @@ const styles = StyleSheet.create((theme) => ({
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,
@@ -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 ? (
<ThemedIcon
@@ -158,17 +158,6 @@ const styles = StyleSheet.create((theme) => ({
// 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: {
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[],
);
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 && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
+2 -18
View File
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...backdropProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[],
);
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}
@@ -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<typeof GorhomBottomSheetModal>;
@@ -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<GorhomBottomSheetModalMethods>(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) => (
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
),
[backdropOpacity],
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={ref}
ref={modalRef}
enableDismissOnClose
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
>
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
</GorhomBottomSheetModal>
@@ -0,0 +1,5 @@
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
}
@@ -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 (
<View style={StyleSheet.absoluteFill}>
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="none"
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("common.bottomSheetBackdrop")}
// The provider owns presentation order, including when a lower backdrop remounts.
// Keep this target until the portal unmounts: animation must not expose the sheet below.
onPress={dismissTopSheet}
style={StyleSheet.absoluteFill}
/>
</View>
);
}
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...backdropProps}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
),
[],
);
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
+1
View File
@@ -26,6 +26,7 @@ export const ar: TranslationResources = {
total: "{{total}} تطابقات",
},
common: {
bottomSheetBackdrop: "خلفية اللوحة السفلية",
back: "خلف",
loading: "تحميل...",
actions: {
+1
View File
@@ -22,6 +22,7 @@ export const en = {
total: "{{total}} matches",
},
common: {
bottomSheetBackdrop: "Bottom sheet backdrop",
back: "Back",
loading: "Loading...",
actions: {
+1
View File
@@ -27,6 +27,7 @@ export const es: TranslationResources = {
total: "{{total}} coincidencias",
},
common: {
bottomSheetBackdrop: "Fondo del panel inferior",
back: "Atrás",
loading: "Cargando...",
actions: {
+1
View File
@@ -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: {
+1
View File
@@ -27,6 +27,7 @@ export const ja: TranslationResources = {
total: "{{total}} 件の一致",
},
common: {
bottomSheetBackdrop: "ボトムシートの背景",
back: "戻る",
loading: "読み込み中...",
actions: {
+1
View File
@@ -26,6 +26,7 @@ export const ko: TranslationResources = {
total: "일치 항목 {{total}}개",
},
common: {
bottomSheetBackdrop: "하단 시트 배경",
back: "뒤로",
loading: "불러오는 중...",
actions: {
+1
View File
@@ -27,6 +27,7 @@ export const ptBR: TranslationResources = {
total: "{{total}} correspondências",
},
common: {
bottomSheetBackdrop: "Fundo do painel inferior",
back: "Voltar",
loading: "Carregando...",
actions: {
+1
View File
@@ -27,6 +27,7 @@ export const ru: TranslationResources = {
total: "Совпадений: {{total}}",
},
common: {
bottomSheetBackdrop: "Фон нижней панели",
back: "Назад",
loading: "Загрузка...",
actions: {
+1
View File
@@ -26,6 +26,7 @@ export const zhCN: TranslationResources = {
total: "{{total}} 个匹配项",
},
common: {
bottomSheetBackdrop: "底部面板背景",
back: "返回",
loading: "加载中...",
actions: {
@@ -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(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? (
<Composer
@@ -2435,11 +2438,13 @@ export function NewWorkspaceScreen({
isCompact={isCompact}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
</View>
{importSession.sheet}
</FileDropZone>
);
}
@@ -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 = <ImportSessionButton compact={isCompact} onPress={onImportSession} />;
const setupFields = (
<>
{isCompact ? <View style={styles.compactTopActions}>{importSessionButton}</View> : null}
<View style={styles.composerTitleContainer} pointerEvents="none">
<Text style={styles.composerTitle}>{title}</Text>
</View>
@@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({
return (
<ComposerDock centered={!isCompact}>
{setupFields}
{children}
<>
{children}
{isCompact ? null : importSessionButton}
</>
</ComposerDock>
);
}
@@ -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
@@ -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 (
<View style={compact ? styles.compact : styles.wide}>
<Button
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
variant="ghost"
size="sm"
leftIcon={Import}
onPress={onPress}
style={compact ? null : styles.pill}
testID="new-workspace-import-session"
>
{t("importSession.title")}
</Button>
</View>
);
}
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,
},
}));
+40 -2
View File
@@ -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,
}),
+19 -2
View File
@@ -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<BuiltinSidebarItemId> = new Set(["usage"]);
function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean {
return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id));
}
export function pluginSidebarNavKey(
group: Pick<PluginSidebarGroup, "pluginId" | "contributionId">,
): string {
@@ -85,6 +95,8 @@ function isBuiltinSidebarItemId<Section extends SidebarSection>(
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
section: Section;
/** Compact layouts start some builtins hidden; a stored preference always wins. */
compact: boolean;
pluginGroups: readonly PluginSidebarGroup[];
preferences: readonly SidebarNavPreference[];
}): SidebarNavItem<Section>[] {
@@ -115,7 +127,12 @@ export function resolveSidebarNavItems<Section extends SidebarSection>(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;
@@ -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 extends SidebarSection>(
section: Section,
): UseSidebarNavItemsReturn<Section> {
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<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -51,7 +54,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[field, pluginGroups, section, updateSettings],
[compact, field, pluginGroups, section, updateSettings],
);
const move = useCallback(
@@ -60,6 +63,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
const previous = current[field];
const currentItems = resolveSidebarNavItems({
section,
compact,
pluginGroups,
preferences: previous,
});
@@ -68,7 +72,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
};
});
},
[field, pluginGroups, section, updateSettings],
[compact, field, pluginGroups, section, updateSettings],
);
return { items, setVisible, move };
@@ -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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[],
);
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
>
<View style={styles.container} testID="tool-call-group-sheet">
+9 -6
View File
@@ -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);
+4 -32
View File
@@ -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 (
<View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 1 ? (
@@ -56,23 +44,7 @@ export function UsageControls({
/>
) : null}
{view.kind === "unavailable" ? null : (
<>
<UsageDisplayToggle display={display} />
{/* The Changes panel's refresh: an icon, with the label in its tooltip. */}
<ToolbarButton
label={busy ? usageCopy.refreshing : usageCopy.refresh}
compact={compact}
disabled={busy}
onPress={onRefresh}
testID="usage-refresh-all"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
</>
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
)}
</View>
);
+3
View File
@@ -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;
-22
View File
@@ -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<UsageDisplayAs>[] = [
{ 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 (
<SegmentedControl
size="xs"
options={OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
testID="usage-display-toggle"
/>
);
}
+15
View File
@@ -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");
});
});
+4 -3
View File
@@ -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);
}
}
+1 -1
View File
@@ -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";
+74
View File
@@ -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<ViewStyle>;
}) {
const fillWidth = clampPct(percent);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, quiet, tone],
);
return (
<View style={[styles.track, style]}>
<View style={fillStyle} />
</View>
);
}
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,
},
}));
+103
View File
@@ -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 (
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedSettings size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
<DropdownMenuContent
align="end"
width={200}
sheetTitle={usageCopy.options}
testID="usage-options-menu-content"
>
<DropdownMenuItem
status={busy ? "pending" : "idle"}
pendingLabel={usageCopy.refreshing}
onSelect={onRefresh}
testID="usage-refresh-all"
>
{usageCopy.refresh}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{usageCopy.displayAs}</DropdownMenuLabel>
<DropdownMenuItem
selected={displayAs === "used"}
onSelect={showUsed}
testID="usage-display-used"
>
{usageCopy.displayUsed}
</DropdownMenuItem>
<DropdownMenuItem
selected={displayAs === "remaining"}
onSelect={showRemaining}
testID="usage-display-remaining"
>
{usageCopy.displayRemaining}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
selected={inSidebar}
onSelect={toggleInSidebar}
testID="usage-show-in-sidebar"
>
{usageCopy.showInSidebar}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+112 -27
View File
@@ -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: "<svg/>",
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: "<svg/>",
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: "<svg/>",
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" }]),
);
+81 -31
View File
@@ -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 }];
});
}
+175 -35
View File
@@ -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 <UsageEntry serverId={serverId} items={NO_ITEMS} display={display} />;
return <UsageEntry serverId={serverId} sources={NO_SOURCES} display={display} />;
}
return (
<PinnedUsageItem
@@ -56,11 +70,12 @@ function PinnedUsageItem({
}) {
const { view } = useHostUsage(serverId);
const reports = view.kind === "ready" ? view.reports : NO_REPORTS;
const items = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} items={items} display={display} />;
const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} sources={sources} display={display} />;
}
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 ? (
<PinnedUsageTrigger label={label} items={items} onPress={handlePress} />
sources.length > 0 ? (
<PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />
) : (
<SidebarHeaderRow
variant="inline"
icon={Gauge}
icon={CircleGauge}
label={label}
onPress={handlePress}
testID="sidebar-usage"
@@ -168,72 +183,197 @@ function HostUsageSheet({
);
}
function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string {
return `${label}: ${items.map((item) => 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<number | null>(null);
const [labelsWidth, setLabelsWidth] = useState<number | null>(null);
const [percentsWidth, setPercentsWidth] = useState<number | null>(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 (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, items)}
accessibilityLabel={pinnedUsageLabel(label, sources)}
style={triggerStyle}
testID="sidebar-usage"
>
{items.map((item) => (
<View key={item.key} style={styles.item} testID="sidebar-usage-pinned-window">
<UsageSourceIcon svg={item.icon} size={14} />
<Text style={styles.percent} numberOfLines={1}>
{item.percentText}
</Text>
<View style={styles.line} onLayout={handleAvailableLayout}>
<PinnedUsageLine sources={sources} layout={layout} />
</View>
<View
style={styles.measure}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<View style={styles.measured} onLayout={handleLabelsLayout}>
<PinnedUsageLine sources={sources} layout="labels" measuring />
</View>
))}
<View style={styles.measured} onLayout={handlePercentsLayout}>
<PinnedUsageLine sources={sources} layout="percents" measuring />
</View>
</View>
</Pressable>
);
}
/**
* 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) => (
<Fragment key={source.key}>
<View
style={sourceIndex === 0 ? null : styles.sourceGap}
testID={measuring ? undefined : "sidebar-usage-source"}
>
<UsageSourceIcon svg={source.icon} size={14} />
</View>
{source.windows.map((window, windowIndex) => {
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
return (
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View>
) : null}
<Text
style={[styles.percent, layout === "meters" ? null : leadStyle]}
numberOfLines={1}
testID={measuring ? undefined : "sidebar-usage-pinned-window"}
>
{window.percentText}
{layout === "percents" || window.shortLabel === "" ? null : (
<Text style={styles.windowLabel}>{` ${window.shortLabel}`}</Text>
)}
</Text>
</Fragment>
);
})}
</Fragment>
));
}
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],
+6 -2
View File
@@ -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";
+12 -52
View File
@@ -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<StyleProp<ViewStyle>>(
() => [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}
/>
)}
</Pressable>
@@ -93,14 +75,16 @@ function WindowRowContent({
value,
trailing,
isAtRisk,
fillStyle,
percent,
tone,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
trailing: string | null | undefined;
isAtRisk: boolean;
fillStyle: StyleProp<ViewStyle>;
percent: number;
tone: UsageTone;
}) {
return (
<>
@@ -116,9 +100,7 @@ function WindowRowContent({
) : null}
</Text>
</View>
<View style={styles.track}>
<View style={fillStyle} />
</View>
<UsageMeter percent={percent} tone={tone} />
</>
);
}
@@ -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,
},
}));
@@ -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 });
+11
View File
@@ -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;
}
+7
View File
@@ -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(),
@@ -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" },
+7 -2
View File
@@ -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),
@@ -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 () => {
+15 -8
View File
@@ -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<typeof windowSchema> | 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),
},
],
@@ -98,6 +98,7 @@ function grokUsageWindow(response: z.infer<typeof GrokUsageResponseSchema>): Usa
return windowFromUsedPct({
id: weekly ? "weekly" : "monthly",
label: weekly ? "Weekly" : "Monthly",
shortLabel: weekly ? "wk" : "mo",
utilizationPct: percent,
resetsAt: period?.end ?? null,
tone: toneFromUsedPct(percent),
+4
View File
@@ -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),
@@ -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);
@@ -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),