feat(settings): one page per section with a grouped nav and search

Settings moves from a tab bar to /settings/<section>: a left nav grouped
General / Agents / Routing / Git / Projects (a select on narrow screens), a
search box over every section (Ctrl/Cmd+F, Enter opens the first match), and
row primitives. Each AI tool has its page under agents/<id>, the AI Tools
modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees
sets which untracked worktrees the sidebar lists, and each project gets a
page with its own worktree override and Forget all. ?tool= and #ai-tools
links redirect to the path form.
This commit is contained in:
2026-10-01 16:55:44 +07:00
parent ba2c4e1914
commit eb90244182
33 changed files with 2478 additions and 2594 deletions
+332 -103
View File
@@ -1,145 +1,374 @@
/**
* @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex have their own; every other installed, enabled catalog tool gets the generic `AiToolConfig` tab, Antigravity's with its hooks card) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git / Projects (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. 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. Each project in `GET /api/projects` gets `projects/<encoded root>`.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Bell,
Bot,
Boxes,
Coins,
FolderGit2,
GitBranch,
KeyRound,
Orbit,
Palette,
Puzzle,
Router,
Search,
Settings as SettingsIcon,
SquareTerminal,
} from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { ProjectSettings } from "./ProjectSettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolsModal } from "../components/settings/AiToolsModal";
import { useLanes } from "../hooks/useLanes";
import { api } from "../lib/api";
import type { Project } from "../lib/types";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
interface ToolTab {
const GROUPS = ["general", "agents", "routing", "git", "projects"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
id: string;
labelKey?: string;
/** A catalog tool's own label, used instead of `labelKey`. */
label?: string;
group: Group;
title: string;
description: string;
Icon: ComponentType<{ className?: string }>;
Panel?: ComponentType;
/** Set on a generic catalog-tool tab: rendered as `<AiToolConfig toolId>`. */
toolId?: string;
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
aiTool?: string;
/** Full content width (config editors and tables), else `max-w-3xl`. */
wide?: boolean;
render: () => ReactNode;
}
const DASHBOARD_TAB: ToolTab = {
id: "dashboard",
labelKey: "tools.dashboard",
Icon: SettingsIcon,
Panel: Settings,
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
const LEGACY_TOOL: Record<string, string> = {
dashboard: "general",
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-router",
"git-credentials": "git-credentials",
};
/** Add a tool here to give it a tab; that is the whole extension point. */
const TOOL_TABS: ToolTab[] = [
DASHBOARD_TAB,
{
id: "claude-code",
labelKey: "tools.claudeCode",
Icon: Boxes,
Panel: CcConfig,
aiTool: "claude",
},
{
id: "codex",
labelKey: "tools.codex",
Icon: SquareTerminal,
Panel: CodexConfig,
aiTool: "codex",
},
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{
id: "git-credentials",
labelKey: "tools.gitCredentials",
Icon: KeyRound,
Panel: GitCredentialsConfig,
},
];
/**
* Settings page shell with one tab per configuration surface.
* @returns The tab bar plus the active tab's panel.
*/
export function SettingsHub() {
const { t } = useTranslation("settings");
const [params, setParams] = useSearchParams();
const requested = params.get("tool");
const aiTools = useAiTools();
const fixed = TOOL_TABS.filter(
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
);
// Every other installed, enabled tool, after Codex; a fixed tab's id wins.
const catalogTabs: ToolTab[] = aiTools.tools
.filter((x) => x.installed && x.enabled && !x.launcher && !fixed.some((f) => f.id === x.id))
.map((x) => ({
id: x.id,
label: x.label,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
toolId: x.id,
}));
const at = fixed.findIndex((tab) => tab.id === "ai-router");
const tabs = [...fixed.slice(0, at), ...catalogTabs, ...fixed.slice(at)];
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation();
const navigate = useNavigate();
const [aiToolsOpen, setAiToolsOpen] = useState(false);
const aiTools = useAiTools();
const { lanes, refresh: refreshLanes } = useLanes();
const [projects, setProjects] = useState<Project[] | null>(null);
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const laneKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
if (location.hash === "#ai-tools") setAiToolsOpen(true);
}, [location.hash]);
let live = true;
api.projects
.list()
.then((r) => live && setProjects(r.projects))
.catch(() => live && setProjects([]));
return () => {
live = false;
};
}, [laneKey]);
const closeAiTools = useCallback(() => {
setAiToolsOpen(false);
// Drop the deep link, or a reload would open the modal again.
if (location.hash) navigate({ search: location.search }, { replace: true });
}, [location.hash, location.search, navigate]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") 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;
e.preventDefault();
searchRef.current?.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
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 sections: Section[] = [
{
id: "general",
group: "general",
Icon: SettingsIcon,
...s("general"),
render: () => <Settings section="general" />,
},
{
id: "appearance",
group: "general",
Icon: Palette,
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "notifications",
group: "general",
Icon: Bell,
...s("notifications"),
render: () => <Settings section="notifications" />,
},
{
id: "agents",
group: "agents",
Icon: Bot,
wide: true,
...s("agents"),
render: () => <AiToolsSettings />,
},
...(enabled("claude")
? [
{
id: "agents/claude-code",
group: "agents" as const,
Icon: Boxes,
wide: true,
...s("claudeCode"),
render: () => <CcConfig />,
},
]
: []),
...(enabled("codex")
? [
{
id: "agents/codex",
group: "agents" as const,
Icon: SquareTerminal,
wide: true,
...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,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
title: x.label,
description: t("settingsNav.section.tool.description", { tool: x.label }),
wide: true,
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
})),
{
id: "ai-router",
group: "routing",
Icon: Router,
wide: true,
...s("aiRouter"),
render: () => <AiRouterConfig />,
},
{
id: "pricing",
group: "routing",
Icon: Coins,
wide: true,
...s("pricing"),
render: () => (
<div className="space-y-6">
<PricingSettings vendor="claude" allowReset id="pricing-claude" />
<PricingSettings vendor="openai" id="pricing-openai" />
<PricingSettings vendor="google" id="pricing-google" />
</div>
),
},
{
id: "git-credentials",
group: "git",
Icon: KeyRound,
...s("gitCredentials"),
render: () => <GitCredentialsConfig />,
},
{
id: "worktrees",
group: "git",
Icon: GitBranch,
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
},
...(projects ?? []).flatMap((p) =>
p.root === null
? []
: [
{
id: `projects/${encodeURIComponent(p.root)}`,
group: "projects" as const,
Icon: FolderGit2,
title: p.name,
description: t("settingsNav.section.project.description"),
render: () => (
<ProjectSettings
project={{ ...p, root: p.root as string }}
lanes={lanes}
onForgotten={() => void refreshLanes()}
/>
),
},
]
),
];
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
...SETTINGS_INDEX.filter(
(e) =>
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,
})),
]);
const rest = 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}`)
: "general";
return <Navigate to={`/settings/${to}`} replace />;
}
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool or project page exists only once its list has loaded.
if (!aiTools.loaded || projects === null) return null;
return <Navigate to="/settings/general" replace />;
}
const ranked = query.trim() ? rankSettings(query, entries) : null;
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
const first = ranked?.[0]?.section ?? null;
const go = (id: string) => navigate(`/settings/${id}`);
return (
<div className="space-y-6">
<PageTabs
ariaLabel={t("tools.label", "Tools")}
activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={tabs.map(({ id, labelKey, label, Icon }) => ({
id,
Icon,
label: label ?? t(labelKey ?? id),
}))}
actions={
<button
type="button"
onClick={() => setAiToolsOpen(true)}
aria-haspopup="dialog"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Bot className="h-3.5 w-3.5" />
{t("aiTools.title")}
</button>
}
/>
<div className="space-y-4">
<Link
to="/workspace"
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("settingsNav.back")}
</Link>
{active.toolId ? (
<AiToolConfig key={active.toolId} toolId={active.toolId} />
) : (
active.Panel && <active.Panel />
)}
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
<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" && first) go(first);
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>
<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 ${active.wide ? "" : "max-w-3xl"}`}>
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="mb-4 w-full rounded-lg border border-border bg-surface-1 px-2 py-1.5 text-sm text-fg-primary md:hidden"
>
{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>
<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" />
{active.render()}
</div>
</div>
</div>
);
}