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