summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-14 17:04:35 +0900
committerAdam Malczewski <[email protected]>2026-10-14 17:04:35 +0900
commit10e78065201c8387494b8aafabb987da87183b3a (patch)
treef038f368459acbd3b817974100fb4bd4817ecbff
parent551a7a050e8cd25e795026a1060c9d5b5ea028bf (diff)
downloaddispatch-web-10e78065201c8387494b8aafabb987da87183b3a.tar.gz
dispatch-web-10e78065201c8387494b8aafabb987da87183b3a.zip
feat(account-usage): sidebar Usage view with Claude/Codex quota bars + on-track pace markers
Consumes GET /usage (transport-contract 0.25.0). DaisyUI progress bars per quota window with a dot at the elapsed fraction of the window (the 'on track' level), ahead/under-pace labels, reset countdowns, stale/unavailable/not-configured states.
-rw-r--r--.dispatch/transport-contract.reference.md117
-rw-r--r--backend-handoff.md18
-rw-r--r--src/app/register-views.ts6
-rw-r--r--src/app/store.svelte.ts29
-rw-r--r--src/app/views/AccountUsagePane.svelte16
-rw-r--r--src/app/views/ExtensionsPane.svelte2
-rw-r--r--src/features/account-usage/index.ts27
-rw-r--r--src/features/account-usage/logic/types.ts33
-rw-r--r--src/features/account-usage/logic/view-model.test.ts198
-rw-r--r--src/features/account-usage/logic/view-model.ts303
-rw-r--r--src/features/account-usage/ui/AccountUsageView.svelte127
-rw-r--r--src/features/account-usage/ui/AccountUsageView.test.ts95
-rw-r--r--src/features/account-usage/ui/UsageBar.svelte49
-rw-r--r--src/features/layout/logic/registry.ts2
14 files changed, 1019 insertions, 3 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 0a3a347..043c95b 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,6 +5,9 @@
> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for
> READING only.
>
+> **2026-10-14 delta (account-usage — ADDITIVE, `0.25.0`):** `GET /usage` + `GET /usage/:provider` and the
+> `AccountUsage*` types (verbatim at the end of this file).
+>
> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever
> it changes.
>
@@ -1253,3 +1256,117 @@ ALWAYS route to the self-hosted Firecrawl client; the tool's `map` mode is REMOV
(service not loaded). The umans entry appears in `available` only when the backend has
`UMANS_API_KEY`; an unconfigured backend is listed but `configured: false` — the UI disables it,
it is never an error.
+
+## Account usage (`[email protected]`, verbatim)
+
+```ts
+// ─── Account usage (Claude / Codex subscription quotas) ───────────────────────
+//
+// Subscription quota usage polled from each provider's first-party account
+// API (Claude `GET /api/oauth/usage`, Codex `GET /backend-api/wham/usage`),
+// cached server-side. Distinct from per-turn token `Usage`: these are the
+// account's plan allowance windows, never estimated from token counts.
+
+/** The subscription accounts the usage service reports on. */
+export type AccountUsageProviderId = "claude" | "codex";
+
+/**
+ * Freshness/availability of one provider's report:
+ * - `fresh` — the last refresh succeeded and is within the cache TTL.
+ * - `stale` — the latest refresh FAILED; `limits` are the last good
+ * report (≤ 24h old), see `fetchedAt` / `error`.
+ * - `unavailable` — refresh failed and there is no last-good report.
+ * - `unsupported` — a credential exists but cannot query subscription usage
+ * (e.g. a plain Anthropic API key).
+ * - `not-configured` — no credential / the auth extension is not loaded.
+ */
+export type AccountUsageState =
+ | "fresh"
+ | "stale"
+ | "unavailable"
+ | "unsupported"
+ | "not-configured";
+
+/** Severity of a single limit: `warning` ≥ 90% used, `exhausted` ≥ 100%. */
+export type AccountUsageLimitStatus = "ok" | "warning" | "exhausted" | "unknown";
+
+/**
+ * One quota meter. Percent meters have `unit: "percent"`, `limit: 100`.
+ * Spend meters (`kind: "spend"`, `unit: "usd"`) may have `limit: null` when the
+ * cap is unlimited/unset — never treat that as a zero-dollar cap.
+ */
+export interface AccountUsageLimit {
+ /** Stable id, e.g. `claude:5h`, `claude:7d:opus`, `codex:primary`, `codex:spark:secondary`. */
+ readonly id: string;
+ /** Human label, e.g. "5 Hour", "7 Day (Opus)", "Spark · 5 hours". */
+ readonly label: string;
+ readonly kind: "window" | "spend";
+ /** Window id, e.g. `5h`, `7d`, `30d`, `extra`. */
+ readonly windowId: string;
+ /** True for account-wide windows that gate every request. */
+ readonly shared: boolean;
+ /** Model family / metered-feature slug for scoped meters, else null. */
+ readonly tier: string | null;
+ /** Window length in ms (null when unknown, e.g. spend meters). */
+ readonly durationMs: number | null;
+ /** Epoch-ms when the window resets (null when unknown). */
+ readonly resetsAt: number | null;
+ readonly unit: "percent" | "usd";
+ readonly used: number;
+ readonly limit: number | null;
+ readonly remaining: number | null;
+ /** 0..1 (null when there is no cap). */
+ readonly usedFraction: number | null;
+ readonly remainingFraction: number | null;
+ readonly status: AccountUsageLimitStatus;
+}
+
+/** Codex paid-credit details (from `/wham/usage` `credits` + `spend_control`). */
+export interface AccountUsageCredits {
+ readonly hasCredits: boolean | null;
+ readonly unlimited: boolean | null;
+ readonly overageLimitReached: boolean | null;
+ /** Remaining credit balance as reported (string or number upstream; normalized to number). */
+ readonly balance: number | null;
+ readonly spendControlReached: boolean | null;
+}
+
+/** One provider's usage report as served to clients. Never carries credentials. */
+export interface AccountUsageReport {
+ readonly provider: AccountUsageProviderId;
+ /** Display name: "Claude" / "Codex". */
+ readonly label: string;
+ readonly state: AccountUsageState;
+ readonly account: {
+ readonly accountId: string | null;
+ readonly email: string | null;
+ readonly planType: string | null;
+ } | null;
+ readonly limits: readonly AccountUsageLimit[];
+ /**
+ * Plan-level verdict (Codex): `limitReached` = plan allowance consumed;
+ * `canServe` = the account can still serve requests (credits may fund
+ * overage). Null when unknown (Claude reports per-window only).
+ */
+ readonly limitReached: boolean | null;
+ readonly canServe: boolean | null;
+ /** True when Codex credits fund requests past an exhausted plan window. */
+ readonly creditOverage: boolean;
+ readonly credits: AccountUsageCredits | null;
+ /** Epoch-ms of the report's data (last successful fetch), null if never. */
+ readonly fetchedAt: number | null;
+ /** Epoch-ms of the last refresh attempt (success or failure), null if never. */
+ readonly lastAttemptAt: number | null;
+ /** Epoch-ms after which the server will refresh on the next request. */
+ readonly nextRefreshAt: number | null;
+ /** Last refresh failure message (sanitized, no secrets), null on success. */
+ readonly error: string | null;
+}
+
+/** Response of `GET /usage` — one report per provider, always both, in fixed order (claude, codex). */
+export interface AccountUsageResponse {
+ readonly providers: readonly AccountUsageReport[];
+ /** Server clock (epoch-ms) when this response was built — use it to compute window progress. */
+ readonly servedAt: number;
+}
+```
diff --git a/backend-handoff.md b/backend-handoff.md
index 6251ca9..1a255dc 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -18,6 +18,24 @@
> ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists
> rebuilt). Recorded below.
+## Milestone: Account usage view (`feature/account-usage`)
+
+**Slice status:** COMPLETE + committed (both repos, `feature/account-usage`; implemented in BOTH
+repos of one worktree like archive-workspaces).
+
+- Backend contract `[email protected]` (additive): `GET /usage` (+ `GET /usage/:provider`,
+ `?refresh=1`) → `AccountUsageResponse` (Claude + Codex subscription quota windows, cached
+ server-side). Full contract: `../backend/notes/account-usage-frontend-handoff.md`; mirror
+ re-generated in `.dispatch/transport-contract.reference.md`.
+- FE: `src/features/account-usage` (pure `logic/view-model.ts` + `ui/AccountUsageView.svelte`,
+ `ui/UsageBar.svelte`), store `accountUsage()`, sidebar view `account-usage` ("Usage").
+ Each limit renders as a DaisyUI `<progress class="progress">` with a **pace marker** dot at the
+ window's elapsed fraction (`(now − (resetsAt − durationMs)) / durationMs`) — the usage level
+ that is "on track" to use the allowance exactly by the reset. Spend meters (no window) show no
+ marker. Polls every 60 s (backend cache hit); Refresh button sends `?refresh=1`.
+- Open asks: none. (Backend-side: inference-header ingestion is implemented in the service but not
+ wired into the external claude/codex providers.)
+
## Milestone: Workspace archiving (`feature/archive-workspaces`)
**Slice status:** COMPLETE + committed (both repos, `feature/archive-workspaces`).
diff --git a/src/app/register-views.ts b/src/app/register-views.ts
index 5b462fe..05afd89 100644
--- a/src/app/register-views.ts
+++ b/src/app/register-views.ts
@@ -9,7 +9,7 @@
*
* `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they
* register with `panelEligible: false` so they never appear in the panel
- * dropdowns. All 14 sidebar views keep the eligible default.
+ * dropdowns. All 15 sidebar views keep the eligible default.
*
* Imported ONCE by App.svelte's module script — before any tile renders.
*/
@@ -17,6 +17,7 @@
import type { Component } from "svelte";
import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout";
import type { AppViewPorts } from "./view-ports";
+import AccountUsagePane from "./views/AccountUsagePane.svelte";
import CacheWarmingPane from "./views/CacheWarmingPane.svelte";
import ChatPane from "./views/ChatPane.svelte";
import CompactionPane from "./views/CompactionPane.svelte";
@@ -50,7 +51,7 @@ function asView(component: WrapperComponent): ViewComponent {
export function registerBuiltinViews(): void {
const views: readonly ViewRegistration[] = [
- // --- sidebar views (panel-eligible by default — the 14 old viewKinds)
+ // --- sidebar views (panel-eligible by default — the 14 old viewKinds + account-usage)
{ id: "tabs", label: "Tabs", component: asView(TabsPane) },
{ id: "model", label: "Model", component: asView(ModelPane) },
{ id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) },
@@ -63,6 +64,7 @@ export function registerBuiltinViews(): void {
{ id: "vision", label: "Vision", component: asView(VisionPane) },
{ id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) },
{ id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) },
+ { id: "account-usage", label: "Usage", component: asView(AccountUsagePane) },
{ id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) },
{ id: "settings", label: "Settings", component: asView(SettingsPane) },
// --- content views (never in the panel dropdowns — decision 5)
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index afccced..7433f81 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -56,6 +56,8 @@ import {
subscribe as protocolSubscribe,
unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
+import type { AccountUsageResult } from "../features/account-usage";
+import { normalizeAccountUsage } from "../features/account-usage";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore, migrateLegacyModelName } from "../features/chat";
import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
@@ -535,6 +537,13 @@ export interface AppStore {
*/
concurrencyStatus(): Promise<ConcurrencyStatusResult>;
/**
+ * Load Claude + Codex subscription usage (`GET /usage`). Served from the
+ * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the
+ * server still rate-limits (min interval + failure cooldown). A 503 (the
+ * `account-usage` extension isn't loaded) returns `unavailable: true`.
+ */
+ accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
+ /**
* Load the GLOBAL search-backend selection
* (`GET /search/config`): which engine backs the `web_search` tool's search
* mode. Not workspace-scoped — the selection is global. `unavailable: true`
@@ -2584,6 +2593,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Account usage request failed",
+ };
+ }
+ },
+
async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
try {
const res = await fetchImpl(`${httpBase}/concurrency/status`);
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte
new file mode 100644
index 0000000..4315d4c
--- /dev/null
+++ b/src/app/views/AccountUsagePane.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ /**
+ * The `account-usage` view — Claude + Codex subscription usage bars with
+ * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it
+ * stays mounted across tab switches (no {#key}).
+ */
+ import { AccountUsageView, type LoadAccountUsage } from "../../features/account-usage";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts);
+</script>
+
+<AccountUsageView load={loadAccountUsage} />
diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte
index 6afc2bf..d5baa39 100644
--- a/src/app/views/ExtensionsPane.svelte
+++ b/src/app/views/ExtensionsPane.svelte
@@ -6,6 +6,7 @@
* surface is a SEPARATE, backend-owned list. Unkeyed (like today).
*/
import Table from "../../components/Table.svelte";
+ import { manifest as accountUsageManifest } from "../../features/account-usage";
import { manifest as cacheWarmingManifest } from "../../features/cache-warming";
import { manifest as chatManifest } from "../../features/chat";
import { manifest as concurrencyManifest } from "../../features/concurrency";
@@ -59,6 +60,7 @@
systemPromptManifest,
heartbeatManifest,
concurrencyManifest,
+ accountUsageManifest,
visionManifest,
namedAgentsManifest,
searchSettingsManifest,
diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts
new file mode 100644
index 0000000..73f2592
--- /dev/null
+++ b/src/features/account-usage/index.ts
@@ -0,0 +1,27 @@
+export type {
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageResult,
+ AccountUsageState,
+ LoadAccountUsage,
+} from "./logic/types";
+export type { BarTone, ProviderCardView, UsageBarView } from "./logic/view-model";
+export {
+ elapsedFraction,
+ formatAge,
+ formatDuration,
+ normalizeAccountUsage,
+ viewProviderCard,
+ viewProviderCards,
+ viewUsageBar,
+} from "./logic/view-model";
+export { default as AccountUsageView } from "./ui/AccountUsageView.svelte";
+export { default as UsageBar } from "./ui/UsageBar.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "account-usage",
+ description: "Claude + Codex subscription usage bars with on-track pace markers",
+} as const;
diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts
new file mode 100644
index 0000000..5bb4d00
--- /dev/null
+++ b/src/features/account-usage/logic/types.ts
@@ -0,0 +1,33 @@
+import type {
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the account-usage feature — zero DOM, zero effects.
+ *
+ * The backend (`account-usage` extension, transport-contract 0.25.0) polls the
+ * Claude + Codex subscription usage endpoints and caches them (5 min TTL,
+ * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual
+ * refresh sends `?refresh=1`, which the server still rate-limits.
+ */
+export type {
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+};
+
+/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */
+export type AccountUsageResult =
+ | { readonly ok: true; readonly usage: AccountUsageResponse }
+ | { readonly ok: false; readonly error: string; readonly unavailable?: boolean };
+
+/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */
+export type LoadAccountUsage = (opts?: {
+ readonly refresh?: boolean;
+}) => Promise<AccountUsageResult>;
diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts
new file mode 100644
index 0000000..943d047
--- /dev/null
+++ b/src/features/account-usage/logic/view-model.test.ts
@@ -0,0 +1,198 @@
+import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ elapsedFraction,
+ formatDuration,
+ normalizeAccountUsage,
+ viewProviderCard,
+ viewUsageBar,
+} from "./view-model";
+
+const HOUR = 3_600_000;
+const DAY = 24 * HOUR;
+const NOW = 1_760_000_000_000;
+
+function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit {
+ return {
+ id: "claude:5h",
+ label: "5 Hour",
+ kind: "window",
+ windowId: "5h",
+ shared: true,
+ tier: null,
+ durationMs: 5 * HOUR,
+ resetsAt: NOW + 2 * HOUR,
+ unit: "percent",
+ used: 40,
+ limit: 100,
+ remaining: 60,
+ usedFraction: 0.4,
+ remainingFraction: 0.6,
+ status: "ok",
+ ...over,
+ };
+}
+
+function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport {
+ return {
+ provider: "claude",
+ label: "Claude",
+ state: "fresh",
+ account: { accountId: "a", email: "[email protected]", planType: "max" },
+ limits: [limit()],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ fetchedAt: NOW - 3 * 60_000,
+ lastAttemptAt: NOW - 3 * 60_000,
+ nextRefreshAt: NOW + 2 * 60_000,
+ error: null,
+ ...over,
+ };
+}
+
+describe("pace marker (elapsedFraction)", () => {
+ it("is halfway two weeks into a four-week window", () => {
+ const resetsAt = NOW + 14 * DAY;
+ expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5);
+ });
+
+ it("clamps to 0..1 and is null when the window is unknown", () => {
+ expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0);
+ expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1);
+ expect(elapsedFraction(null, NOW, NOW)).toBeNull();
+ expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull();
+ });
+});
+
+describe("viewUsageBar", () => {
+ it("places the pace marker at elapsed % and labels ahead/under pace", () => {
+ // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under.
+ const bar = viewUsageBar(limit(), NOW);
+ expect(bar.percent).toBe(40);
+ expect(bar.pacePercent).toBeCloseTo(60);
+ expect(bar.paceLabel).toBe("20% under pace");
+ expect(bar.aheadOfPace).toBe(false);
+ expect(bar.resetLabel).toBe("resets in 2h");
+ expect(bar.valueLabel).toBe("40%");
+ expect(bar.tone).toBe("success");
+
+ const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW);
+ expect(ahead.paceLabel).toBe("20% ahead of pace");
+ expect(ahead.aheadOfPace).toBe(true);
+ expect(ahead.tone).toBe("warning");
+ });
+
+ it("has no pace marker for spend meters; formats dollars", () => {
+ const spend = viewUsageBar(
+ limit({
+ id: "claude:extra",
+ kind: "spend",
+ unit: "usd",
+ durationMs: null,
+ resetsAt: null,
+ used: 12.5,
+ limit: 50,
+ usedFraction: 0.25,
+ }),
+ NOW,
+ );
+ expect(spend.pacePercent).toBeNull();
+ expect(spend.valueLabel).toBe("$12.50 / $50.00");
+ const uncapped = viewUsageBar(
+ limit({
+ unit: "usd",
+ kind: "spend",
+ limit: null,
+ usedFraction: null,
+ used: 3,
+ durationMs: null,
+ }),
+ NOW,
+ );
+ expect(uncapped.valueLabel).toBe("$3.00 (no cap)");
+ expect(uncapped.percent).toBe(0);
+ });
+
+ it("exhausted → error tone", () => {
+ expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe(
+ "error",
+ );
+ });
+});
+
+describe("viewProviderCard", () => {
+ it("fresh card with subtitle and age", () => {
+ const c = viewProviderCard(report(), NOW);
+ expect(c.subtitle).toBe("Max · [email protected]");
+ expect(c.stateBadge).toBeNull();
+ expect(c.message).toBeNull();
+ expect(c.ageLabel).toBe("3m ago");
+ expect(c.bars).toHaveLength(1);
+ });
+
+ it("stale / not-configured / unsupported states", () => {
+ const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW);
+ expect(stale.stateBadge).toBe("stale");
+ expect(stale.message).toContain("HTTP 429");
+ const nc = viewProviderCard(
+ report({
+ provider: "codex",
+ label: "Codex",
+ state: "not-configured",
+ limits: [],
+ account: null,
+ }),
+ NOW,
+ );
+ expect(nc.stateBadge).toBe("not configured");
+ expect(nc.message).toContain("Codex");
+ expect(nc.ageLabel).toBeNull();
+ const un = viewProviderCard(
+ report({ state: "unsupported", error: "api key", limits: [] }),
+ NOW,
+ );
+ expect(un.message).toBe("api key");
+ });
+
+ it("credit overage note", () => {
+ const c = viewProviderCard(
+ report({ creditOverage: true, limitReached: true, canServe: true }),
+ NOW,
+ );
+ expect(c.creditNote).toContain("credits");
+ });
+});
+
+describe("normalizeAccountUsage", () => {
+ it("coerces a well-formed body and drops malformed rows", () => {
+ const n = normalizeAccountUsage(
+ {
+ servedAt: 5,
+ providers: [
+ { ...report(), limits: [limit(), { id: "bad" }] },
+ { provider: "gemini" },
+ "junk",
+ ],
+ },
+ 0,
+ );
+ expect(n.servedAt).toBe(5);
+ expect(n.providers).toHaveLength(1);
+ expect(n.providers[0]?.limits).toHaveLength(1);
+ });
+
+ it("malformed body → empty", () => {
+ expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 });
+ });
+});
+
+describe("formatDuration", () => {
+ it("formats", () => {
+ expect(formatDuration(30_000)).toBe("<1m");
+ expect(formatDuration(45 * 60_000)).toBe("45m");
+ expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m");
+ expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h");
+ });
+});
diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts
new file mode 100644
index 0000000..d6f3053
--- /dev/null
+++ b/src/features/account-usage/logic/view-model.ts
@@ -0,0 +1,303 @@
+import type {
+ AccountUsageCredits,
+ AccountUsageLimit,
+ AccountUsageLimitStatus,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure view-models for the account-usage feature — zero DOM, zero effects,
+ * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit
+ * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be
+ * to use the whole allowance exactly by the reset (elapsed fraction of the
+ * window: 2 weeks into a 4-week window ⇒ 50%).
+ */
+
+// ── Normalization (network seam) ──────────────────────────────────────────────
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+function num(v: unknown): number | null {
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
+}
+function str(v: unknown): string | null {
+ return typeof v === "string" ? v : null;
+}
+function bool(v: unknown): boolean | null {
+ return typeof v === "boolean" ? v : null;
+}
+
+const STATES: readonly AccountUsageState[] = [
+ "fresh",
+ "stale",
+ "unavailable",
+ "unsupported",
+ "not-configured",
+];
+const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"];
+
+function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null {
+ const id = str(r.id);
+ const used = num(r.used);
+ if (id === null || used === null) return null;
+ const status = STATUSES.find((s) => s === r.status) ?? "unknown";
+ return {
+ id,
+ label: str(r.label) ?? id,
+ kind: r.kind === "spend" ? "spend" : "window",
+ windowId: str(r.windowId) ?? "",
+ shared: r.shared === true,
+ tier: str(r.tier),
+ durationMs: num(r.durationMs),
+ resetsAt: num(r.resetsAt),
+ unit: r.unit === "usd" ? "usd" : "percent",
+ used,
+ limit: num(r.limit),
+ remaining: num(r.remaining),
+ usedFraction: num(r.usedFraction),
+ remainingFraction: num(r.remainingFraction),
+ status,
+ };
+}
+
+function normalizeCredits(v: unknown): AccountUsageCredits | null {
+ if (!isRecord(v)) return null;
+ return {
+ hasCredits: bool(v.hasCredits),
+ unlimited: bool(v.unlimited),
+ overageLimitReached: bool(v.overageLimitReached),
+ balance: num(v.balance),
+ spendControlReached: bool(v.spendControlReached),
+ };
+}
+
+function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null {
+ const provider: AccountUsageProviderId | null =
+ r.provider === "claude" || r.provider === "codex" ? r.provider : null;
+ if (provider === null) return null;
+ const account = isRecord(r.account)
+ ? {
+ accountId: str(r.account.accountId),
+ email: str(r.account.email),
+ planType: str(r.account.planType),
+ }
+ : null;
+ const limits = Array.isArray(r.limits)
+ ? r.limits
+ .filter(isRecord)
+ .map(normalizeLimit)
+ .filter((l): l is AccountUsageLimit => l !== null)
+ : [];
+ return {
+ provider,
+ label: str(r.label) ?? (provider === "claude" ? "Claude" : "Codex"),
+ state: STATES.find((s) => s === r.state) ?? "unavailable",
+ account,
+ limits,
+ limitReached: bool(r.limitReached),
+ canServe: bool(r.canServe),
+ creditOverage: r.creditOverage === true,
+ credits: normalizeCredits(r.credits),
+ fetchedAt: num(r.fetchedAt),
+ lastAttemptAt: num(r.lastAttemptAt),
+ nextRefreshAt: num(r.nextRefreshAt),
+ error: str(r.error),
+ };
+}
+
+/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */
+export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse {
+ if (!isRecord(data)) return { providers: [], servedAt: fallbackNow };
+ const providers = Array.isArray(data.providers)
+ ? data.providers
+ .filter(isRecord)
+ .map(normalizeReport)
+ .filter((p): p is AccountUsageReport => p !== null)
+ : [];
+ return { providers, servedAt: num(data.servedAt) ?? fallbackNow };
+}
+
+// ── Formatting ────────────────────────────────────────────────────────────────
+
+/** "2d 4h" / "3h 12m" / "45m" / "<1m". */
+export function formatDuration(ms: number): string {
+ if (ms < 60_000) return "<1m";
+ const totalMin = Math.floor(ms / 60_000);
+ const d = Math.floor(totalMin / 1440);
+ const h = Math.floor((totalMin % 1440) / 60);
+ const m = totalMin % 60;
+ if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
+ return `${m}m`;
+}
+
+/** "updated 3m ago" style age, or "just now". */
+export function formatAge(fetchedAt: number | null, now: number): string | null {
+ if (fetchedAt === null) return null;
+ const ms = now - fetchedAt;
+ return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`;
+}
+
+function formatUsd(v: number): string {
+ return `$${v.toFixed(2)}`;
+}
+
+// ── Bars ──────────────────────────────────────────────────────────────────────
+
+export type BarTone = "success" | "warning" | "error" | "neutral";
+
+export interface UsageBarView {
+ readonly id: string;
+ readonly label: string;
+ /** Fill 0..100 for the `<progress>` value. */
+ readonly percent: number;
+ /** "42%" or "$12.50 / $50.00". */
+ readonly valueLabel: string;
+ readonly tone: BarTone;
+ /**
+ * Pace marker position 0..100 (% of the window elapsed) — the usage level
+ * that would be "on track" to exactly use the allowance by the reset. Null
+ * when the window's start can't be determined (no duration/reset).
+ */
+ readonly pacePercent: number | null;
+ /** "+12% ahead of pace" / "8% under pace" / null. */
+ readonly paceLabel: string | null;
+ /** True when usage is ahead of the pace marker. */
+ readonly aheadOfPace: boolean;
+ /** "resets in 3h 12m" / null. */
+ readonly resetLabel: string | null;
+}
+
+/**
+ * Fraction (0..1) of the window elapsed at `now`. The window started at
+ * `resetsAt - durationMs`; returns null when either is unknown.
+ */
+export function elapsedFraction(
+ durationMs: number | null,
+ resetsAt: number | null,
+ now: number,
+): number | null {
+ if (durationMs === null || resetsAt === null || durationMs <= 0) return null;
+ const start = resetsAt - durationMs;
+ return Math.min(1, Math.max(0, (now - start) / durationMs));
+}
+
+function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone {
+ if (status === "exhausted") return "error";
+ if (status === "warning") return "warning";
+ if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success";
+ return "neutral";
+}
+
+export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView {
+ const fraction = limit.usedFraction;
+ const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100));
+ const valueLabel =
+ limit.unit === "usd"
+ ? limit.limit === null
+ ? `${formatUsd(limit.used)} (no cap)`
+ : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`
+ : `${Math.round(limit.used)}%`;
+ const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now);
+ const pacePercent = elapsed === null ? null : elapsed * 100;
+ let paceLabel: string | null = null;
+ let aheadOfPace = false;
+ if (pacePercent !== null && fraction !== null) {
+ const diff = Math.round(percent - pacePercent);
+ aheadOfPace = diff > 0;
+ paceLabel =
+ diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`;
+ }
+ const resetLabel =
+ limit.resetsAt === null
+ ? null
+ : limit.resetsAt <= now
+ ? "resetting"
+ : `resets in ${formatDuration(limit.resetsAt - now)}`;
+ return {
+ id: limit.id,
+ label: limit.label,
+ percent,
+ valueLabel,
+ tone: toneFor(limit.status, fraction),
+ pacePercent,
+ paceLabel,
+ aheadOfPace,
+ resetLabel,
+ };
+}
+
+// ── Provider cards ────────────────────────────────────────────────────────────
+
+export interface ProviderCardView {
+ readonly provider: AccountUsageProviderId;
+ readonly label: string;
+ /** "Pro · [email protected]" / null. */
+ readonly subtitle: string | null;
+ readonly state: AccountUsageState;
+ /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */
+ readonly stateBadge: string | null;
+ /** Explanatory line for empty/non-fresh states, else null. */
+ readonly message: string | null;
+ readonly bars: readonly UsageBarView[];
+ /** "updated 3m ago" / null. */
+ readonly ageLabel: string | null;
+ /** Credit-overage note (Codex), or null. */
+ readonly creditNote: string | null;
+}
+
+function stateMessage(r: AccountUsageReport): string | null {
+ switch (r.state) {
+ case "fresh":
+ return r.limits.length === 0 ? "No quota windows reported." : null;
+ case "stale":
+ return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`;
+ case "unavailable":
+ return `Usage unavailable${r.error ? `: ${r.error}` : "."}`;
+ case "unsupported":
+ return r.error ?? "This credential type can't report subscription usage.";
+ case "not-configured":
+ return r.provider === "claude"
+ ? "Not signed in to Claude (no OAuth credentials)."
+ : "Not signed in to Codex (no ChatGPT OAuth credentials).";
+ }
+}
+
+function capitalize(s: string): string {
+ return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1);
+}
+
+export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView {
+ const parts = [
+ r.account?.planType ? capitalize(r.account.planType) : null,
+ r.account?.email ?? null,
+ ].filter((p): p is string => p !== null && p !== "");
+ let creditNote: string | null = null;
+ if (r.creditOverage) {
+ creditNote = "Plan allowance used — requests are being funded by credits.";
+ } else if (r.limitReached === true && r.canServe === false) {
+ creditNote = "Plan limit reached — requests are blocked until reset.";
+ }
+ return {
+ provider: r.provider,
+ label: r.label,
+ subtitle: parts.length > 0 ? parts.join(" · ") : null,
+ state: r.state,
+ stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "),
+ message: stateMessage(r),
+ bars: r.limits.map((l) => viewUsageBar(l, now)),
+ ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null,
+ creditNote,
+ };
+}
+
+export function viewProviderCards(
+ usage: AccountUsageResponse,
+ now: number,
+): readonly ProviderCardView[] {
+ return usage.providers.map((p) => viewProviderCard(p, now));
+}
diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte
new file mode 100644
index 0000000..c1af2a7
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.svelte
@@ -0,0 +1,127 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { AccountUsageResponse, LoadAccountUsage } from "../logic/types";
+ import { viewProviderCards } from "../logic/view-model";
+ import UsageBar from "./UsageBar.svelte";
+
+ let {
+ load,
+ now = () => Date.now(),
+ pollMs = 60_000,
+ }: {
+ load: LoadAccountUsage;
+ /** Injected clock (tests). */
+ now?: () => number;
+ /** How often to re-read `GET /usage` (served from the backend cache). */
+ pollMs?: number;
+ } = $props();
+
+ let usage = $state<AccountUsageResponse | null>(null);
+ let error = $state<string | null>(null);
+ let refreshing = $state(false);
+ let inFlight = false;
+ /** Ticks so the pace marker + "resets in" labels advance between polls. */
+ let clock = $state(untrack(() => now()));
+
+ const cards = $derived(usage ? viewProviderCards(usage, clock) : []);
+
+ async function refresh(force: boolean): Promise<void> {
+ if (inFlight) return;
+ inFlight = true;
+ if (force) refreshing = true;
+ const result = await load(force ? { refresh: true } : undefined);
+ inFlight = false;
+ refreshing = false;
+ clock = now();
+ if (result.ok) {
+ usage = result.usage;
+ error = null;
+ } else {
+ error = result.unavailable
+ ? "Account usage is unavailable (backend extension not loaded)."
+ : result.error;
+ }
+ }
+
+ $effect(() => {
+ const interval = untrack(() => pollMs);
+ untrack(() => void refresh(false));
+ const poll = setInterval(() => void refresh(false), interval);
+ const tick = setInterval(() => {
+ clock = now();
+ }, 30_000);
+ return () => {
+ clearInterval(poll);
+ clearInterval(tick);
+ };
+ });
+</script>
+
+<div class="flex flex-col gap-3">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => void refresh(true)}
+ disabled={refreshing}
+ aria-label="Refresh account usage"
+ >
+ {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if}
+ Refresh
+ </button>
+ </div>
+
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {/if}
+
+ {#if usage === null && error === null}
+ <p class="text-xs opacity-60">Loading…</p>
+ {/if}
+
+ {#each cards as card (card.provider)}
+ <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage">
+ <div class="flex items-start justify-between gap-2">
+ <div class="flex min-w-0 flex-col">
+ <span class="text-sm font-semibold">{card.label}</span>
+ {#if card.subtitle}
+ <span class="truncate text-[11px] opacity-60">{card.subtitle}</span>
+ {/if}
+ </div>
+ <div class="flex shrink-0 flex-col items-end gap-0.5">
+ {#if card.stateBadge}
+ <span
+ class="badge badge-xs"
+ class:badge-warning={card.state === "stale"}
+ class:badge-error={card.state === "unavailable"}
+ class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"}
+ >{card.stateBadge}</span
+ >
+ {/if}
+ {#if card.ageLabel}
+ <span class="text-[11px] opacity-50">updated {card.ageLabel}</span>
+ {/if}
+ </div>
+ </div>
+
+ {#if card.message}
+ <p class="text-xs opacity-70">{card.message}</p>
+ {/if}
+ {#if card.creditNote}
+ <p class="text-xs text-warning">{card.creditNote}</p>
+ {/if}
+
+ {#each card.bars as bar (bar.id)}
+ <UsageBar {bar} />
+ {/each}
+ </section>
+ {/each}
+
+ {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))}
+ <p class="flex items-center gap-1.5 text-[11px] opacity-50">
+ <span class="inline-block size-2 rounded-full bg-base-content"></span>
+ on-track pace — usage that would exactly use the allowance by its reset
+ </p>
+ {/if}
+</div>
diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts
new file mode 100644
index 0000000..e7e5799
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.test.ts
@@ -0,0 +1,95 @@
+import type { AccountUsageResponse } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { AccountUsageResult } from "../logic/types";
+import AccountUsageView from "./AccountUsageView.svelte";
+
+const NOW = 1_760_000_000_000;
+const DAY = 86_400_000;
+
+const usage: AccountUsageResponse = {
+ servedAt: NOW,
+ providers: [
+ {
+ provider: "claude",
+ label: "Claude",
+ state: "fresh",
+ account: { accountId: "a", email: "[email protected]", planType: "max" },
+ limits: [
+ {
+ id: "claude:7d",
+ label: "7 Day",
+ kind: "window",
+ windowId: "7d",
+ shared: true,
+ tier: null,
+ durationMs: 28 * DAY,
+ resetsAt: NOW + 14 * DAY,
+ unit: "percent",
+ used: 30,
+ limit: 100,
+ remaining: 70,
+ usedFraction: 0.3,
+ remainingFraction: 0.7,
+ status: "ok",
+ },
+ ],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ fetchedAt: NOW,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 300_000,
+ error: null,
+ },
+ {
+ provider: "codex",
+ label: "Codex",
+ state: "not-configured",
+ account: null,
+ limits: [],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ fetchedAt: null,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 30_000,
+ error: "No Codex OAuth credentials",
+ },
+ ],
+};
+
+describe("AccountUsageView", () => {
+ it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => {
+ const calls: Array<{ refresh?: boolean } | undefined> = [];
+ const load = async (opts?: { refresh?: boolean }): Promise<AccountUsageResult> => {
+ calls.push(opts);
+ return { ok: true, usage };
+ };
+ render(AccountUsageView, { load, now: () => NOW });
+ const bar = await screen.findByLabelText("7 Day usage");
+ expect(bar.tagName).toBe("PROGRESS");
+ expect(bar.classList.contains("progress")).toBe(true);
+ expect(bar.getAttribute("value")).toBe("30");
+ const marker = screen.getByTestId("pace-marker");
+ expect(marker.getAttribute("style")).toContain("left: 50%");
+ expect(screen.getByText("20% under pace")).toBeTruthy();
+ expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy();
+
+ await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" }));
+ expect(calls.at(-1)).toEqual({ refresh: true });
+ });
+
+ it("shows the unavailable message on 503", async () => {
+ const load = async (): Promise<AccountUsageResult> => ({
+ ok: false,
+ error: "x",
+ unavailable: true,
+ });
+ render(AccountUsageView, { load, now: () => NOW });
+ expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy();
+ });
+});
diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte
new file mode 100644
index 0000000..f4298c1
--- /dev/null
+++ b/src/features/account-usage/ui/UsageBar.svelte
@@ -0,0 +1,49 @@
+<script lang="ts">
+ import type { UsageBarView } from "../logic/view-model";
+
+ let { bar }: { bar: UsageBarView } = $props();
+
+ const progressClass = $derived(
+ {
+ success: "progress-success",
+ warning: "progress-warning",
+ error: "progress-error",
+ neutral: "",
+ }[bar.tone],
+ );
+</script>
+
+<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}>
+ <div class="flex items-baseline justify-between gap-2 text-xs">
+ <span class="truncate font-medium">{bar.label}</span>
+ <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span>
+ </div>
+ <div class="relative flex items-center">
+ <progress
+ class="progress h-2 w-full {progressClass}"
+ value={bar.percent}
+ max="100"
+ aria-label="{bar.label} usage"
+ ></progress>
+ {#if bar.pacePercent !== null}
+ <!-- Pace marker: where usage would be if spread evenly to the reset. -->
+ <span
+ class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content"
+ style="left: {bar.pacePercent}%"
+ data-testid="pace-marker"
+ title="On-track pace: {Math.round(bar.pacePercent)}%"
+ aria-hidden="true"
+ ></span>
+ {/if}
+ </div>
+ {#if bar.resetLabel || bar.paceLabel}
+ <div class="flex justify-between gap-2 text-[11px] opacity-60">
+ <span>{bar.resetLabel ?? ""}</span>
+ {#if bar.paceLabel}
+ <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace}
+ >{bar.paceLabel}</span
+ >
+ {/if}
+ </div>
+ {/if}
+</div>
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
index 3a86cb9..bcd49e0 100644
--- a/src/features/layout/logic/registry.ts
+++ b/src/features/layout/logic/registry.ts
@@ -1,7 +1,7 @@
/**
* The view registry — the map from view id → component that tile leaves mount.
*
- * Every view the renderer can place (the 14 sidebar views + chat / composer /
+ * Every view the renderer can place (the sidebar views + chat / composer /
* message-queue today; plugin views from plan 03 later) registers here. The
* registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
* open dropdowns, and the tree renderer resolves leaves through it per render.