Streamline sidebar usage and stacked sheet interactions (#5805)

* feat(app): streamline sidebar usage and stacked sheets

* fix(app): restore compact sidebar navigation clicks

The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.

* fix(app): cap menu sheets at eighty percent height

Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
This commit is contained in:
Mohamed Boudra
2026-10-01 04:55:17 +01:00
committed by GitHub
parent 15f1431416
commit e939dd4c7a
65 changed files with 1292 additions and 527 deletions
+9 -6
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
@@ -9,19 +10,20 @@ interface ResolvedBalance {
usedPct: number | null;
}
function resolveBalance(balance: UsageBalance): ResolvedBalance {
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
const usedText = usedAmount != null ? format(usedAmount) : "—";
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
return { amountText: `${format(remaining)} left`, usedPct: null };
}
if (used != null) {
return { amountText: formatAmount(used, unit), usedPct: null };
return { amountText: format(used), usedPct: null };
}
return { amountText: "—", usedPct: null };
}
@@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) {
}
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
+4 -32
View File
@@ -1,22 +1,13 @@
import { RotateCw } from "lucide-react-native";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { HostFilter } from "@/components/hosts/host-filter";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMemo, type ReactElement } from "react";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { UsageDisplayToggle } from "./display-toggle";
import { UsageOptionsMenu } from "./options-menu";
import type { UsageHost } from "./model";
import { useHostUsage } from "./queries";
import type { UsageView } from "./types";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/** The hosts to choose between, and which one is shown. */
export interface UsageHostSelection {
hosts: UsageHost[];
@@ -26,8 +17,7 @@ export interface UsageHostSelection {
/**
* The controls on the right of every usage title row: the host filter when there is more than one
* host, the used/remaining toggle and Refresh. A host that cannot report usage keeps only the
* host filter.
* host, and the options menu. A host that cannot report usage keeps only the host filter.
*/
export function UsageControls({
view,
@@ -41,8 +31,6 @@ export function UsageControls({
hostSelection?: UsageHostSelection;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
<View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 1 ? (
@@ -56,23 +44,7 @@ export function UsageControls({
/>
) : null}
{view.kind === "unavailable" ? null : (
<>
<UsageDisplayToggle display={display} />
{/* The Changes panel's refresh: an icon, with the label in its tooltip. */}
<ToolbarButton
label={busy ? usageCopy.refreshing : usageCopy.refresh}
compact={compact}
disabled={busy}
onPress={onRefresh}
testID="usage-refresh-all"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
</>
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
)}
</View>
);
+3
View File
@@ -3,6 +3,7 @@
export const usageCopy = {
title: "Usage",
planUsage: "Plan usage",
options: "Usage options",
refresh: "Refresh",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
@@ -16,6 +17,8 @@ export const usageCopy = {
clientUnavailable: "Host connection is not ready",
retry: "Try again",
pin: "Pin",
displayAs: "Show",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Show in sidebar",
} as const;
-22
View File
@@ -1,22 +0,0 @@
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import type { UsageDisplayAs } from "./preferences";
const OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
/** Switches every usage surface between the share used and the share left. */
export function UsageDisplayToggle({ display }: { display: UsageDisplay }) {
return (
<SegmentedControl
size="xs"
options={OPTIONS}
value={display.displayAs}
onValueChange={display.setDisplayAs}
testID="usage-display-toggle"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatAmount } from "./format";
describe("formatAmount", () => {
it("groups thousands in the app's language", () => {
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345");
});
it("formats dollars as the language writes currency", () => {
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
});
+4 -3
View File
@@ -34,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
export function formatAmount(value: number, unit: UsageBalanceUnit): string {
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
switch (unit) {
case "usd":
return `$${value.toFixed(2)}`;
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
case "tokens":
return formatTokenCount(value);
default:
return value.toLocaleString();
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { HostUsageSection } from "./host-usage-section";
export { UsageScreen } from "./usage-screen";
export { UsageSidebarItem } from "./sidebar-item";
export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item";
+74
View File
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct } from "./format";
import type { UsageTone } from "./types";
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
/**
* A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead,
* for summaries where a row of coloured bars would be noise. The caller sizes the track.
*/
export function UsageMeter({
percent,
tone,
quiet = false,
style,
}: {
percent: number;
tone: UsageTone;
quiet?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const fillWidth = clampPct(percent);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, quiet, tone],
);
return (
<View style={[styles.track, style]}>
<View style={fillStyle} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
// Follows the track, so a caller that sizes the track sizes the bar.
fill: {
height: "100%",
borderRadius: theme.borderRadius.full,
},
fillQuiet: {
backgroundColor: theme.colors.surface4,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+103
View File
@@ -0,0 +1,103 @@
import { Settings } from "lucide-react-native";
import { useCallback } from "react";
import { withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
const ThemedSettings = withUnistyles(Settings);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
/**
* The usage title row's options: Refresh every report, whether percents read as used or
* remaining, and whether the sidebar shows the Usage item (the same switch as Settings > Sidebar). The trigger spins while a refresh is in flight, since the menu hides the one it came
* from.
*/
export function UsageOptionsMenu({
display,
busy,
onRefresh,
}: {
display: UsageDisplay;
busy: boolean;
onRefresh: () => void;
}) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
const { displayAs, setDisplayAs } = display;
const showUsed = useCallback(() => setDisplayAs("used"), [setDisplayAs]);
const showRemaining = useCallback(() => setDisplayAs("remaining"), [setDisplayAs]);
const sidebarItems = useSidebarNavItems("footer");
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
const { setVisible } = sidebarItems;
const toggleInSidebar = useCallback(
() => setVisible("usage", !inSidebar),
[inSidebar, setVisible],
);
return (
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
compact={compact}
testID="usage-options-menu"
>
{busy ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedSettings size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
<DropdownMenuContent
align="end"
width={200}
sheetTitle={usageCopy.options}
testID="usage-options-menu-content"
>
<DropdownMenuItem
status={busy ? "pending" : "idle"}
pendingLabel={usageCopy.refreshing}
onSelect={onRefresh}
testID="usage-refresh-all"
>
{usageCopy.refresh}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{usageCopy.displayAs}</DropdownMenuLabel>
<DropdownMenuItem
selected={displayAs === "used"}
onSelect={showUsed}
testID="usage-display-used"
>
{usageCopy.displayUsed}
</DropdownMenuItem>
<DropdownMenuItem
selected={displayAs === "remaining"}
onSelect={showRemaining}
testID="usage-display-remaining"
>
{usageCopy.displayRemaining}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
selected={inSidebar}
onSelect={toggleInSidebar}
testID="usage-show-in-sidebar"
>
{usageCopy.showInSidebar}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+112 -27
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import type { UsagePreferences } from "./preferences";
import { resolvePinnedUsage } from "./pinned";
import {
choosePinnedUsageLayout,
resolvePinnedUsage,
type PinnedUsageSource,
type PinnedUsageWindow,
} from "./pinned";
import type { UsageReportEntry, UsageWindow } from "./types";
function report(input: {
@@ -26,8 +31,8 @@ const claude = report({
sourceLabel: "Claude",
icon: "<svg/>",
windows: [
{ id: "five-hour", label: "5-hour", usedPct: 31 },
{ id: "weekly", label: "Weekly", usedPct: 80 },
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 },
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 },
],
});
const codex = report({
@@ -36,6 +41,10 @@ const codex = report({
windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }],
});
function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] {
return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows);
}
function preferences(
pinned: UsagePreferences["pinned"],
displayAs: UsagePreferences["displayAs"] = "used",
@@ -43,6 +52,26 @@ function preferences(
return { displayAs, pinned, serverId: null };
}
describe("choosePinnedUsageLayout", () => {
const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 };
it("shows meters only when every window's meter fits beside the labels", () => {
expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters");
expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels");
});
it("drops the labels before it lets the line overflow", () => {
expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents");
});
it("shows labels until the widths are measured", () => {
expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels");
expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe(
"labels",
);
});
});
describe("resolvePinnedUsage", () => {
it("defaults to the first window with a percent for each source account", () => {
const work = report({
@@ -60,57 +89,113 @@ describe("resolvePinnedUsage", () => {
windows: [{ id: "empty", label: "Empty" }],
});
expect(
resolvePinnedUsage([claude, codex, work, empty], preferences([])).map((item) => [
windows([claude, codex, work, empty], preferences([])).map((item) => [
item.key,
item.percentText,
]),
).toEqual([
["claude:default/five-hour", "31%"],
["codex:default/weekly", "12%"],
["claude:work/weekly", "90%"],
["codex:default/weekly", "12%"],
]);
});
it("pins replace defaults, and removing all pins restores defaults", () => {
const reports = [claude, codex];
expect(
resolvePinnedUsage(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map(
windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map(
(item) => item.key,
),
).toEqual(["claude:default/weekly"]);
expect(resolvePinnedUsage(reports, preferences([])).map((item) => item.key)).toEqual([
expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([
"claude:default/five-hour",
"codex:default/weekly",
]);
});
it("shows pinned windows in pin order, not report order", () => {
const items = resolvePinnedUsage(
[claude, codex],
it("groups pinned windows by source in report order, not pin order", () => {
const work = report({
sourceId: "claude",
sourceLabel: "Claude",
account: "work",
windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }],
});
const sources = resolvePinnedUsage(
[claude, codex, work],
preferences([
{ sourceId: "claude", windowId: "weekly" },
{ sourceId: "codex", windowId: "weekly" },
{ sourceId: "claude", windowId: "five-hour" },
]),
);
expect(items).toEqual([
{
key: "codex:default/weekly",
icon: null,
label: "Codex Weekly 12% used",
percentText: "12%",
},
{
key: "claude:default/five-hour",
icon: "<svg/>",
label: "Claude 5-hour 31% used",
percentText: "31%",
},
const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key);
expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([
["claude:default", ["claude:default/five-hour", "claude:default/weekly"]],
["claude:work", ["claude:work/weekly"]],
["codex:default", ["codex:default/weekly"]],
]);
expect(sources[0]).toEqual({
key: "claude:default",
icon: "<svg/>",
windows: [
{
key: "claude:default/five-hour",
label: "Claude Session 31% used",
shortLabel: "5h",
percent: 31,
percentText: "31%",
tone: "default",
},
{
key: "claude:default/weekly",
label: "Claude Weekly 80% used",
shortLabel: "wk",
percent: 80,
percentText: "80%",
tone: "warning",
},
],
});
});
it("names a window by its label when the source gives no short label", () => {
const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }]));
expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]);
});
it("defaults to every window the source marks as summary, when it marks any", () => {
const marked = report({
sourceId: "claude",
sourceLabel: "Claude",
windows: [
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true },
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true },
{ id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 },
],
});
expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([
"claude:default/five-hour",
"claude:default/weekly",
"codex:default/weekly",
]);
});
it("keeps an empty short label empty, for a percent that needs no name", () => {
const opencode = report({
sourceId: "opencode",
sourceLabel: "OpenCode Go",
windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }],
});
const items = windows([opencode], preferences([]));
expect(items.map((item) => item.shortLabel)).toEqual([""]);
});
it("formats the share left when the user reads usage as remaining", () => {
const items = resolvePinnedUsage(
const items = windows(
[claude, codex],
preferences(
[
@@ -123,7 +208,7 @@ describe("resolvePinnedUsage", () => {
expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]);
expect(items.map((item) => item.label)).toEqual([
"Claude 5-hour 69% left",
"Claude Session 69% left",
"Codex Weekly 88% left",
]);
});
@@ -134,7 +219,7 @@ describe("resolvePinnedUsage", () => {
sourceLabel: "OpenCode",
windows: [{ id: "monthly", label: "Monthly" }],
});
const items = resolvePinnedUsage(
const items = windows(
[claude, noPercent],
preferences([
{ sourceId: "claude", windowId: "monthly" },
@@ -159,7 +244,7 @@ describe("resolvePinnedUsage", () => {
account: "work",
windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }],
});
const items = resolvePinnedUsage(
const items = windows(
[personal, codex, work],
preferences([{ sourceId: "claude", windowId: "five-hour" }]),
);
+81 -31
View File
@@ -1,15 +1,53 @@
import { formatPct } from "./format";
import { displayPercent } from "./model";
import type { UsagePreferences } from "./preferences";
import type { UsageReportEntry } from "./types";
import { windowTone } from "./tone";
import type { UsageReportEntry, UsageTone, UsageWindow } from "./types";
/** One summary window of one account, as the sidebar Usage item shows it. */
export interface PinnedUsageWindow {
key: string;
icon: string | null;
/** Accessible description, including the source, account, window and percent meaning. */
label: string;
/** The window's short name ("5h"), empty for none, or its label when the source sends none. */
shortLabel: string;
/** The share shown, under the user's used/remaining preference. */
percent: number;
percentText: string;
tone: UsageTone;
}
/** One account's summary windows, under its source's icon. */
export interface PinnedUsageSource {
key: string;
icon: string | null;
windows: PinnedUsageWindow[];
}
/** How much of each window the one-line summary has room for. */
export type PinnedUsageLayout = "meters" | "labels" | "percents";
// A meter narrower than this is a sliver that reads as noise rather than a share.
export const MIN_METER_WIDTH = 16;
/** The space between a meter and its text. */
export const METER_GAP = 4;
/**
* The richest layout that fits on one line: every window's meter, else its short label, else its
* percent alone. Meters are all or none; a line never mixes them. Until the widths are known it
* shows labels, which never flashes meters that turn out not to fit.
*/
export function choosePinnedUsageLayout(input: {
available: number | null;
labelsWidth: number | null;
percentsWidth: number | null;
windowCount: number;
}): PinnedUsageLayout {
const { available, labelsWidth, percentsWidth, windowCount } = input;
if (available === null || labelsWidth === null || percentsWidth === null) return "labels";
if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters";
if (labelsWidth <= available) return "labels";
return percentsWidth < labelsWidth ? "percents" : "labels";
}
function describe(entry: UsageReportEntry, windowLabel: string): string {
@@ -17,40 +55,52 @@ function describe(entry: UsageReportEntry, windowLabel: string): string {
return `${entry.sourceLabel}${account} ${windowLabel}`;
}
/** Pins replace defaults; without pins, summarize each account's first window with a percent. */
/** Reports with every account of a source together, sources in the order they first appear. */
function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] {
const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))];
return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId));
}
/**
* Pins replace defaults. Without pins, an account shows the windows its source marks as summary,
* or its first window with a percent when the source marks none.
*/
function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] {
const withPercent = entry.report.windows.filter(
(window) => displayPercent(window, preferences.displayAs) !== null,
);
if (preferences.pinned.length === 0) {
const marked = withPercent.filter((window) => window.summary);
return marked.length > 0 ? marked : withPercent.slice(0, 1);
}
return withPercent.filter((window) =>
preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id),
);
}
/**
* The sidebar Usage item's summary: one group per account, grouped by source, each with its
* windows in its report's order. Accounts with nothing to show are left out.
*/
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
): PinnedUsageWindow[] {
const selections =
preferences.pinned.length === 0
? reports.map((entry) => ({
entry,
window: entry.report.windows.find(
(window) => displayPercent(window, preferences.displayAs) !== null,
),
}))
: preferences.pinned.flatMap((pin) =>
reports
.filter((entry) => entry.sourceId === pin.sourceId)
.map((entry) => ({
entry,
window: entry.report.windows.find((window) => window.id === pin.windowId),
})),
);
return selections.flatMap(({ entry, window }) => {
if (!window) return [];
const percent = displayPercent(window, preferences.displayAs);
if (percent === null) return [];
const percentText = formatPct(percent);
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return [
{
): PinnedUsageSource[] {
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return groupBySource(reports).flatMap((entry) => {
const windows = summaryWindows(entry, preferences).map((window) => {
const percent = displayPercent(window, preferences.displayAs) ?? 0;
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
icon: entry.icon ?? null,
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
shortLabel: window.shortLabel ?? window.label,
percent,
percentText,
},
];
tone: windowTone(window),
};
});
if (windows.length === 0) return [];
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
});
}
+175 -35
View File
@@ -1,8 +1,14 @@
import { router } from "expo-router";
import { Gauge } from "lucide-react-native";
import { useCallback, useMemo, useState } from "react";
import { CircleGauge } from "lucide-react-native";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import {
Pressable,
Text,
View,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
@@ -16,13 +22,21 @@ import { useUsageHostId, useUsageHostSelection } from "./hosts";
import type { UsagePreferences } from "./preferences";
import { useHostUsage } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned";
import { UsageMeter } from "./meter";
import {
choosePinnedUsageLayout,
METER_GAP,
MIN_METER_WIDTH,
resolvePinnedUsage,
type PinnedUsageLayout,
type PinnedUsageSource,
} from "./pinned";
import type { UsageReportEntry } from "./types";
import type { UsageHost } from "./model";
import { UsageBody } from "./usage-section";
const NO_REPORTS: UsageReportEntry[] = [];
const NO_ITEMS: PinnedUsageWindow[] = [];
const NO_SOURCES: PinnedUsageSource[] = [];
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, or a plain
@@ -33,7 +47,7 @@ export function UsageSidebarItem() {
const { preferences, display } = useUsagePreferences();
const serverId = useUsageHostId();
if (!serverId) {
return <UsageEntry serverId={serverId} items={NO_ITEMS} display={display} />;
return <UsageEntry serverId={serverId} sources={NO_SOURCES} display={display} />;
}
return (
<PinnedUsageItem
@@ -56,11 +70,12 @@ function PinnedUsageItem({
}) {
const { view } = useHostUsage(serverId);
const reports = view.kind === "ready" ? view.reports : NO_REPORTS;
const items = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} items={items} display={display} />;
const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return <UsageEntry serverId={serverId} sources={sources} display={display} />;
}
function useOpenUsageScreen(): () => void {
/** Opens the Usage screen, over the sidebar on compact layouts. */
export function useOpenUsageScreen(): () => void {
const isCompact = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
return useCallback(() => {
@@ -71,11 +86,11 @@ function useOpenUsageScreen(): () => void {
function UsageEntry({
serverId,
items,
sources,
display,
}: {
serverId: string | null;
items: readonly PinnedUsageWindow[];
sources: readonly PinnedUsageSource[];
display: UsageDisplay;
}) {
const { t } = useTranslation();
@@ -97,12 +112,12 @@ function UsageEntry({
}, [openUsageScreen, usesSheet]);
const trigger =
items.length > 0 ? (
<PinnedUsageTrigger label={label} items={items} onPress={handlePress} />
sources.length > 0 ? (
<PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />
) : (
<SidebarHeaderRow
variant="inline"
icon={Gauge}
icon={CircleGauge}
label={label}
onPress={handlePress}
testID="sidebar-usage"
@@ -168,72 +183,197 @@ function HostUsageSheet({
);
}
function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string {
return `${label}: ${items.map((item) => item.label).join(", ")}`;
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
const windows = sources.flatMap((source) => source.windows);
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
}
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger;
}
function layoutWidth(event: LayoutChangeEvent): number {
return event.nativeEvent.layout.width;
}
/**
* One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure
* the line with labels and with percents alone; the richest layout that fits is shown, and what
* it leaves out takes no space at all.
*/
function PinnedUsageTrigger({
label,
items,
sources,
onPress,
}: {
label: string;
items: readonly PinnedUsageWindow[];
sources: readonly PinnedUsageSource[];
onPress: () => void;
}) {
const [available, setAvailable] = useState<number | null>(null);
const [labelsWidth, setLabelsWidth] = useState<number | null>(null);
const [percentsWidth, setPercentsWidth] = useState<number | null>(null);
const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => {
setAvailable(layoutWidth(event));
}, []);
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
setLabelsWidth(layoutWidth(event));
}, []);
const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => {
setPercentsWidth(layoutWidth(event));
}, []);
const layout = choosePinnedUsageLayout({
available,
labelsWidth,
percentsWidth,
windowCount: sources.reduce((count, source) => count + source.windows.length, 0),
});
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={pinnedUsageLabel(label, items)}
accessibilityLabel={pinnedUsageLabel(label, sources)}
style={triggerStyle}
testID="sidebar-usage"
>
{items.map((item) => (
<View key={item.key} style={styles.item} testID="sidebar-usage-pinned-window">
<UsageSourceIcon svg={item.icon} size={14} />
<Text style={styles.percent} numberOfLines={1}>
{item.percentText}
</Text>
<View style={styles.line} onLayout={handleAvailableLayout}>
<PinnedUsageLine sources={sources} layout={layout} />
</View>
<View
style={styles.measure}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
aria-hidden
>
<View style={styles.measured} onLayout={handleLabelsLayout}>
<PinnedUsageLine sources={sources} layout="labels" measuring />
</View>
))}
<View style={styles.measured} onLayout={handlePercentsLayout}>
<PinnedUsageLine sources={sources} layout="percents" measuring />
</View>
</View>
</Pressable>
);
}
/**
* The line's items as direct children of one row, so every meter shares the leftover width
* equally.
*/
function PinnedUsageLine({
sources,
layout,
measuring = false,
}: {
sources: readonly PinnedUsageSource[];
layout: PinnedUsageLayout;
measuring?: boolean;
}) {
return sources.map((source, sourceIndex) => (
<Fragment key={source.key}>
<View
style={sourceIndex === 0 ? null : styles.sourceGap}
testID={measuring ? undefined : "sidebar-usage-source"}
>
<UsageSourceIcon svg={source.icon} size={14} />
</View>
{source.windows.map((window, windowIndex) => {
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
return (
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
</View>
) : null}
<Text
style={[styles.percent, layout === "meters" ? null : leadStyle]}
numberOfLines={1}
testID={measuring ? undefined : "sidebar-usage-pinned-window"}
>
{window.percentText}
{layout === "percents" || window.shortLabel === "" ? null : (
<Text style={styles.windowLabel}>{` ${window.shortLabel}`}</Text>
)}
</Text>
</Fragment>
);
})}
</Fragment>
));
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
// A wrapping row packs its lines at the top; center them in the 28px row instead.
alignContent: "center",
columnGap: theme.spacing[3],
rowGap: theme.spacing[1],
overflow: "hidden",
// Same row geometry and leading rail as Add project and the footer icons.
minHeight: 28,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1.5],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
item: {
line: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
overflow: "hidden",
},
// Wide enough that nothing inside wraps or shrinks; each child reports its natural width.
measure: {
position: "absolute",
top: 0,
left: 0,
width: 10_000,
opacity: 0,
},
measured: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
},
sourceGap: {
marginLeft: theme.spacing[3],
},
iconGap: {
marginLeft: theme.spacing[1.5],
},
windowGap: {
marginLeft: theme.spacing[2],
},
// Every slot takes an equal share of the leftover width, so the line fills the row.
meterSlot: {
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP,
},
meter: {
height: 6,
borderRadius: 3,
},
percent: {
color: theme.colors.foregroundMuted,
flexShrink: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
windowLabel: {
color: theme.colors.foregroundMuted,
},
sheetBody: {
padding: theme.spacing[3],
gap: theme.spacing[3],
+6 -2
View File
@@ -1,6 +1,10 @@
import type { UsageTone } from "./types";
import { usedPercent } from "./model";
import type { UsageTone, UsageWindow } from "./types";
export function deriveTone(usedPct: number | null | undefined): UsageTone {
/** The source's tone for a window, or one derived from how much of it is used. */
export function windowTone(window: UsageWindow): UsageTone {
if (window.tone) return window.tone;
const usedPct = usedPercent(window);
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
+12 -52
View File
@@ -1,25 +1,13 @@
import { useMemo } from "react";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatDisplayPct, formatResetLabel } from "./format";
import { displayPercent, usageWindowRowLabel, usedPercent } from "./model";
import { formatDisplayPct, formatResetLabel } from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import type { UsageDisplayAs } from "./preferences";
import { deriveTone } from "./tone";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
function highlightStyle(pinned: boolean, hovered: boolean) {
@@ -43,15 +31,8 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
const usedPct = usedPercent(window);
const shownPct = displayPercent(window, displayAs);
const tone = window.tone ?? deriveTone(usedPct);
const fillWidth = clampPct(shownPct ?? 0);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
[fillWidth, tone],
);
const tone = windowTone(window);
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
const trailing = isAtRisk
@@ -80,7 +61,8 @@ export function UsageWindowBar({
value={value}
trailing={trailing}
isAtRisk={isAtRisk}
fillStyle={fillStyle}
percent={shownPct ?? 0}
tone={tone}
/>
)}
</Pressable>
@@ -93,14 +75,16 @@ function WindowRowContent({
value,
trailing,
isAtRisk,
fillStyle,
percent,
tone,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
trailing: string | null | undefined;
isAtRisk: boolean;
fillStyle: StyleProp<ViewStyle>;
percent: number;
tone: UsageTone;
}) {
return (
<>
@@ -116,9 +100,7 @@ function WindowRowContent({
) : null}
</Text>
</View>
<View style={styles.track}>
<View style={fillStyle} />
</View>
<UsageMeter percent={percent} tone={tone} />
</>
);
}
@@ -174,26 +156,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));