Files
AgentHub/client/src/pages/SettingsHub.tsx
T
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

651 lines
24 KiB
TypeScript

/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* 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
* clears), and one page per section. A `#anchor` in the URL scrolls to the
* element with that `id` or `data-settings-anchor` and briefly highlights it.
* Back returns to the page before Settings when there is one, else /workspace;
* In server mode a signed-in account gets **Account** first (its own
* information, display name/email, password and sign-ins —
* `AccountSettings`); local mode has no account and no such section.
* 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). 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. Those pages are not in the nav: they are tabs on AI tools (the nav
* marks AI tools current, the search shows their matches under it), and a
* row in the AI tools list opens its tool's tab. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* With `section` it is a workspace's Settings tab instead (`?settings=` on
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useEffect,
useRef,
useState,
type ComponentType,
type MouseEvent,
type ReactNode,
} from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Bell,
Database,
Bot,
CalendarClock,
Coins,
GitBranch,
Palette,
Router,
Search,
Server,
UserRound,
} from "lucide-react";
import { Settings } from "./Settings";
import { useSettingsHref } from "../lib/settingsLink";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AccountSettings } from "../components/settings/AccountSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import {
rankSettings,
rankSettingsEntries,
SETTINGS_INDEX,
type SettingsEntry,
} from "../lib/settingsSearch";
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth, usePermissions } from "../components/AuthGate";
import { PageTabs } from "../components/PageTabs";
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
id: string;
group: Group;
title: string;
description: string;
Icon: ComponentType<{ className?: string }>;
render: () => ReactNode;
/** A tool's own page: a tab on AI tools, not a nav item. */
tool?: string;
}
/** The nav item a section sits under: a tool's page is a tab on AI tools. */
const navId = (id: string) => (id.startsWith("agents/") ? "agents" : id);
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "appearance",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-proxy",
"git-credentials": "git",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
const HIGHLIGHT = ["ring-2", "ring-accent/60", "rounded-lg"];
/** A history entry's index as react-router's BrowserRouter records it. */
const historyIdx = (): number => {
const idx = (window.history.state as { idx?: unknown } | null)?.idx;
return typeof idx === "number" ? idx : 0;
};
/**
* How far Back moves: to the entry before the one Settings was entered at, and
* always at least one step — after a browser Back below that entry, the plain
* difference would be 0 (`history.go(0)`, a reload) or a step forward.
*/
export const backDelta = (current: number, entry: number): number =>
-Math.max(1, current - entry + 1);
/** Sections folded into Appearance: old links and bookmarks still land. */
/** A tool's own mark as a nav icon, one stable component per tool id. */
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
function toolNavIcon(id: string, label: string) {
let Icon = navIcons.get(id);
if (!Icon) {
Icon = ({ className }) => (
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
);
navIcons.set(id, Icon);
}
return Icon;
}
const MOVED: Record<string, string> = {
general: "appearance",
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 that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
// AI Proxy is not here: every user keeps personal profiles (S3).
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
/** An unknown section: say so, then show Appearance (`to`). */
function UnknownSection({ name, to }: { name: string; to: string }) {
const { t } = useTranslation("settings");
useEffect(() => {
toast.info(t("settingsNav.unknownSection", { section: name }));
}, [name, t]);
return <Navigate to={to} replace />;
}
export function SettingsHub({ section }: { section?: string } = {}) {
const { t } = useTranslation("settings");
const location = useLocation();
const navigate = useNavigate();
const embedded = section !== undefined;
const href = useSettingsHref();
const nav = embedded ? { replace: true } : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const aiTools = useAiTools();
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
// The history index Settings was entered at: Back unwinds to the entry
// before it rather than always landing on /workspace.
const entryIdx = useRef<number | null>(null);
if (entryIdx.current === null) entryIdx.current = historyIdx();
const canGoBack = entryIdx.current > 0;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
// A workspace's Settings tab hidden behind a session: not on screen.
if (rootRef.current?.closest(".hidden")) return;
// A config editor's own find keeps the shortcut.
const el = e.target;
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
return;
// An open modal owns the keyboard: never pull focus to a box behind it.
if (document.querySelector('[aria-modal="true"]')) return;
e.preventDefault();
const box = searchRef.current;
if (!box) return;
// A second press while the box has focus selects its text, so the
// shortcut always does something visible (and never opens the browser's).
if (document.activeElement === box) box.select();
else box.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Deep link to a row: scroll to the anchor once the page has rendered it
// (content loads async, so poll briefly) and highlight it for a moment.
useEffect(() => {
let id = location.hash.slice(1);
try {
id = decodeURIComponent(id);
} catch {
// A malformed escape (`#%`): look the raw text up instead of crashing.
}
if (!id) return;
let timer: number | undefined;
let lit: Element | null = null;
let tries = 0;
const find = () =>
document.getElementById(id) ??
document.querySelector(`[data-settings-anchor="${id.replace(/["\\]/g, "")}"]`);
const tick = () => {
const el = find();
if (!el) {
if (++tries < 30) timer = window.setTimeout(tick, 100);
return;
}
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView?.({ block: "start", behavior: reduce ? "auto" : "smooth" });
el.classList.add(...HIGHLIGHT);
lit = el;
timer = window.setTimeout(() => {
el.classList.remove(...HIGHLIGHT);
lit = null;
}, 1600);
};
tick();
return () => {
window.clearTimeout(timer);
lit?.classList.remove(...HIGHLIGHT);
};
}, [location.key, location.hash]);
const { isAdmin, local } = usePermissions();
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.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[] = [
...(signedIn
? [
{
id: "account",
group: "general" as const,
Icon: UserRound,
...s("account"),
render: () => <AccountSettings />,
},
]
: []),
{
id: "appearance",
group: "general",
Icon: Palette,
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "notifications",
group: "general",
Icon: Bell,
...s("notifications"),
render: () => <Settings section="notifications" />,
},
{
id: "system",
group: "general",
Icon: Server,
...s("system"),
render: () => <Settings section="about" />,
},
{
id: "agents",
group: "agents",
Icon: Bot,
...s("agents"),
render: () => (
<div className="space-y-6">
<AgentInstructionsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
</div>
),
},
...(enabled("claude")
? [
{
id: "agents/claude-code",
group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
render: () => <CcConfig />,
},
]
: []),
...(enabled("codex")
? [
{
id: "agents/codex",
group: "agents" as const,
tool: "codex",
Icon: toolNavIcon("codex", "Codex"),
...s("codex"),
render: () => <CodexConfig />,
},
]
: []),
...aiTools.tools
.filter(
(x) => x.installed && x.enabled && !x.launcher && x.id !== "claude" && x.id !== "codex"
)
.map((x) => ({
id: `agents/${x.id}`,
group: "agents" as const,
tool: x.id,
Icon: toolNavIcon(x.id, x.label),
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
})),
{
id: "ai-proxy",
group: "agents",
Icon: Router,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "data",
group: "data",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "pricing",
group: "data",
Icon: Coins,
...s("pricing"),
render: () => (
<div className="space-y-6">
<PricingSettings
vendor="claude"
allowReset
id="pricing-claude"
heading={t("pricing.vendor.claude")}
/>
<PricingSettings
vendor="openai"
id="pricing-openai"
heading={t("pricing.vendor.openai")}
/>
<PricingSettings
vendor="google"
id="pricing-google"
heading={t("pricing.vendor.google")}
/>
</div>
),
},
{
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: an admin's. */}
{isAdmin && <ReportSettings />}
</div>
),
},
{
id: "git",
group: "data",
Icon: GitBranch,
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: an admin's. Not in local mode,
nor the identity below: git uses this machine's own config,
SSH keys and credential helper there. */}
{isAdmin && !local && (
<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>
)}
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
{!local && (isAdmin || signedIn) && (
<section id="git-identity" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
{t("gitIdentity.title")}
</h2>
<GitIdentitySettings />
</section>
)}
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
</div>
),
},
];
// Server mode: only an admin changes the host's own configuration (the
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
const page = toolTabs.find((x) => x.tool === id);
return page ? href(page.id) : undefined;
}
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
...SETTINGS_INDEX.filter(
(e) =>
(isAdmin || !e.adminOnly) &&
(!local || !e.serverOnly) &&
(e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
!SETTINGS_INDEX.some((x) => x.section === sec.id)))
).map((e) => ({
section: sec.id,
title: t(e.titleKey),
description: e.descKey ? t(e.descKey) : undefined,
keywords: e.keywords,
to: e.to,
})),
]);
const rest = embedded
? section
: location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
if (!rest) {
const params = new URLSearchParams(location.search);
const tool = params.get("tool");
const to =
location.hash === "#ai-tools"
? "agents"
: tool
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
: "appearance";
return <Navigate to={href(to)} replace />;
}
if (MOVED[rest]) return <Navigate to={href(MOVED[rest])} replace />;
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded)
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-40 w-full" />
</div>
);
return <UnknownSection name={rest} to={href("appearance")} />;
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked
? navSections.filter((x) => ranked.some((r) => navId(r.section) === x.id))
: navSections;
const first = ranked?.[0] ? navId(ranked[0].section) : null;
const navActive = navId(active.id);
const results = ranked ? rankSettingsEntries(query, entries) : [];
const titleOf = (id: string) => sections.find((x) => x.id === id)?.title ?? id;
const hrefOf = (e: SettingsEntry) => href(`${e.section}${e.to ?? ""}`);
const go = (id: string) => navigate(href(id), nav);
const back = (e: MouseEvent) => {
if (!canGoBack) return;
e.preventDefault();
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]), nav);
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)}
replace={embedded}
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 ref={rootRef}>
{!embedded && (
<Link
to="/workspace"
onClick={back}
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>
)}
<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 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={href(id)}
replace={embedded}
aria-current={id === navActive ? "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 === navActive
? "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 === navActive ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
))}
</div>
</div>
);
})}
</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={navActive}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = navSections.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>
{navActive === "agents" && toolTabs.length > 0 ? (
<PageTabs
ariaLabel={t("settingsNav.toolTabs")}
activeId={active.id}
onSelect={go}
tabs={[
{ id: "agents", label: t("settingsNav.toolsTab"), Icon: Bot },
...toolTabs.map(({ id, title, Icon }) => ({ id, label: title, Icon })),
]}
>
<div className="pt-4">{active.render()}</div>
</PageTabs>
) : (
active.render()
)}
</div>
</div>
</div>
);
}