feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

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.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+175 -178
View File
@@ -1,7 +1,7 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
* General / Agents / Data (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it — and selects its text when it already has focus —, a result list
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
@@ -10,7 +10,10 @@
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
* `general` and `pet` redirect there). Small pages share one: System is the old
* About (`about` redirects), Git holds git logins and worktree suggestions
* (`git-credentials`, `worktrees`), Snapshots & reports is `reports`
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
@@ -31,17 +34,14 @@ import {
ArrowLeft,
Bell,
Database,
Info,
Bot,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
Palette,
Router,
Search,
UsersRound,
Server,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -66,9 +66,8 @@ import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -86,7 +85,7 @@ const LEGACY_TOOL: Record<string, string> = {
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-proxy",
"git-credentials": "git-credentials",
"git-credentials": "git",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
@@ -125,16 +124,15 @@ const MOVED: Record<string, string> = {
pet: "appearance#pet",
// The AI Router → AI Proxy rename.
"ai-router": "ai-proxy",
// Small pages merged into one.
about: "system",
snapshots: "reports#snapshots",
"git-credentials": "git",
worktrees: "git#worktrees",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -219,27 +217,12 @@ export function SettingsHub() {
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const allSections: Section[] = [
{
id: "data",
group: "general",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "about",
group: "general",
Icon: Info,
...s("about"),
render: () => <Settings section="about" />,
},
{
id: "appearance",
group: "general",
@@ -247,31 +230,6 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "snapshots",
group: "general",
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -279,6 +237,13 @@ export function SettingsHub() {
...s("notifications"),
render: () => <Settings section="notifications" />,
},
{
id: "system",
group: "general",
Icon: Server,
...s("system"),
render: () => <Settings section="about" />,
},
{
id: "agents",
group: "agents",
@@ -322,14 +287,21 @@ export function SettingsHub() {
})),
{
id: "ai-proxy",
group: "routing",
group: "agents",
Icon: Router,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "data",
group: "data",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "pricing",
group: "cost",
group: "data",
Icon: Coins,
...s("pricing"),
render: () => (
@@ -354,18 +326,39 @@ export function SettingsHub() {
),
},
{
id: "git-credentials",
group: "git",
Icon: KeyRound,
...s("gitCredentials"),
render: () => <GitCredentialsConfig />,
id: "reports",
group: "data",
Icon: CalendarClock,
...s("reports"),
render: () => (
<div className="space-y-6">
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: staff only. */}
{!isMember && <ReportSettings />}
</div>
),
},
{
id: "worktrees",
group: "git",
id: "git",
group: "data",
Icon: GitBranch,
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: staff only. */}
{!isMember && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</section>
)}
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
</div>
),
},
];
// Server mode: a member cannot open the host's own configuration or the
@@ -429,128 +422,132 @@ export function SettingsHub() {
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
};
const searchBox = (
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-sm text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
);
const searchResults = ranked && results.length > 0 && (
<ul
aria-label={t("search.results")}
className="card mt-2 divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
);
return (
<div className="space-y-4">
<div>
<Link
to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<label className="relative block md:w-56">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
{ranked && (
<div className="md:w-96">
{results.length === 0 ? (
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
{t("search.noMatch", { query })}
</p>
) : (
<ul
aria-label={t("search.results")}
className="card divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
i === 0 ? "bg-surface-2" : ""
}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-6">
<nav
aria-label={t("settingsNav.label")}
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
>
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
return (
<div key={g} className="mb-3">
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t(`settingsNav.group.${g}`)}
</p>
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
id === active.id
? "bg-accent/15 text-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{title}</span>
</Link>
))}
</div>
);
})}
</nav>
<div className="min-w-0 flex-1">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select mb-4 w-full py-1.5 md:hidden"
>
<div className="flex gap-8">
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
{searchBox}
{searchResults}
<nav aria-label={t("settingsNav.label")} className="mt-4 space-y-5">
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
const label = t(`settingsNav.group.${g}`);
return (
<div key={g} role="group" aria-label={label}>
<p className="px-2 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="space-y-0.5">
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
))}
</div>
</div>
);
})}
</select>
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
<div className="mb-6 mt-4 border-b border-border" />
</nav>
</aside>
<div className="min-w-0 max-w-5xl flex-1 pb-10">
<div className="mb-4 md:hidden">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
);
})}
</select>
</div>
<header className="mb-6 flex items-start gap-3 border-b border-border pb-5">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-accent">
<active.Icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<h1 className="text-xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div>
</header>
{active.render()}
</div>
</div>