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:
+332
-103
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user