diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.test.tsx
new file mode 100644
index 000000000..f3e5b4252
--- /dev/null
+++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.test.tsx
@@ -0,0 +1,34 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { TooltipProvider } from "@bb/shared-ui/tooltip";
+import { afterEach, describe, expect, it } from "vitest";
+import { Usage } from "./UsageLimitsSettingsSection.stories";
+
+afterEach(cleanup);
+
+describe("settings/Settings Page/Usage story", () => {
+ it("renders the usage state gallery", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("complete usage")).toBeDefined();
+ expect(screen.getAllByText("Fable").length).toBeGreaterThan(0);
+ expect(screen.getByText("authentication")).toBeDefined();
+ expect(screen.getByText("provider responses")).toBeDefined();
+ expect(screen.getByText("loading")).toBeDefined();
+ expect(screen.getByText("request failed")).toBeDefined();
+ expect(screen.getByText("multiple machines")).toBeDefined();
+ expect(
+ screen.getByRole("button", { name: "Usage limits machine" }),
+ ).toBeDefined();
+
+ const providerHeadingIds = screen
+ .getAllByRole("region")
+ .map((region) => region.getAttribute("aria-labelledby"));
+ expect(new Set(providerHeadingIds).size).toBe(providerHeadingIds.length);
+ });
+});
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx
new file mode 100644
index 000000000..596123061
--- /dev/null
+++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx
@@ -0,0 +1,225 @@
+import { useState, type ReactNode } from "react";
+import type { Host } from "@bb/domain";
+import { StoryCard, StoryRow } from "../../../.ladle/story-card";
+import {
+ UsageLimitsSettingsSectionContent,
+ type UsageLimitsSettingsSectionContentProps,
+} from "./UsageLimitsSettingsSection";
+
+export default {
+ title: "settings/Settings Page",
+};
+
+type Usage = UsageLimitsSettingsSectionContentProps["usage"];
+
+const noop = () => {};
+
+function futureIso(minutesFromNow: number): string {
+ return new Date(Date.now() + minutesFromNow * 60_000).toISOString();
+}
+
+const HEALTHY_USAGE: Usage = {
+ codex: {
+ status: "ok",
+ accountEmail: "sawyer@example.com",
+ planLabel: "Pro",
+ windows: [
+ {
+ label: "Weekly usage limit",
+ usedPercent: 8,
+ resetsAt: futureIso(5 * 24 * 60),
+ },
+ ],
+ },
+ claudeCode: {
+ status: "ok",
+ accountEmail: "sawyer@example.com",
+ planLabel: "Max (20x)",
+ windows: [
+ {
+ label: "Current session",
+ usedPercent: 53,
+ resetsAt: futureIso(187),
+ },
+ {
+ label: "All models",
+ usedPercent: 25,
+ resetsAt: futureIso(67),
+ },
+ {
+ label: "Fable",
+ usedPercent: 48,
+ resetsAt: futureIso(67),
+ },
+ ],
+ },
+ cursor: {
+ status: "ok",
+ accountEmail: "sawyer@example.com",
+ planLabel: "Pro",
+ windows: [
+ {
+ label: "Plan usage",
+ usedPercent: 72,
+ resetsAt: futureIso(14 * 24 * 60),
+ },
+ {
+ label: "On-demand spend",
+ usedPercent: 25,
+ resetsAt: futureIso(14 * 24 * 60),
+ cost: { usedUsdCents: 1_250, limitUsdCents: 5_000 },
+ },
+ ],
+ },
+};
+
+const AUTH_USAGE: Usage = {
+ codex: { status: "unauthenticated" },
+ claudeCode: { status: "expired" },
+ cursor: { status: "not_installed" },
+};
+
+const EMPTY_AND_ERROR_USAGE: Usage = {
+ codex: {
+ status: "ok",
+ accountEmail: null,
+ planLabel: "Team",
+ windows: [],
+ },
+ claudeCode: {
+ status: "error",
+ message: "Claude usage is temporarily unavailable.",
+ },
+ cursor: { status: "not_installed" },
+};
+
+const HOSTS: Host[] = [
+ {
+ id: "host-macbook",
+ name: "MacBook Pro",
+ type: "persistent",
+ status: "connected",
+ lastSeenAt: 1_700_000_000_000,
+ maxPermissionMode: "full",
+ lastRejectedProtocolVersion: null,
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ {
+ id: "host-studio",
+ name: "Mac Studio",
+ type: "persistent",
+ status: "connected",
+ lastSeenAt: 1_700_000_000_000,
+ maxPermissionMode: "full",
+ lastRejectedProtocolVersion: null,
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ {
+ id: "host-build",
+ name: "Build machine",
+ type: "persistent",
+ status: "disconnected",
+ lastSeenAt: 1_700_000_000_000,
+ maxPermissionMode: "full",
+ lastRejectedProtocolVersion: null,
+ createdAt: 1,
+ updatedAt: 2,
+ },
+];
+
+function Stage({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+type UsagePreviewProps = Pick &
+ Partial<
+ Pick<
+ UsageLimitsSettingsSectionContentProps,
+ | "hosts"
+ | "isError"
+ | "isFetching"
+ | "isLoading"
+ | "onSelectHost"
+ | "selectedHostId"
+ >
+ >;
+
+function UsagePreview({
+ usage,
+ hosts,
+ isError = false,
+ isFetching = false,
+ isLoading = false,
+ onSelectHost,
+ selectedHostId,
+}: UsagePreviewProps) {
+ return (
+
+
+
+ );
+}
+
+function MultipleMachinesPreview() {
+ const [selectedHostId, setSelectedHostId] = useState(HOSTS[0]?.id ?? null);
+
+ return (
+
+ );
+}
+
+export function Usage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx
index 7fed8317b..c98d88abc 100644
--- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx
+++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx
@@ -63,6 +63,7 @@ describe("UsageLimitsSettingsSectionContent", () => {
});
expect(screen.getByRole("heading", { name: "Cursor" })).toBeDefined();
+ expect(screen.getByRole("region", { name: "Cursor" })).toBeDefined();
expect(screen.getByText("cursor@example.com")).toBeDefined();
expect(screen.getByText("Plan usage")).toBeDefined();
expect(screen.getByText("50% used")).toBeDefined();
@@ -86,6 +87,20 @@ describe("UsageLimitsSettingsSectionContent", () => {
expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined();
});
+ it("keeps states without usage bars with the provider heading", () => {
+ renderContent({
+ usage: { codex: { status: "unauthenticated" } },
+ isLoading: false,
+ isError: false,
+ isFetching: false,
+ onRefresh: vi.fn(),
+ });
+
+ const heading = screen.getByRole("heading", { name: "Codex" });
+ const status = screen.getByText(/Run `codex` to sign in/u);
+ expect(heading.parentElement?.contains(status)).toBe(true);
+ });
+
it("selects which connected machine supplies usage", () => {
const onSelectHost = vi.fn();
renderContent({
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
index 9689356f7..10742f633 100644
--- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
+++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useId, useState } from "react";
import type { Host } from "@bb/domain";
import type {
ProviderUsage,
@@ -6,7 +6,11 @@ import type {
} from "@bb/host-daemon-contract";
import { Button } from "@bb/shared-ui/button";
import { Icon } from "@bb/shared-ui/icon";
-import { SettingsSection } from "@/components/ui/settings-section";
+import {
+ SettingsBadge,
+ SettingsRowList,
+ SettingsSection,
+} from "@/components/ui/settings-section";
import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
import {
DropdownMenu,
@@ -20,11 +24,16 @@ import {
useSystemUsageLimits,
} from "@/hooks/queries/system-queries";
import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries";
+import {
+ getProviderIconColorClass,
+ getProviderIconInfo,
+} from "@/lib/provider-icon";
import { cn } from "@bb/shared-ui/lib/utils";
interface ProviderConfig {
key: "codex" | "claudeCode" | "cursor";
name: string;
+ providerId: "codex" | "claude-code" | "acp-cursor";
signInHint: string;
expiredHint: string;
}
@@ -33,12 +42,14 @@ const PROVIDERS: ProviderConfig[] = [
{
key: "codex",
name: "Codex",
+ providerId: "codex",
signInHint: "Run `codex` to sign in and see your usage.",
expiredHint: "Your Codex session expired. Run `codex`, then reload usage.",
},
{
key: "claudeCode",
name: "Claude Code",
+ providerId: "claude-code",
signInHint: "Run `claude` to sign in and see your usage.",
expiredHint:
"Your Claude session expired. Run `claude`, then reload usage.",
@@ -46,6 +57,7 @@ const PROVIDERS: ProviderConfig[] = [
{
key: "cursor",
name: "Cursor",
+ providerId: "acp-cursor",
signInHint: "Run `cursor-agent login` to sign in and see your usage.",
expiredHint:
"Your Cursor session expired. Run `cursor-agent login`, then reload usage.",
@@ -222,29 +234,66 @@ function ProviderUsageBlock({
}: ProviderUsageBlockProps) {
const planLabel = usage?.status === "ok" ? usage.planLabel : null;
const accountEmail = usage?.status === "ok" ? usage.accountEmail : null;
+ const iconInfo = getProviderIconInfo(config.providerId);
+ const ProviderIcon = iconInfo?.icon;
+ const headingId = useId();
+ const showsUsageWindows =
+ !isError && usage?.status === "ok" && usage.windows.length > 0;
return (
-
+
-
-
{config.name}
- {accountEmail ? (
-
- {accountEmail}
-
+
+ {ProviderIcon ? (
+
+
+
) : null}
+
+
+ {config.name}
+
+ {accountEmail ? (
+
+ {accountEmail}
+
+ ) : null}
+ {!showsUsageWindows ? (
+
+ ) : null}
+
- {planLabel ? (
-
{planLabel}
- ) : null}
+ {planLabel ?
{planLabel} : null}
-
-
+ {showsUsageWindows ? (
+
+ ) : null}
+
);
}
@@ -352,18 +401,17 @@ export function UsageLimitsSettingsSectionContent({
}
>
-
+
{visibleProviders.map((config) => (
-
+
))}
-
+
);
}
diff --git a/apps/app/src/components/tools/Automations.stories.test.tsx b/apps/app/src/components/tools/Automations.stories.test.tsx
new file mode 100644
index 000000000..b62fd81d1
--- /dev/null
+++ b/apps/app/src/components/tools/Automations.stories.test.tsx
@@ -0,0 +1,22 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it } from "vitest";
+import { BreadcrumbNavigation } from "./Automations.stories";
+
+afterEach(cleanup);
+
+describe("Automations stories", () => {
+ it("uses the preview router for breadcrumb navigation", async () => {
+ render(
+
+
+ ,
+ );
+
+ expect(
+ await screen.findByText("/plugins/automations/automations/browse"),
+ ).toBeDefined();
+ });
+});
diff --git a/apps/app/src/components/tools/Automations.stories.tsx b/apps/app/src/components/tools/Automations.stories.tsx
index c58e3e3a1..3f2f25050 100644
--- a/apps/app/src/components/tools/Automations.stories.tsx
+++ b/apps/app/src/components/tools/Automations.stories.tsx
@@ -1,5 +1,5 @@
-import { useState, type CSSProperties, type ReactNode } from "react";
-import { Link, MemoryRouter, useLocation } from "react-router-dom";
+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
+import { Link, useLocation, useNavigate } from "react-router-dom";
import { AutomationDetailView } from "bb-plugin-automations/detail-view";
import {
AutomationOverviewView,
@@ -257,11 +257,11 @@ function BreadcrumbFlowHarness() {
}
export function BreadcrumbNavigation() {
- return (
-
-
-
- );
+ const navigate = useNavigate();
+ useEffect(() => {
+ navigate(`${AUTOMATIONS_ROOT}/browse`, { replace: true });
+ }, [navigate]);
+ return ;
}
const DETAIL_AUTOMATION = automation("nightly-digest", "Nightly digest", {
diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx
index 59d5d5db7..b8ee4b22a 100644
--- a/apps/app/src/views/SettingsView.stories.tsx
+++ b/apps/app/src/views/SettingsView.stories.tsx
@@ -431,11 +431,9 @@ export function Appearance() {
);
}
-export function UsageAndFiles() {
+export function Files() {
return (
-
-
);
diff --git a/apps/host-daemon/src/provider-usage.test.ts b/apps/host-daemon/src/provider-usage.test.ts
index 7bc5538e8..0ebb13f62 100644
--- a/apps/host-daemon/src/provider-usage.test.ts
+++ b/apps/host-daemon/src/provider-usage.test.ts
@@ -87,6 +87,34 @@ describe("normalizeCodexUsage", () => {
});
});
+ it("labels a weekly primary window from its duration", () => {
+ const resetAt = 1_786_380_099;
+ expect(
+ normalizeCodexUsage({
+ plan_type: "pro",
+ rate_limit: {
+ primary_window: {
+ used_percent: 8,
+ limit_window_seconds: 604_800,
+ reset_at: resetAt,
+ },
+ secondary_window: null,
+ },
+ }),
+ ).toEqual({
+ status: "ok",
+ accountEmail: null,
+ planLabel: "Pro",
+ windows: [
+ {
+ label: "Weekly limit",
+ usedPercent: 8,
+ resetsAt: new Date(resetAt * 1000).toISOString(),
+ },
+ ],
+ });
+ });
+
it("flags a malformed payload instead of inventing numbers", () => {
const result = normalizeCodexUsage({
rate_limit: { primary_window: { used_percent: "lots" } },
@@ -102,13 +130,29 @@ describe("normalizeClaudeUsage", () => {
subscriptionType: "max",
};
- it("maps the session and weekly windows and derives the plan label", () => {
+ it("maps session, weekly, and model-scoped windows and derives the plan label", () => {
const result = normalizeClaudeUsage(
{
five_hour: { utilization: 0, resets_at: "2026-06-19T22:00:00.000Z" },
seven_day: { utilization: 18.4, resets_at: "2026-06-24T14:23:00.000Z" },
- // Model-specific sub-limits are intentionally ignored.
seven_day_sonnet: { utilization: 0, resets_at: null },
+ limits: [
+ {
+ kind: "session",
+ scope: null,
+ percent: 0,
+ resets_at: "2026-06-19T22:00:00.000Z",
+ },
+ {
+ kind: "weekly_scoped",
+ scope: {
+ model: { id: null, display_name: "Fable" },
+ surface: null,
+ },
+ percent: 48.2,
+ resets_at: "2026-06-24T14:22:59.000Z",
+ },
+ ],
},
credentials,
"claude@example.com",
@@ -129,6 +173,11 @@ describe("normalizeClaudeUsage", () => {
usedPercent: 18,
resetsAt: "2026-06-24T14:23:00.000Z",
},
+ {
+ label: "Fable",
+ usedPercent: 48,
+ resetsAt: "2026-06-24T14:22:59.000Z",
+ },
],
});
});
@@ -138,6 +187,20 @@ describe("normalizeClaudeUsage", () => {
{
five_hour: { utilization: 7, resets_at: null },
seven_day: { resets_at: "2026-06-24T14:23:00.000Z" },
+ limits: [
+ {
+ kind: "weekly_scoped",
+ scope: { model: null },
+ percent: 25,
+ resets_at: "2026-06-24T14:23:00.000Z",
+ },
+ {
+ kind: "weekly_scoped",
+ scope: { model: { display_name: "Fable" } },
+ percent: null,
+ resets_at: "2026-06-24T14:23:00.000Z",
+ },
+ ],
},
{ accessToken: "token" },
);
@@ -149,6 +212,79 @@ describe("normalizeClaudeUsage", () => {
windows: [{ label: "Current session", usedPercent: 7, resetsAt: null }],
});
});
+
+ it("keeps valid usage when one optional scoped row is malformed", () => {
+ const result = normalizeClaudeUsage(
+ {
+ five_hour: { utilization: 7, resets_at: null },
+ seven_day: { utilization: 18, resets_at: null },
+ limits: [
+ {
+ kind: "weekly_scoped",
+ scope: { model: { display_name: 42 }, surface: null },
+ percent: "lots",
+ resets_at: null,
+ },
+ {
+ kind: "weekly_scoped",
+ scope: { model: { display_name: "Fable" }, surface: null },
+ percent: 48,
+ resets_at: null,
+ },
+ ],
+ },
+ { accessToken: "token" },
+ );
+
+ expect(result).toEqual({
+ status: "ok",
+ accountEmail: null,
+ planLabel: null,
+ windows: [
+ { label: "Current session", usedPercent: 7, resetsAt: null },
+ { label: "Weekly limit", usedPercent: 18, resetsAt: null },
+ { label: "Fable", usedPercent: 48, resetsAt: null },
+ ],
+ });
+ });
+
+ it("drops surface-scoped and duplicate model rows", () => {
+ const result = normalizeClaudeUsage(
+ {
+ limits: [
+ {
+ kind: "weekly_scoped",
+ scope: {
+ model: { display_name: "Fable" },
+ surface: { display_name: "Claude Code" },
+ },
+ percent: 20,
+ resets_at: null,
+ },
+ {
+ kind: "weekly_scoped",
+ scope: { model: { display_name: "Fable" }, surface: null },
+ percent: 48,
+ resets_at: null,
+ },
+ {
+ kind: "weekly_scoped",
+ scope: { model: { display_name: "fable" }, surface: null },
+ percent: 52,
+ resets_at: null,
+ },
+ ],
+ },
+ { accessToken: "token" },
+ );
+
+ expect(result).toEqual({
+ status: "ok",
+ accountEmail: null,
+ planLabel: null,
+ windows: [{ label: "Fable", usedPercent: 48, resetsAt: null }],
+ });
+ });
});
describe("normalizeCursorUsage", () => {
diff --git a/apps/host-daemon/src/provider-usage.ts b/apps/host-daemon/src/provider-usage.ts
index 7e4f4f177..63da54a6b 100644
--- a/apps/host-daemon/src/provider-usage.ts
+++ b/apps/host-daemon/src/provider-usage.ts
@@ -107,11 +107,13 @@ function codexPlanLabel(planType: string | null | undefined): string | null {
function codexWindow(
window: z.infer | null | undefined,
- label: string,
+ fallbackLabel: string,
): ProviderUsageWindow | null {
if (!window) {
return null;
}
+ const label =
+ window.limit_window_seconds === 604_800 ? "Weekly limit" : fallbackLabel;
return {
label,
usedPercent: clampPercent(window.used_percent),
@@ -251,10 +253,32 @@ const claudeUsageWindowSchema = z.object({
resets_at: z.string().nullish(),
});
+const claudeScopedUsageLimitSchema = z
+ .object({
+ kind: z.string(),
+ scope: z
+ .object({
+ model: z
+ .object({ display_name: z.string().trim().min(1).nullish() })
+ .nullish(),
+ // Surface-specific buckets need a distinct display identity. Until the
+ // provider documents that shape, accept only the aggregate model row.
+ surface: z.null().optional(),
+ })
+ .nullish(),
+ percent: z.number().nullish(),
+ resets_at: z.string().nullish(),
+ })
+ .passthrough();
+
const claudeUsageResponseSchema = z
.object({
five_hour: claudeUsageWindowSchema.nullish(),
seven_day: claudeUsageWindowSchema.nullish(),
+ limits: z
+ .array(claudeScopedUsageLimitSchema.nullable().catch(null))
+ .nullish()
+ .catch([]),
})
.passthrough();
@@ -359,6 +383,37 @@ function claudeWindow(
};
}
+function claudeScopedWindows(
+ limits:
+ | (z.infer | null)[]
+ | null
+ | undefined,
+): ProviderUsageWindow[] {
+ // `limits` repeats the aggregate session/week rows and adds model buckets.
+ // Only the model-scoped weekly rows are additive to the legacy top-level data.
+ const windows: ProviderUsageWindow[] = [];
+ const seenLabels = new Set();
+ for (const limit of limits ?? []) {
+ const label = limit?.scope?.model?.display_name;
+ if (
+ limit == null ||
+ limit.kind !== "weekly_scoped" ||
+ label == null ||
+ limit.percent == null ||
+ seenLabels.has(label.toLowerCase())
+ ) {
+ continue;
+ }
+ seenLabels.add(label.toLowerCase());
+ windows.push({
+ label,
+ usedPercent: clampPercent(limit.percent),
+ resetsAt: normalizeIsoTimestamp(limit.resets_at),
+ });
+ }
+ return windows;
+}
+
function normalizeClaudeUsage(
raw: unknown,
credentials: ClaudeCredentials,
@@ -372,6 +427,7 @@ function normalizeClaudeUsage(
const windows = [
claudeWindow(parsed.data.five_hour, "Current session"),
claudeWindow(parsed.data.seven_day, "Weekly limit"),
+ ...claudeScopedWindows(parsed.data.limits),
].filter((window): window is ProviderUsageWindow => window !== null);
return {
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index effee13b1..5f5554bbf 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -35,7 +35,7 @@ import {
providerCliStatusResponseSchema,
} from "./local.js";
-export const HOST_DAEMON_PROTOCOL_VERSION = 70 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 71 as const;
export {
BRANCH_LIST_LIMIT_MAX,
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index dfcbf6461..af9660c78 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -1023,11 +1023,11 @@ describe("host-daemon local schemas", () => {
});
describe("host-daemon command schemas", () => {
- // Codex structured inference gained a required reasoning-effort field in
- // version 70. Older daemons reject that strict command shape, so the bump
- // forces an update before the server can send it.
- it("uses protocol version 70 for explicit Codex inference reasoning", () => {
- expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(70);
+ // Provider usage gained Claude model-scoped windows and duration-aware Codex
+ // labels in version 71. Older daemons omit or mislabel them, so the bump
+ // forces an update before the server requests provider usage.
+ it("uses protocol version 71 for provider usage normalization", () => {
+ expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(71);
});
it("binds Plan cancellation to a required turn id and typed result", () => {