a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
/**
|
|
* @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=<local 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<ToolQuota["id"], string> = {
|
|
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<string, { tokens: number; cost: number }>();
|
|
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 (
|
|
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
|
|
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** 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, unknown>) => 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<string, string> = {
|
|
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 (
|
|
<div data-testid="status-bar-quota-account" className="min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate font-medium">{q.label || q.account}</span>
|
|
{q.plan && (
|
|
<span
|
|
data-testid="status-bar-quota-plan"
|
|
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
|
>
|
|
{q.plan}
|
|
</span>
|
|
)}
|
|
{many && q.active !== false && (
|
|
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
|
{t("statusBar.quotaActive")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
|
|
{q.error && !q.windows.length ? (
|
|
<div className="text-status-warning">
|
|
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
|
</div>
|
|
) : (
|
|
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
|
{q.windows.map((w, i) => {
|
|
const p = Math.round(w.usedPercent ?? 0);
|
|
const resets = untilText(w.resetsAt);
|
|
return (
|
|
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
|
{w.group && w.group !== q.windows[i - 1]?.group && (
|
|
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
|
{w.group}
|
|
</span>
|
|
)}
|
|
<span className="text-fg-muted">
|
|
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
|
</span>
|
|
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
|
|
<span
|
|
className={`block h-full ${barTone(p)}`}
|
|
style={{ width: `${Math.min(p, 100)}%` }}
|
|
/>
|
|
</span>
|
|
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
|
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
|
<span className="text-right text-fg-muted">
|
|
{resets && t("statusBar.quotaResets", { time: resets })}
|
|
</span>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<div
|
|
role="dialog"
|
|
aria-label={t("statusBar.quotaTitle")}
|
|
data-testid="status-bar-quota-panel"
|
|
onMouseEnter={onEnter}
|
|
onMouseLeave={onLeave}
|
|
onFocus={onEnter}
|
|
onBlur={onLeave}
|
|
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
|
>
|
|
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
|
{groups.map(([id, list]) => (
|
|
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
|
|
<div className="mb-1 flex items-center gap-1.5">
|
|
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
|
|
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
|
|
</div>
|
|
<div className="space-y-2 pl-[1.375rem]">
|
|
{list.map((q, i) => (
|
|
<QuotaAccount
|
|
key={`${q.label ?? q.account ?? ""}-${i}`}
|
|
q={q}
|
|
many={list.length > 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
data-testid="status-bar-quota-refresh"
|
|
// Not `disabled`: a focused button that turns disabled loses focus, and
|
|
// that blur would close the panel under the pointer.
|
|
onClick={() => !refreshing && onRefresh()}
|
|
aria-busy={refreshing}
|
|
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
|
|
refreshing ? "cursor-default" : "hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
|
|
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
|
|
</button>
|
|
</div>,
|
|
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<ToolQuota[]>([]);
|
|
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<number>();
|
|
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 (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid="status-bar-quota"
|
|
data-tip-off
|
|
onClick={refresh}
|
|
onMouseEnter={show}
|
|
onMouseLeave={hide}
|
|
onFocus={show}
|
|
onBlur={hide}
|
|
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
|
|
>
|
|
{tools
|
|
.filter((q) => q.active !== false)
|
|
.map((q) => {
|
|
const p = Math.round(fullest(q));
|
|
return (
|
|
<span key={q.id} className="flex items-center gap-1">
|
|
<AiToolIcon
|
|
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
|
|
className="h-4 w-4 !p-0"
|
|
/>
|
|
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
|
|
{q.error ? (
|
|
<span className="text-status-warning">!</span>
|
|
) : (
|
|
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
|
|
)}
|
|
</span>
|
|
);
|
|
})}
|
|
</button>
|
|
{open && (
|
|
<QuotaPanel
|
|
tools={tools}
|
|
onEnter={show}
|
|
onLeave={hide}
|
|
onRefresh={refresh}
|
|
refreshing={refreshing}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<CostResult | null>(null);
|
|
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
|
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
|
const [runs, setRuns] = useState<RunHandle[]>([]);
|
|
// The budget furthest past one of its thresholds, if any.
|
|
const [budget, setBudget] = useState<BudgetStatus | null>(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<RunHandle[]>).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 (
|
|
<footer
|
|
data-testid="status-bar"
|
|
style={{ left }}
|
|
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
|
|
>
|
|
{/* What a screen reader is told without looking: the connection dropping
|
|
or coming back, and agents waiting on the user. Polite, and nothing
|
|
that ticks on its own (costs, running counts) — those stay silent. */}
|
|
<span role="status" className="sr-only" data-testid="status-bar-live">
|
|
{wsConnected ? t("live") : t("disconnected")}
|
|
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onConnectionClick}
|
|
title={t("connectionDetails")}
|
|
className={`flex items-center gap-1.5 hover:text-fg-primary ${
|
|
wsConnected ? "text-status-success" : ""
|
|
}`}
|
|
>
|
|
{wsConnected ? (
|
|
<Wifi className="h-3 w-3" aria-hidden />
|
|
) : (
|
|
<WifiOff className="h-3 w-3" aria-hidden />
|
|
)}
|
|
{wsConnected ? t("live") : t("disconnected")}
|
|
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
|
{updateAvailable && (
|
|
<span
|
|
data-testid="status-bar-update"
|
|
title={t("updateAvailable")}
|
|
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
|
>
|
|
<span className="sr-only">{t("updateAvailable")}</span>
|
|
</span>
|
|
)}
|
|
</button>
|
|
<span data-testid="status-bar-counts">
|
|
{t("statusBar.running", { count: running })}
|
|
{" · "}
|
|
<span className={needYou ? "text-status-warning" : ""}>
|
|
{t("statusBar.needYou", { count: needYou })}
|
|
</span>
|
|
</span>
|
|
{today !== null && (
|
|
<CostFigure
|
|
testId="status-bar-cost"
|
|
label={t("statusBar.today", { cost: usd(today.total_cost) })}
|
|
r={today}
|
|
/>
|
|
)}
|
|
{lane !== null && laneCost !== null && (
|
|
<CostFigure
|
|
testId="status-bar-workspace-cost"
|
|
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
|
|
r={laneCost}
|
|
/>
|
|
)}
|
|
{sessionId && sessionCost !== null && (
|
|
<CostFigure
|
|
testId="status-bar-session-cost"
|
|
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
|
|
r={sessionCost}
|
|
/>
|
|
)}
|
|
{budget && (
|
|
<Link
|
|
to="/?tab=analytics&sub=cost"
|
|
data-testid="status-bar-budget"
|
|
title={t("statusBar.budgetTitle", {
|
|
name: budget.name,
|
|
spend: usd(budget.spend),
|
|
limit: usd(budget.config.limit_usd ?? 0),
|
|
})}
|
|
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
|
|
>
|
|
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
|
</Link>
|
|
)}
|
|
<QuotaChips />
|
|
</footer>
|
|
);
|
|
}
|