7a88b74016
- 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>
651 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|