From d831c7bf33bbcad1835cf8e81668b8eebb97f3f4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 2 Oct 2026 20:59:16 +0100 Subject: [PATCH] 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 --- docs/design.md | 2 +- packages/app/e2e/browser/usage-screen.spec.ts | 89 ++++++++++++++- .../app/e2e/browser/usage-settings.spec.ts | 5 + .../e2e/browser/usage-sidebar-item.spec.ts | 36 +++--- .../e2e/support/helpers/usage-sidebar-item.ts | 17 +-- .../components/settings/collapsible-row.tsx | 102 +++++++++++++++++ .../app/src/components/settings/index.tsx | 1 + packages/app/src/usage/controls.tsx | 29 ++--- packages/app/src/usage/copy.ts | 9 +- packages/app/src/usage/options-menu.tsx | 103 ------------------ packages/app/src/usage/options.tsx | 57 ++++++++++ packages/app/src/usage/refresh-button.tsx | 31 ++++++ packages/app/src/usage/sidebar-item.tsx | 4 +- packages/app/src/usage/usage-screen.tsx | 4 +- packages/app/src/usage/usage-section.tsx | 4 +- packages/app/src/usage/window-bar.tsx | 15 +-- 16 files changed, 341 insertions(+), 167 deletions(-) create mode 100644 packages/app/src/components/settings/collapsible-row.tsx delete mode 100644 packages/app/src/usage/options-menu.tsx create mode 100644 packages/app/src/usage/options.tsx create mode 100644 packages/app/src/usage/refresh-button.tsx diff --git a/docs/design.md b/docs/design.md index e0904d917e..e21270c85d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 ``. 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 ``. 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 (`` with `` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`). diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 48625eea4e..2ca4e50830 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -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" }); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 617bd121b5..04bfe649b1 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -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(); }); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 902435a475..f42b438067 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -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); diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index d85ab3724f..42eebe65e9 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -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 { - 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 { - await openUsageOptions(page); - await page.getByRole("menuitem", { name: "Refresh", exact: true }).click(); + await visible(page, "usage-refresh-all").click(); } /** Opens the compact sidebar drawer. */ diff --git a/packages/app/src/components/settings/collapsible-row.tsx b/packages/app/src/components/settings/collapsible-row.tsx new file mode 100644 index 0000000000..219c19776c --- /dev/null +++ b/packages/app/src/components/settings/collapsible-row.tsx @@ -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 ( + + + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + <> + + {label} + + + + + + )} + + {expanded + ? Children.toArray(children).map((child, index) => ( + + {child} + + )) + : null} + + ); +} + +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" }], + }, +})); diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 00ac273bf0..f31a0e7b38 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -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 { hint?: ReactNode; diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 3fc29dd3e8..ccd58fb63e 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -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 : ( - + )} ); @@ -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( - () => ( - - ), - [display, hostSelection, refresh, view], + () => , + [hostSelection, refresh, view], ); return { view, refresh, controls }; } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 92721b69bd..cda74b464c 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -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; diff --git a/packages/app/src/usage/options-menu.tsx b/packages/app/src/usage/options-menu.tsx deleted file mode 100644 index 05184d5d5a..0000000000 --- a/packages/app/src/usage/options-menu.tsx +++ /dev/null @@ -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 ( - - - {busy ? ( - - ) : ( - - )} - - - - {usageCopy.refresh} - - - {usageCopy.displayAs} - - {usageCopy.displayUsed} - - - {usageCopy.displayRemaining} - - - - {usageCopy.showInSidebar} - - - - ); -} diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx new file mode 100644 index 0000000000..18ec7c2f1a --- /dev/null +++ b/packages/app/src/usage/options.tsx @@ -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[] = [ + { 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 ( + + + + + + + + + + + ); +} diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx new file mode 100644 index 0000000000..bf6dc787b2 --- /dev/null +++ b/packages/app/src/usage/refresh-button.tsx @@ -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 ( + + {busy ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index a0bd6a7e15..7b719bdb59 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -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 ( + {view.kind === "unavailable" ? null : } diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx index e1eaf0bb02..d8e2ba08dc 100644 --- a/packages/app/src/usage/usage-screen.tsx +++ b/packages/app/src/usage/usage-screen.tsx @@ -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 ( + {view.kind === "unavailable" ? null : } diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 44b3ecc8ce..ffb40e985d 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -27,8 +27,8 @@ export function UsageSection({ testID?: string; }) { const trailing = useMemo( - () => , - [display, onRefresh, view], + () => , + [onRefresh, view], ); return ( diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 7ad06e423a..8b668eab00 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -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; @@ -95,7 +90,6 @@ function WindowRowContent({ isAtRisk: boolean; percent: number; tone: UsageTone; - pinVisible: boolean; pinned: boolean; }) { return ( @@ -116,7 +110,7 @@ function WindowRowContent({ - + ); @@ -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 - {usageCopy.pin} + {pinned ? usageCopy.unpin : usageCopy.pin} ); @@ -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,