mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
+1
-1
@@ -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`).
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user