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
@@ -0,0 +1,40 @@
/**
* @file settingsSearch.test.ts
* @description `rankSettings`: an empty query matches nothing, a section title
* outranks an entry title, which outranks a description and a keyword, exact
* beats prefix beats substring, and a query matching nothing returns nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { rankSettings, type SettingsEntry } from "../settingsSearch";
const entries: SettingsEntry[] = [
{ section: "pricing", title: "Pricing", sectionTitle: true },
{ section: "claude", title: "Claude Code", sectionTitle: true },
{ section: "claude", title: "Model Pricing" },
{ section: "about", title: "About", description: "Server pricing info" },
{ section: "kw", title: "Other", keywords: ["pricing"] },
{ section: "prefix", title: "Pricing tables", sectionTitle: true },
];
describe("rankSettings", () => {
it("returns nothing for an empty or blank query", () => {
expect(rankSettings("", entries)).toEqual([]);
expect(rankSettings(" ", entries)).toEqual([]);
});
it("orders by field tier, then exact > prefix > substring", () => {
expect(rankSettings("Pricing", entries)).toEqual([
{ section: "pricing", score: 900 },
{ section: "prefix", score: 850 },
{ section: "claude", score: 600 },
{ section: "about", score: 400 },
{ section: "kw", score: 300 },
]);
});
it("returns nothing when no field matches", () => {
expect(rankSettings("zzz", entries)).toEqual([]);
});
});
+19 -2
View File
@@ -3,7 +3,8 @@
* ONE localStorage key (`agenthub.sidebar.projects`): which projects are
* collapsed, whether a repo's untracked worktrees are listed, and which
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
* LANES_ROOT) are hidden. A `useSyncExternalStore` reader that caches nothing
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
* but the parsed blob for the current raw string, so the sidebar and Settings
* can never disagree. Nothing here reaches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -17,9 +18,16 @@ export interface ProjectTreePrefs {
collapsed: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
/** Per project root: replaces `hideSources` for that project. */
projectHideSources: Record<string, string[]>;
}
const DEFAULTS: ProjectTreePrefs = { collapsed: {}, showUntracked: true, hideSources: [] };
const DEFAULTS: ProjectTreePrefs = {
collapsed: {},
showUntracked: true,
hideSources: [],
projectHideSources: {},
};
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
@@ -41,6 +49,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
collapsed: parsed.collapsed && typeof parsed.collapsed === "object" ? parsed.collapsed : {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
projectHideSources:
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
? parsed.projectHideSources
: {},
};
} catch {
value = DEFAULTS;
@@ -58,6 +70,11 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
listeners.forEach((l) => l());
}
/** The sources hidden for one project: its own override, else the global list. */
export function hiddenSourcesFor(prefs: ProjectTreePrefs, root: string | null): string[] {
return (root !== null && prefs.projectHideSources[root]) || prefs.hideSources;
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
+93
View File
@@ -0,0 +1,93 @@
/**
* @file settingsSearch — the Settings page's search: `SETTINGS_INDEX` names the
* rows each section holds (as i18n keys, resolved at render), and
* `rankSettings` scores sections against a query. Per field, exact beats
* prefix beats substring: section title 900/850/800, entry title 700/650/600,
* description 500/450/400, keywords 300/250/200; a section scores its best hit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface SettingsEntry {
section: string;
title: string;
description?: string;
keywords?: string[];
/** The section's own title (scored in the top tier). */
sectionTitle?: boolean;
}
/** One searchable row per section, by i18n key in the `settings` namespace. A
* section whose id starts with `agents/` and is not listed here is a catalog
* tool's page and takes the `agents/*` entries. */
export const SETTINGS_INDEX: {
section: string;
titleKey: string;
descKey?: string;
keywords?: string[];
}[] = [
{ section: "general", titleKey: "lanesSection.title", descKey: "lanesSection.description" },
{ section: "general", titleKey: "lanesSection.runHousekeeping" },
{ section: "general", titleKey: "import.title", keywords: ["history", "transcripts"] },
{ section: "general", titleKey: "remoteSources.title", keywords: ["ssh", "machines"] },
{ section: "general", titleKey: "sagi.title", descKey: "sagi.description" },
{ section: "general", titleKey: "data.title", descKey: "data.description" },
{ section: "general", titleKey: "danger.title", keywords: ["clear", "delete", "export"] },
{ section: "general", titleKey: "about.title", keywords: ["version", "server", "database"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
{ section: "notifications", titleKey: "notifications.enable", keywords: ["push", "browser"] },
{ section: "notifications", titleKey: "notifications.awaitingInput" },
{ section: "notifications", titleKey: "notifications.turnComplete" },
{ section: "notifications", titleKey: "alertsHub.title", descKey: "alertsHub.description" },
{ section: "notifications", titleKey: "webhooks.title", keywords: ["slack", "discord"] },
{
section: "agents",
titleKey: "aiTools.title",
descKey: "aiTools.description",
keywords: ["install", "update", "default"],
},
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title", keywords: ["profiles"] },
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-router", titleKey: "aiRouter.title", keywords: ["proxy", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "sidebar"] },
{ section: "worktrees", titleKey: "worktrees.claude" },
{ section: "worktrees", titleKey: "worktrees.agenthub" },
];
function tier(hay: string | undefined, q: string, exact: number): number {
if (!hay) return 0;
const h = hay.toLowerCase();
if (h === q) return exact;
if (h.startsWith(q)) return exact - 50;
if (h.includes(q)) return exact - 100;
return 0;
}
/** Sections matching `query`, best first; ties keep the entries' order. */
export function rankSettings(
query: string,
entries: SettingsEntry[]
): { section: string; score: number }[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const best = new Map<string, number>();
for (const e of entries) {
const score = Math.max(
tier(e.title, q, e.sectionTitle ? 900 : 700),
tier(e.description, q, 500),
...(e.keywords ?? []).map((k) => tier(k, q, 300))
);
if (score > (best.get(e.section) ?? 0)) best.set(e.section, score);
}
return [...best]
.map(([section, score]) => ({ section, score }))
.sort((a, b) => b.score - a.score);
}