/** * @file StatusBar — the one-line bar along the bottom of the app shell, on * every page: the live-connection state (click: the connection details modal, * which the sidebar owns and which also carries the update check), the version * — with a green dot while a newer release is out —, how many SESSIONS are * running and how many need the user (`GET /api/sessions?status=active` and * `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session * messages), and today's spend — `GET /api/pricing/cost?date=` — and, on the * Workspace page, the open workspace's total (`?lane=` on the same call: the * sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for * the URL's `session`, or the focused pane's session from the page's run poll, * `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and * whenever the URL names another workspace or session. Each figure carries its * token total, and hovering it lists tokens and cost per model. At the right * end, the plan quota spent per native tool (`GET /api/pricing/quota`, every * 5 min while visible; a click forces a re-read): one "N% used" chip per tool — * its fullest window, live account only — and, on hover or focus, a panel * above grouping every account per tool (saved Claude profiles too): name, * sign-in expiry, and a row per window with its bar, percent and reset time. * @author Nguyễn Ngọc Trí Vĩ */ import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom"; import { RefreshCw, Wifi, WifiOff } from "lucide-react"; import { api, type RunHandle } from "../lib/api"; import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types"; import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format"; import { AiToolIcon } from "./AiToolIcon"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useSpin } from "../hooks/useSpin"; import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree"; const COST_POLL_MS = 30_000; const QUOTA_POLL_MS = 5 * 60_000; const QUOTA_TOOL_LABEL: Record = { claude: "Claude", codex: "Codex", antigravity: "Antigravity", cursor: "Cursor", grok: "Grok", kimi: "Kimi", opencode: "OpenCode Go", }; function localDate(d = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } const usd = (n: number) => `$${n.toFixed(2)}`; /** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded), * costliest first: the hover text of a cost figure. */ function perModel(r: CostResult): { tokens: number; lines: string } { const byModel = new Map(); for (const b of r.breakdown) { const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 }; m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens; m.cost += b.cost; byModel.set(b.model, m); } const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost); return { tokens: rows.reduce((n, [, m]) => n + m.tokens, 0), lines: rows .map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`) .join("\n"), }; } function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) { const { t } = useTranslation("nav"); const { tokens, lines } = perModel(r); return ( {label} {t("statusBar.tokens", { tokens: fmt(tokens) })} ); } /** Re-run `load` every `ms` and whenever the tab becomes visible again. */ function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) { useEffect(() => { const tick = () => document.visibilityState === "visible" && load(); tick(); const id = setInterval(tick, ms); document.addEventListener("visibilitychange", tick); return () => { clearInterval(id); document.removeEventListener("visibilitychange", tick); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0)); const quotaTone = (p: number) => p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : ""; const barTone = (p: number) => p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted"; function untilText(iso: string | null): string | null { const ms = iso ? Date.parse(iso) - Date.now() : NaN; if (!(ms > 0)) return null; const m = Math.floor(ms / 60_000); const d = Math.floor(m / 1440); const h = Math.floor((m % 1440) / 60); return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`; } /** "account · sign-in state · as of", whichever parts the entry has. */ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record) => string) { const until = untilText(q.expiresAt ?? null); return [ q.label && q.account, q.expiresAt && q.error !== "ELIVEEXPIRED" && (until ? t("statusBar.quotaSignInExpires", { time: until }) : t("statusBar.quotaSignInExpired")), q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }), ] .filter(Boolean) .join(" · "); } const QUOTA_ERROR_KEY: Record = { ELIVEEXPIRED: "statusBar.quotaExpired", EPROFILEEXPIRED: "statusBar.quotaProfileExpired", EWRONGACCOUNT: "statusBar.quotaWrongAccount", ETOKENEXPIRED: "statusBar.quotaTokenExpired", }; /** One account: its name, sign-in line, then a row per window with its reset. */ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) { const { t } = useTranslation("nav"); const meta = quotaMeta(q, t); return (
{q.label || q.account} {q.plan && ( {q.plan} )} {many && q.active !== false && ( {t("statusBar.quotaActive")} )}
{meta &&
{meta}
} {q.error && !q.windows.length ? (
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
) : (
{q.windows.map((w, i) => { const p = Math.round(w.usedPercent ?? 0); const resets = untilText(w.resetsAt); return ( {w.group && w.group !== q.windows[i - 1]?.group && ( {w.group} )} {t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })} {p}% {/* In words: a ↻ glyph here read as a Refresh button. */} {resets && t("statusBar.quotaResets", { time: resets })} ); })}
)}
); } function QuotaPanel({ tools, onEnter, onLeave, onRefresh, refreshing, }: { tools: ToolQuota[]; /** The footer's Refresh: re-reads the quota, as a click on the chips does. */ onRefresh: () => void; /** A forced read is in flight: the Refresh icon spins. */ refreshing: boolean; /** The pointer moved onto the panel / off it: it stays open while read. */ onEnter: () => void; onLeave: () => void; }) { const { t } = useTranslation("nav"); const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][]) .map((id) => [id, tools.filter((q) => q.id === id)] as const) .filter(([, list]) => list.length); return createPortal(
{t("statusBar.quotaTitle")}
{groups.map(([id, list]) => (
{QUOTA_TOOL_LABEL[id]}
{list.map((q, i) => ( 1} /> ))}
))}
, document.body ); } /** The right-end quota chips; nothing at all until a tool reports one. */ function QuotaChips() { const { t } = useTranslation("nav"); const [tools, setTools] = useState([]); const [open, setOpen] = useState(false); // Leaving the chips closes the panel only after a beat, and entering the // panel cancels that: the pointer crosses the gap to read it. const closeTimer = useRef(); const show = () => { window.clearTimeout(closeTimer.current); setOpen(true); }; const hide = () => { window.clearTimeout(closeTimer.current); closeTimer.current = window.setTimeout(() => setOpen(false), 200); }; useEffect(() => () => window.clearTimeout(closeTimer.current), []); const load = useCallback( (force = false) => api.pricing .quota(force) .then((r) => setTools(r.tools)) .catch(() => undefined), [] ); const [refreshing, refresh] = useSpin(() => load(true)); useVisiblePoll(load, QUOTA_POLL_MS, [load]); if (!tools.length) return null; return ( <> {open && ( )} ); } export function StatusBar({ wsConnected, left, onConnectionClick, updateAvailable = false, }: { wsConnected: boolean; /** Sidebar width, so the bar starts where the main column does. */ left: string; onConnectionClick: () => void; /** A newer release is out: a dot beside the version. */ updateAvailable?: boolean; }) { const { t } = useTranslation("nav"); const [active, setActive] = useState(0); const [needYou, setNeedYou] = useState(0); const running = Math.max(active - needYou, 0); const [today, setToday] = useState(null); const [laneCost, setLaneCost] = useState(null); const [sessionCost, setSessionCost] = useState(null); const [runs, setRuns] = useState([]); // The budget furthest past one of its thresholds, if any. const [budget, setBudget] = useState(null); const location = useLocation(); const shown = selectedFrom(location.pathname, location.search); const lane = shown.lane; const sessionId = shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null; useEffect(() => { const take = (e: Event) => setRuns((e as CustomEvent).detail); window.addEventListener(LIVE_RUNS_EVENT, take); return () => window.removeEventListener(LIVE_RUNS_EVENT, take); }, []); const loadCounts = useCallback(() => { api.sessions .list({ status: "active", limit: 1 }) .then((r) => setActive(r.total)) .catch(() => undefined); api.sessions .list({ awaiting: true, limit: 1 }) .then((r) => setNeedYou(r.total)) .catch(() => undefined); }, []); useEffect(loadCounts, [loadCounts]); useLiveRefresh(loadCounts, { intervalMs: 15_000, match: (msg) => msg.type === "session_updated" || msg.type === "session_created", }); useEffect(() => { let live = true; // Another workspace or session: never show the previous one's figure. setLaneCost(null); setSessionCost(null); const tick = () => { if (document.visibilityState !== "visible") return; api.pricing .totalCost({ date: localDate() }) .then((r) => live && setToday(r)) .catch(() => undefined); api.budgets .status() .then(({ budgets }) => { const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct); if (live) setBudget(hit[0] ?? null); }) .catch(() => undefined); if (lane !== null) { api.pricing .totalCost({ lane }) .then((r) => live && setLaneCost(r)) .catch(() => undefined); } if (sessionId) { api.pricing .sessionCost(sessionId) .then((r) => live && setSessionCost(r)) .catch(() => undefined); } }; tick(); const id = setInterval(tick, COST_POLL_MS); document.addEventListener("visibilitychange", tick); return () => { live = false; clearInterval(id); document.removeEventListener("visibilitychange", tick); }; }, [lane, sessionId]); return ( ); }