Make Usage settings discoverable above reports (#5921)

* feat(usage): replace options cog with a collapsible Settings row

* test(usage): find Settings by accessible button name
This commit is contained in:
Mohamed Boudra
2026-10-02 20:59:16 +01:00
committed by GitHub
parent 8608525496
commit d831c7bf33
16 changed files with 341 additions and 167 deletions
+1 -1
View File
@@ -217,7 +217,7 @@ Changing state must not move the layout. A row that grows when its badge arrives
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
+86 -3
View File
@@ -21,7 +21,16 @@ import {
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import { expectPinnedUsage, togglePin, usageItem } from "../support/helpers/usage-sidebar-item";
import {
claudeAndCodexReports,
expectPinnedUsage,
pinRow,
openUsageOptions,
showUsageAs,
refreshAllUsage,
togglePin,
usageItem,
} from "../support/helpers/usage-sidebar-item";
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
test.use({
@@ -228,6 +237,8 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
).toBeVisible({ timeout: 10_000 });
await qaScreenshot(page, "phase7-usage-update-host");
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
});
@@ -315,9 +326,9 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
await expect(row).toBeChecked();
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "0");
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
await hoverUsageWindow(page, "Weekly");
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
await expect(page.getByText("Unpin", { exact: true })).toBeVisible();
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
"fill",
@@ -330,6 +341,8 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
"fill",
"none",
);
await hoverUsageWindow(page, "Weekly");
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% 5h"]);
await qaScreenshot(page, "usage-pin-unselected");
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
@@ -401,3 +414,73 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
}
});
}
for (const theme of ["light", "dark"] as const) {
for (const [size, viewport] of Object.entries({
desktop: { width: 1440, height: 900 },
compact: { width: 390, height: 844 },
})) {
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript((value) => {
const key = "@paseo:app-settings";
const current = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...current, theme: value }));
}, theme);
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize(viewport);
await page.goto("/usage");
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = screen.getByRole("button", { name: "Settings", exact: true });
const chevron = settings.locator("svg").locator("../..");
await expect(settings).toHaveAccessibleName("Settings");
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(chevron).toHaveCSS("transform", "none");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
).toHaveCSS("opacity", "1");
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
await togglePin(screen, "Claude", "Weekly");
await page.mouse.move(0, viewport.height - 1);
await expect(
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
).toHaveCSS("opacity", "1");
await openUsageOptions(page);
await expect(settings).toHaveAttribute("aria-expanded", "true");
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
"aria-selected",
"true",
);
await settings.blur();
await page.mouse.move(0, viewport.height - 1);
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
await showUsageAs(page, "remaining");
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
);
await settings.click();
await expect(settings).toHaveAttribute("aria-expanded", "false");
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
});
}
}
async function captureSettingsState(
page: Page,
testInfo: import("@playwright/test").TestInfo,
name: string,
) {
await qaScreenshot(page, name);
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
}
@@ -95,7 +95,12 @@ test.describe("usage settings", () => {
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
// Percentages now live on the Usage screen, and still apply to the host section.
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
const hostUsageUrl = page.url();
await page.goto("/usage");
await showUsageAs(page, "remaining");
await page.goto(hostUsageUrl);
await expect(card.getByText("30% left")).toBeVisible();
await expect(card.getByText("93% left")).toBeVisible();
});
@@ -137,7 +137,7 @@ test.describe("Usage item", () => {
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: "Usage options", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
await qaScreenshot(page, "usage-screen-header", {
kind: "element",
locator: page.getByTestId("page-title").locator(".."),
@@ -185,22 +185,26 @@ test.describe("Usage item", () => {
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, \d+% left( · .+)?$/,
);
// The sheet carries the Usage screen's controls.
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
// The options open as a sheet of their own over the usage sheet.
// The sheet carries the same inline Settings row and title-row Refresh all.
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
"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 qaScreenshot(page, "compact-settings-expanded");
await sheet.getByTestId("usage-options-toggle").click();
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
await qaScreenshot(page, "phase7-compact-sheet");
@@ -217,11 +221,11 @@ test.describe("Usage item", () => {
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-checked",
"aria-selected",
"true",
);
await qaScreenshot(page, "usage-options-menu");
await page.keyboard.press("Escape");
await qaScreenshot(page, "usage-settings-expanded");
await page.getByTestId("usage-options-toggle").click();
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
@@ -294,7 +298,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
await expectOnUsageScreen(page);
});
test("the Usage options show and hide the sidebar Usage item", async ({ page }) => {
test("the Usage Settings show and hide the sidebar Usage item", async ({ page }) => {
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
@@ -303,7 +307,7 @@ test("the Usage options show and hide the sidebar Usage item", async ({ page })
await expectOnUsageScreen(page);
await openUsageOptions(page);
const toggle = page.getByTestId("usage-show-in-sidebar");
const toggle = page.getByTestId("usage-show-in-sidebar").getByRole("switch");
await expect(toggle).toHaveAttribute("aria-checked", "true");
await toggle.click();
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
@@ -175,21 +175,24 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
/** The usage title row's options menu: Refresh and Used/Remaining. */
/** Expand the inline Settings row when its controls are folded. */
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();
const toggle = visible(page, "usage-options-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
await expect(visible(page, "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);
await visible(page, `usage-display-${displayAs}`).click();
await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
export async function refreshAllUsage(page: Page): Promise<void> {
await openUsageOptions(page);
await page.getByRole("menuitem", { name: "Refresh", exact: true }).click();
await visible(page, "usage-refresh-all").click();
}
/** Opens the compact sidebar drawer. */
@@ -0,0 +1,102 @@
import { ChevronRight } from "lucide-react-native";
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { isWeb } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
interface SettingsCollapsibleRowProps {
label: string;
defaultExpanded?: boolean;
testID?: string;
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
children: ReactNode;
}
/**
* A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a
* trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any
* other row.
*/
export function SettingsCollapsibleRow({
label,
defaultExpanded = false,
testID,
children,
}: SettingsCollapsibleRowProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
const webExpandedProps = useMemo(
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
hovered && styles.headerHovered,
pressed && styles.headerPressed,
],
[],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<View testID={testID}>
<Pressable
{...webExpandedProps}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
onPress={toggle}
style={headerStyle}
testID={testID ? `${testID}-toggle` : undefined}
>
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</View>
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
</>
)}
</Pressable>
{expanded
? Children.toArray(children).map((child, index) => (
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
{child}
</View>
))
: null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerHovered: {
backgroundColor: theme.colors.surface2,
},
headerPressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
@@ -26,6 +26,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
+10 -19
View File
@@ -2,8 +2,7 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HostFilter } from "@/components/hosts/host-filter";
import { useMemo, type ReactElement } from "react";
import type { UsageDisplay } from "./display";
import { UsageOptionsMenu } from "./options-menu";
import { UsageRefreshButton } from "./refresh-button";
import type { UsageHost } from "./model";
import { useHostUsage } from "./queries";
import type { UsageView } from "./types";
@@ -17,16 +16,14 @@ export interface UsageHostSelection {
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, and the options menu. A host that cannot report usage keeps only the host filter.
* host, and Refresh all. A host that cannot report usage keeps only the host filter.
*/
export function UsageControls({
view,
display,
onRefresh,
hostSelection,
}: {
view: UsageView;
display: UsageDisplay;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
@@ -44,7 +41,7 @@ export function UsageControls({
/>
) : null}
{view.kind === "unavailable" ? null : (
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
)}
</View>
);
@@ -67,21 +64,15 @@ const styles = StyleSheet.create((theme) => ({
* One host's usage and the title-row controls that go with it, for the Usage screen and the
* compact usage sheet.
*/
export function useHostUsageWithControls(
hostSelection: UsageHostSelection,
display: UsageDisplay,
): { view: UsageView; refresh: () => void; controls: ReactElement } {
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
controls: ReactElement;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
const controls = useMemo(
() => (
<UsageControls
view={view}
display={display}
onRefresh={refresh}
hostSelection={hostSelection}
/>
),
[display, hostSelection, refresh, view],
() => <UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />,
[hostSelection, refresh, view],
);
return { view, refresh, controls };
}
+6 -3
View File
@@ -15,8 +15,9 @@ export const usageCopy = {
},
title: "Usage",
planUsage: "Plan usage",
options: "Usage options",
options: "Settings",
refresh: "Refresh",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
updated: "Updated",
@@ -29,8 +30,10 @@ export const usageCopy = {
clientUnavailable: "Host connection is not ready",
retry: "Try again",
pin: "Pin",
displayAs: "Show",
unpin: "Unpin",
displayAs: "Percentages",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Show in sidebar",
showInSidebar: "Summary in sidebar",
showInSidebarHint: "Pinned windows show in the sidebar footer",
} as const;
-103
View File
@@ -1,103 +0,0 @@
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>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import { View } from "react-native";
import {
SettingsCard,
SettingsCollapsibleRow,
SettingsRow,
SettingsSwitch,
} from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import type { UsageDisplayAs } from "./preferences";
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
/**
* The usage options, folded above the reports: whether the sidebar shows the summary (the same
* switch as Settings > Sidebar) and whether percents read as used or remaining.
*/
export function UsageOptions({ display }: { display: UsageDisplay }) {
const sidebarItems = useSidebarNavItems("footer");
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
const { setVisible } = sidebarItems;
const setInSidebar = useCallback(
(visible: boolean) => setVisible("usage", visible),
[setVisible],
);
return (
<View style={settingsStyles.section}>
<SettingsCard>
<SettingsCollapsibleRow label={usageCopy.options} testID="usage-options">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<SettingsRow label={usageCopy.displayAs}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
testID="usage-display-as"
/>
</SettingsRow>
</SettingsCollapsibleRow>
</SettingsCard>
</View>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
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 { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/** Refreshes every report on the host; spins while a refresh is in flight. */
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
<ToolbarButton
label={usageCopy.refreshAll}
compact={compact}
disabled={busy}
onPress={onRefresh}
testID="usage-refresh-all"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
);
}
+3 -1
View File
@@ -29,6 +29,7 @@ import {
type PinnedUsageSource,
} from "./pinned";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody } from "./usage-section";
/** Each summary window with data on the usage host, under its source; empty while none has. */
@@ -134,7 +135,7 @@ function HostUsageSheet({
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection, display);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
return (
<SidebarPopoverSurface
section="footer"
@@ -143,6 +144,7 @@ function HostUsageSheet({
testID="sidebar-usage-sheet"
>
<View style={styles.sheetBody} testID="usage-expanded">
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</SidebarPopoverSurface>
+3 -1
View File
@@ -8,6 +8,7 @@ import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageOptions } from "./options";
import { UsageBody, UsageMessage } from "./usage-section";
// The screen is reachable by URL, so there may be no history to go back to.
@@ -61,10 +62,11 @@ function HostUsage({
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection, display);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
return (
<UsagePage actions={controls}>
<View testID={`usage-host-${serverId}`}>
{view.kind === "unavailable" ? null : <UsageOptions display={display} />}
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</UsagePage>
+2 -2
View File
@@ -27,8 +27,8 @@ export function UsageSection({
testID?: string;
}) {
const trailing = useMemo(
() => <UsageControls view={view} display={display} onRefresh={onRefresh} />,
[display, onRefresh, view],
() => <UsageControls view={view} onRefresh={onRefresh} />,
[onRefresh, view],
);
return (
+4 -11
View File
@@ -2,8 +2,6 @@ import { Pin } from "lucide-react-native";
import { useMemo } from "react";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usageCopy } from "./copy";
import { formatDisplayPct, formatResetLabel } from "./format";
@@ -36,7 +34,6 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
const isCompact = useIsCompactFormFactor();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
@@ -69,7 +66,6 @@ export function UsageWindowBar({
isAtRisk={isAtRisk}
percent={shownPct ?? 0}
tone={tone}
pinVisible={Boolean(hovered) || isNative || isCompact}
pinned={pinned}
/>
)}
@@ -85,7 +81,6 @@ function WindowRowContent({
isAtRisk,
percent,
tone,
pinVisible,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
@@ -95,7 +90,6 @@ function WindowRowContent({
isAtRisk: boolean;
percent: number;
tone: UsageTone;
pinVisible: boolean;
pinned: boolean;
}) {
return (
@@ -116,7 +110,7 @@ function WindowRowContent({
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
<UsagePinGlyph visible={pinVisible} pinned={pinned} />
<UsagePinGlyph pinned={pinned} />
</View>
</>
);
@@ -124,7 +118,7 @@ function WindowRowContent({
const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ visible, pinned }: { visible: boolean; pinned: boolean }) {
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
@@ -136,14 +130,14 @@ function UsagePinGlyph({ visible, pinned }: { visible: boolean; pinned: boolean
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<View
style={visible ? styles.pin : styles.pinHidden}
style={styles.pin}
testID={pinned ? "usage-pin-glyph-pinned" : "usage-pin-glyph-unpinned"}
>
<ThemedPin size={12} uniProps={iconMapping} />
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>{usageCopy.pin}</Text>
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
</TooltipContent>
</Tooltip>
);
@@ -153,7 +147,6 @@ const styles = StyleSheet.create((theme) => ({
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
windowContent: { flex: 1, gap: 3 },
pin: { width: 12, alignItems: "center" },
pinHidden: { width: 12, alignItems: "center", opacity: 0 },
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
row: {
gap: 3,