From 10e78065201c8387494b8aafabb987da87183b3a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 14 Oct 2026 17:04:35 +0900 Subject: 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. --- src/app/register-views.ts | 6 ++++-- src/app/store.svelte.ts | 29 +++++++++++++++++++++++++++++ src/app/views/AccountUsagePane.svelte | 16 ++++++++++++++++ src/app/views/ExtensionsPane.svelte | 2 ++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/app/views/AccountUsagePane.svelte (limited to 'src/app') 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"; @@ -534,6 +536,13 @@ export interface AppStore { * an empty list when the extension isn't loaded (`{ providers: [] }`). */ concurrencyStatus(): Promise; + /** + * 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; /** * Load the GLOBAL search-backend selection * (`GET /search/config`): which engine backs the `web_search` tool's search @@ -2584,6 +2593,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async accountUsage(opts?: { readonly refresh?: boolean }): Promise { + 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 { 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 @@ + + + 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, -- cgit v1.2.3