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