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:
+175
-178
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user