mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -302,6 +302,22 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
|
||||
forwards commands to the current Gorhom handle.
|
||||
|
||||
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
|
||||
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
|
||||
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
|
||||
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
|
||||
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
|
||||
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
React cannot copy contexts reflectively, so the only way across the teleport in
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -191,6 +191,8 @@ its own.
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
|
||||
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
|
||||
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
|
||||
+1
-1
@@ -478,7 +478,7 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
|
||||
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
|
||||
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts, Import session,
|
||||
are rows between Add project and the footer's icon row. The icon row (Hosts,
|
||||
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
|
||||
button.
|
||||
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
|
||||
|
||||
@@ -84,6 +84,18 @@ async function openModelSelector(page: Page) {
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function dismissStackedModelPickerWithBackdrop(page: Page) {
|
||||
await page
|
||||
.getByTestId("agent-controls-settings-list")
|
||||
.getByRole("button", { name: /Select model/ })
|
||||
.click();
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
|
||||
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openAndCloseTabSwitcherTwice(page: Page) {
|
||||
await openTabSwitcher(page);
|
||||
await closeBottomSheetWithBackdrop(page);
|
||||
@@ -114,6 +126,8 @@ test.describe("mobile bottom sheet reopen", () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await dismissStackedModelPickerWithBackdrop(page);
|
||||
|
||||
await page.getByTestId("model-search-all-input").click();
|
||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||
await expect(model).toBeVisible({
|
||||
|
||||
@@ -128,7 +128,6 @@ test("an open Import Session row keeps its age current", async ({ page }) => {
|
||||
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
await flow.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
@@ -141,13 +140,13 @@ test("captures the compact import-session journey", async ({ page }, testInfo) =
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
|
||||
await test.step("the mobile sidebar exposes import in its footer", async () => {
|
||||
await flow.revealMobileEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
|
||||
await test.step("the new workspace screen offers import at the top", async () => {
|
||||
await flow.revealNewWorkspaceEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
|
||||
});
|
||||
|
||||
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
|
||||
await flow.openGlobally();
|
||||
await flow.openFromNewWorkspace();
|
||||
await flow.expectScope("Sessions on", false);
|
||||
await flow.expectRows({
|
||||
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
||||
|
||||
@@ -31,13 +31,13 @@ import {
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
test("fixed footer line keeps the labeled Add project button and four icons", async ({ page }) => {
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31%", "7%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await footerScreenshot(page, "footer-desktop-with-rows");
|
||||
|
||||
@@ -215,7 +215,7 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31%"]);
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
@@ -226,7 +226,7 @@ test.describe("usage screen", () => {
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await expectPinnedUsage(page, ["12%"]);
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
await addConnectedHostAndReload(page, {
|
||||
@@ -234,7 +234,7 @@ test.describe("usage screen", () => {
|
||||
label: "Secondary box",
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12%"]);
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
@@ -89,7 +90,7 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await card.getByTestId("usage-display-remaining").click();
|
||||
await showUsageAs(page, "remaining");
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
@@ -117,7 +118,7 @@ test.describe("usage settings", () => {
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
@@ -13,11 +17,15 @@ import {
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -47,6 +55,32 @@ async function footerClip(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -59,15 +93,20 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectPinnedUsage(page, ["31%", "7%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await openSidebarNavSettings(page);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
// Phones start with the Usage item off; turning it on stores the choice.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "compact-settings-sidebar-footer");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31%", "7%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "compact-footer-defaults");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
@@ -82,12 +121,12 @@ test.describe("Usage item", () => {
|
||||
0,
|
||||
);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await expectPinnedUsage(page, ["31%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["31%", "12%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h", "12% wk"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Usage options", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
@@ -100,7 +139,7 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69%", "88%"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
|
||||
@@ -123,7 +162,7 @@ test.describe("Usage item", () => {
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69%", "88%"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
@@ -136,7 +175,18 @@ test.describe("Usage item", () => {
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the Usage screen's controls.
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
|
||||
// The options open as a sheet of their own over the usage sheet.
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-options-sheet");
|
||||
// Tap the backdrop above both sheets; only the options sheet closes.
|
||||
await page.mouse.click(COMPACT.width / 2, 40);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
|
||||
@@ -153,10 +203,14 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69%", "88%"]);
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-options-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
@@ -173,10 +227,10 @@ test.describe("Usage item", () => {
|
||||
await test.step("unpinning both brings back default windows, and that survives a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["69%", "93%"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectPinnedUsage(page, ["69%", "93%"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "46% wk", "93% 5h", "88% wk"]);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
@@ -194,6 +248,28 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the Usage options show and hide the sidebar Usage item", async ({ page }) => {
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
|
||||
await openUsageOptions(page);
|
||||
const toggle = page.getByTestId("usage-show-in-sidebar");
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("released hosts supply source logos through the client conversion", async ({ page }) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
@@ -201,12 +277,12 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
});
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31%", "7%"]);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
// Source logos are decorative SVGs with no accessible role. Their path data distinguishes
|
||||
// the source artwork from the fallback gauge.
|
||||
const claudePath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
|
||||
const codexPath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!;
|
||||
const summary = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
const summary = usageItem(page).getByTestId("sidebar-usage-source");
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
|
||||
|
||||
@@ -18,16 +19,24 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
async revealMobileEntryPoint() {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
const button = this.page.getByTestId("sidebar-import-session");
|
||||
/** The new workspace screen's Import session button, reached from the sidebar. */
|
||||
async revealNewWorkspaceEntryPoint() {
|
||||
const compact = (this.page.viewportSize()?.width ?? 0) < 768;
|
||||
if (compact) {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
async openGlobally() {
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await this.page.getByTestId("sidebar-import-session").click();
|
||||
await this.revealNewWorkspaceEntryPoint();
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -108,8 +117,12 @@ export class ImportSessionFlow {
|
||||
});
|
||||
}
|
||||
async expectTranscript(userText: string, assistantText: string) {
|
||||
await expect(this.page.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
|
||||
await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
await expect(
|
||||
this.page.getByTestId("assistant-message").filter({ visible: true }),
|
||||
).toContainText(assistantText);
|
||||
}
|
||||
async showAll() {
|
||||
await this.page.getByTestId("import-session-show-all").click();
|
||||
@@ -120,7 +133,9 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
|
||||
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
|
||||
}
|
||||
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
// The shell keeps a hidden copy of the other layout's sidebar mounted.
|
||||
await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -220,32 +220,32 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise<v
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-import-session",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/** The labeled Add project button on the left, then the four fixed icons on the right. */
|
||||
/**
|
||||
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
);
|
||||
const [first, ...rest] = boxes.map((box) => box!);
|
||||
expect(first.width).toBeGreaterThan(rest[0]!.width);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="sidebar-add-project"]:visible')
|
||||
.getByText("Add project", { exact: true }),
|
||||
).toBeVisible();
|
||||
let previous = first;
|
||||
for (const box of rest) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first.y - first.height / 2)).toBeLessThan(2);
|
||||
expect(box.x).toBeGreaterThan(previous.x);
|
||||
previous = box;
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
expect(box.width).toBe(first!.width);
|
||||
}
|
||||
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
|
||||
expect(gaps).toEqual([0, 0, expect.any(Number), 0]);
|
||||
expect(gaps[2]).toBeGreaterThan(first!.width);
|
||||
}
|
||||
|
||||
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
|
||||
|
||||
@@ -163,7 +163,6 @@ class StartupAssertions {
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
export async function installTallUsageSource() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("tall-usage");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "tall-usage",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { z } from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({
|
||||
id: "tall-usage", label: "Scrolling account", input: z.object({}),
|
||||
discover: async () => [{}],
|
||||
identify: async () => ({ key: "scrolling-account" }),
|
||||
fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i), label: "Window " + (i + 1), usedPct: 25,
|
||||
})) }),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
|
||||
|
||||
@@ -28,8 +80,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 31,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 54,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -44,8 +110,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 7,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 12,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -71,9 +151,10 @@ export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
|
||||
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(windows).toHaveText(percents);
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
|
||||
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(pinned).toHaveText(windows);
|
||||
}
|
||||
|
||||
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
|
||||
@@ -95,8 +176,21 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
|
||||
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
|
||||
/** The usage title row's options menu: Refresh and Used/Remaining. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="usage-options-menu"]:visible').first().click();
|
||||
await expect(page.getByTestId("usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
await openUsageOptions(page);
|
||||
await page.getByTestId(`usage-display-${displayAs}`).click();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
|
||||
}
|
||||
|
||||
/** Opens the compact sidebar drawer. */
|
||||
|
||||
@@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetFlatList,
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -718,12 +714,6 @@ export function CommandCenter() {
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const backdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
@@ -735,7 +725,7 @@ export function CommandCenter() {
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={backdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
@@ -550,13 +549,6 @@ export function AdaptiveModalSheet({
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopCard,
|
||||
@@ -649,7 +641,7 @@ export function AdaptiveModalSheet({
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ar: TranslationResources = {
|
||||
total: "{{total}} تطابقات",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "خلفية اللوحة السفلية",
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
actions: {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const en = {
|
||||
total: "{{total}} matches",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Bottom sheet backdrop",
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const es: TranslationResources = {
|
||||
total: "{{total}} coincidencias",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Fondo del panel inferior",
|
||||
back: "Atrás",
|
||||
loading: "Cargando...",
|
||||
actions: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ja: TranslationResources = {
|
||||
total: "{{total}} 件の一致",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "ボトムシートの背景",
|
||||
back: "戻る",
|
||||
loading: "読み込み中...",
|
||||
actions: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ko: TranslationResources = {
|
||||
total: "일치 항목 {{total}}개",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "하단 시트 배경",
|
||||
back: "뒤로",
|
||||
loading: "불러오는 중...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ptBR: TranslationResources = {
|
||||
total: "{{total}} correspondências",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Fundo do painel inferior",
|
||||
back: "Voltar",
|
||||
loading: "Carregando...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ru: TranslationResources = {
|
||||
total: "Совпадений: {{total}}",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Фон нижней панели",
|
||||
back: "Назад",
|
||||
loading: "Загрузка...",
|
||||
actions: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }]),
|
||||
);
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6189,6 +6189,13 @@ export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "er
|
||||
export const ProviderUsageWindowSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
/**
|
||||
* A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string
|
||||
* shows the percent alone; leaving it out shows `label`.
|
||||
*/
|
||||
shortLabel: z.string().optional(),
|
||||
/** Shown in the usage summary until the user pins windows of their own. */
|
||||
summary: z.boolean().optional(),
|
||||
usedPct: z.number().nullable().optional(),
|
||||
remainingPct: z.number().nullable().optional(),
|
||||
resetsAt: z.string().nullable().optional(),
|
||||
|
||||
@@ -307,6 +307,38 @@ describe("Claude usage source scoped weekly limits", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes the session and weekly windows by default, not scoped ones", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [fableLimit()],
|
||||
});
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows.map((window) => [window.id, window.summary ?? false])).toEqual([
|
||||
["five_hour", true],
|
||||
["weekly", true],
|
||||
["weekly_model_fable", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives every window a short label for tight spaces", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" },
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
limits: [fableLimit()],
|
||||
});
|
||||
|
||||
const usage = await provider.fetchUsage();
|
||||
|
||||
expect(usage.windows.map((window) => [window.id, window.shortLabel])).toEqual([
|
||||
["five_hour", "5h"],
|
||||
["weekly", "wk"],
|
||||
["weekly_model_fable", "wk Fable"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a scoped window that is at zero and inactive", async () => {
|
||||
const { provider } = claudeProvider({
|
||||
seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" },
|
||||
|
||||
@@ -112,9 +112,11 @@ const UNSCOPED_WINDOWS: ReadonlyArray<{
|
||||
field: "five_hour" | "seven_day";
|
||||
id: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
summary: boolean;
|
||||
}> = [
|
||||
{ field: "five_hour", id: "five_hour", label: "Session" },
|
||||
{ field: "seven_day", id: "weekly", label: "Weekly" },
|
||||
{ field: "five_hour", id: "five_hour", label: "Session", shortLabel: "5h", summary: true },
|
||||
{ field: "seven_day", id: "weekly", label: "Weekly", shortLabel: "wk", summary: true },
|
||||
];
|
||||
|
||||
// Scoped windows from before `limits[]` existed. Declaring the dimension here is what
|
||||
@@ -253,6 +255,8 @@ function unscopedWindows(resp: ClaudeUsageResponse): UsageWindow[] {
|
||||
windowFromUsedPct({
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
shortLabel: spec.shortLabel,
|
||||
summary: spec.summary,
|
||||
utilizationPct: window.utilization,
|
||||
resetsAt: window.resets_at ?? null,
|
||||
tone: toneFromUsedPct(window.utilization),
|
||||
@@ -272,6 +276,7 @@ function scopedWindows(limits: ScopedLimit[]): UsageWindow[] {
|
||||
return windowFromUsedPct({
|
||||
id,
|
||||
label: `Weekly \u00b7 ${limit.name}`,
|
||||
shortLabel: `wk ${limit.name}`,
|
||||
utilizationPct: limit.usedPct,
|
||||
resetsAt: limit.resetsAt,
|
||||
tone: toneFromUsedPct(limit.usedPct),
|
||||
|
||||
@@ -103,7 +103,31 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
|
||||
expect.objectContaining({ id: "weekly", tone: "danger" }),
|
||||
]),
|
||||
);
|
||||
expect(report.balances).toEqual([expect.objectContaining({ remaining: 0, tone: "danger" })]);
|
||||
expect(report.balances).toEqual([
|
||||
expect.objectContaining({ remaining: 0, unit: "credits", tone: "danger" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("summarizes the session and weekly windows by default, not code review", async () => {
|
||||
const report = await fetchUsage(
|
||||
{},
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 12 },
|
||||
secondary_window: { used_percent: 40 },
|
||||
},
|
||||
code_review_rate_limit: { primary_window: { used_percent: 5 } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
expect(report.windows.map((window) => [window.id, window.summary ?? false])).toEqual([
|
||||
["session", true],
|
||||
["weekly", true],
|
||||
["code_review", false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("HTML usage body is unavailable", async () => {
|
||||
|
||||
@@ -57,15 +57,13 @@ export async function readAuth(
|
||||
}
|
||||
|
||||
function usageWindow(
|
||||
id: string,
|
||||
label: string,
|
||||
spec: { id: string; label: string; shortLabel: string; summary?: boolean },
|
||||
value: z.infer<typeof windowSchema> | null | undefined,
|
||||
): UsageWindow | null {
|
||||
if (!value) return null;
|
||||
const usedPct = value.used_percent ?? 0;
|
||||
return windowFromUsedPct({
|
||||
id,
|
||||
label,
|
||||
...spec,
|
||||
utilizationPct: usedPct,
|
||||
resetsAt: value.reset_at != null ? new Date(value.reset_at * 1000).toISOString() : null,
|
||||
tone: toneFromUsedPct(usedPct),
|
||||
@@ -95,9 +93,18 @@ export async function fetchUsage(
|
||||
if (text.trim().startsWith("<")) return { status: "unavailable", windows: [] };
|
||||
const usage = responseSchema.parse(JSON.parse(text));
|
||||
const windows = [
|
||||
usageWindow("session", "Session", usage.rate_limit?.primary_window),
|
||||
usageWindow("weekly", "Weekly", usage.rate_limit?.secondary_window),
|
||||
usageWindow("code_review", "Code review", usage.code_review_rate_limit?.primary_window),
|
||||
usageWindow(
|
||||
{ id: "session", label: "Session", shortLabel: "5h", summary: true },
|
||||
usage.rate_limit?.primary_window,
|
||||
),
|
||||
usageWindow(
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true },
|
||||
usage.rate_limit?.secondary_window,
|
||||
),
|
||||
usageWindow(
|
||||
{ id: "code_review", label: "Code review", shortLabel: "review" },
|
||||
usage.code_review_rate_limit?.primary_window,
|
||||
),
|
||||
].filter((window): window is UsageWindow => window !== null);
|
||||
const balance = usage.credits?.balance;
|
||||
return {
|
||||
@@ -112,7 +119,7 @@ export async function fetchUsage(
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: balance,
|
||||
unit: "usd",
|
||||
unit: "credits",
|
||||
tone: balanceToneFromRemaining(balance),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -98,6 +98,7 @@ function grokUsageWindow(response: z.infer<typeof GrokUsageResponseSchema>): Usa
|
||||
return windowFromUsedPct({
|
||||
id: weekly ? "weekly" : "monthly",
|
||||
label: weekly ? "Weekly" : "Monthly",
|
||||
shortLabel: weekly ? "wk" : "mo",
|
||||
utilizationPct: percent,
|
||||
resetsAt: period?.end ?? null,
|
||||
tone: toneFromUsedPct(percent),
|
||||
|
||||
@@ -78,6 +78,8 @@ export class Usage {
|
||||
windowFromUsedPct({
|
||||
id: "five_hour",
|
||||
label: `${usage.window.windowDurationMins / 60} hours`,
|
||||
shortLabel: `${usage.window.windowDurationMins / 60}h`,
|
||||
summary: true,
|
||||
utilizationPct: usage.window.usedPercent,
|
||||
resetsAt: new Date(usage.window.resetsAtMs).toISOString(),
|
||||
tone: toneFromUsedPct(usage.window.usedPercent),
|
||||
@@ -85,6 +87,8 @@ export class Usage {
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
utilizationPct: usage.weekly.usedPercent,
|
||||
resetsAt: new Date(usage.weekly.resetsAtMs).toISOString(),
|
||||
tone: toneFromUsedPct(usage.weekly.usedPercent),
|
||||
|
||||
@@ -59,6 +59,8 @@ test("discovers and fetches the default key from read-only auth.json", async ()
|
||||
["weekly", 42, "2026-09-28T00:00:00.000Z"],
|
||||
["monthly", 100, "2026-10-01T00:00:00.000Z"],
|
||||
]);
|
||||
// The rolling window's length is not reported, so its percent stands without a name.
|
||||
expect(report.windows.map((window) => window.shortLabel)).toEqual(["", "wk", "mo"]);
|
||||
// The current upstream endpoint returns windows only; do not invent balances.
|
||||
expect(report.balances).toBeUndefined();
|
||||
expect(await readFile(path, "utf8")).toBe(content);
|
||||
|
||||
@@ -65,14 +65,16 @@ export async function fetchUsage(
|
||||
const data = responseSchema.parse(await response.json());
|
||||
const windows = (
|
||||
[
|
||||
["rolling", "Rolling", data.usage.rolling],
|
||||
["weekly", "Weekly", data.usage.weekly],
|
||||
["monthly", "Monthly", data.usage.monthly],
|
||||
// The rolling window's length is not reported, so its percent stands without a name.
|
||||
["rolling", "Rolling", "", data.usage.rolling],
|
||||
["weekly", "Weekly", "wk", data.usage.weekly],
|
||||
["monthly", "Monthly", "mo", data.usage.monthly],
|
||||
] as const
|
||||
).map(([id, label, value]) =>
|
||||
).map(([id, label, shortLabel, value]) =>
|
||||
windowFromUsedPct({
|
||||
id,
|
||||
label,
|
||||
shortLabel,
|
||||
utilizationPct: value.percent,
|
||||
resetsAt: value.resetsAt,
|
||||
tone: toneFromUsedPct(value.percent),
|
||||
|
||||
Reference in New Issue
Block a user