feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
This commit is contained in:
@@ -11,22 +11,30 @@
|
||||
* 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. No provider quota: reading Claude's quota rotates
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* 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 { useCallback, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import type { CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
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" };
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -64,6 +72,255 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
|
||||
);
|
||||
}
|
||||
|
||||
/** 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",
|
||||
};
|
||||
|
||||
/** 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>
|
||||
{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_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<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>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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] w-[19rem] max-w-[calc(100vw-1rem)] 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 [refreshing, setRefreshing] = useState(false);
|
||||
const load = useCallback((force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined)
|
||||
.finally(() => force && setRefreshing(false));
|
||||
}, []);
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
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={() => load(true)}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
@@ -213,6 +470,7 @@ export function StatusBar({
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user