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
+6 -5
View File
@@ -190,8 +190,9 @@ client/
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
│ │ ├── settings/ # Settings panels, among them:
│ │ │ ├── AiToolsModal.tsx # AI Tools dialog, opened from the Settings tab bar's right edge
│ │ │ ├── AiToolsSettings.tsx # Its body: installed/available CLIs, enable/default, install/update
│ │ │ ├── SettingsRow.tsx # Row primitives: SettingsSection, SettingsRow, SettingsSwitchRow, SettingsSegmented
│ │ │ ├── AiToolsSettings.tsx # Settings → Agents → AI tools: installed/available CLIs, enable/default, install/update
│ │ │ ├── WorktreeVisibilitySettings.tsx # Settings → Git → Worktrees: which untracked worktrees the sidebar lists
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
@@ -252,7 +253,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → Overview, Antigravity:<br/>each tool's pricing + hooks]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
@@ -501,12 +502,12 @@ function App() {
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} />
{/* legacy path - Claude config now lives in a Settings tab */}
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
{/* legacy paths - lanes and runs are one page now */}
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route path="run" element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<Workspace />} />
<Route path="settings" element={<SettingsHub />} />
<Route path="settings/*" element={<SettingsHub />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+2 -2
View File
@@ -103,11 +103,11 @@ export default function App() {
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route
path="cc-config"
element={<Navigate to="/settings?tool=claude-code" replace />}
element={<Navigate to="/settings/agents/claude-code" replace />}
/>
<Route path="run" element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<Workspace />} />
<Route path="settings" element={<SettingsHub />} />
<Route path="settings/*" element={<SettingsHub />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+7 -2
View File
@@ -27,7 +27,11 @@ import {
sortByStoredOrder,
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import {
hiddenSourcesFor,
useProjectTreePrefs,
writeProjectTreePrefs,
} from "../lib/projectTreePrefs";
import { WorkspaceRow } from "./lanes/WorkspaceRow";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal";
@@ -199,7 +203,8 @@ export function ProjectTree() {
const visible = untracked.filter(
(w) =>
revealed.has(key) ||
(prefs.showUntracked && !(w.source && prefs.hideSources.includes(w.source)))
(prefs.showUntracked &&
!(w.source && hiddenSourcesFor(prefs, p.root).includes(w.source)))
);
const hidden = untracked.length - visible.length;
const collapsed = !!prefs.collapsed[key];
+1 -1
View File
@@ -437,7 +437,7 @@ export function AddLaneModal({
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings#ai-tools" className="underline">
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
@@ -26,7 +26,7 @@ export interface DestructiveLaneModalProps {
onConfirm: (body: { expect: Record<string, string | number | null>; force?: true }) => void;
}
function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
export function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
if (preflight.action === "purge") {
return {
sessions: preflight.sessions,
@@ -826,7 +826,7 @@ describe("AddLaneModal — tool selection", () => {
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
"href",
"/settings#ai-tools"
"/settings/agents"
);
});
@@ -1,67 +0,0 @@
/**
* @file AiToolsModal.tsx
* @description The AI Tools dialog opened from the Settings tab bar's right
* edge: a header with the title, description and a close button around
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
* nested ConfirmModal already handled (defaultPrevented) is left to it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Bot, X } from "lucide-react";
import { AiToolsSettings } from "./AiToolsSettings";
/**
* @param props.onClose - Called on Escape, a backdrop click or the X.
* @returns The modal.
*/
export function AiToolsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation("settings");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="ai-tools-title"
onClick={(e) => e.stopPropagation()}
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2
id="ai-tools-title"
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<Bot className="h-4 w-4 text-fg-muted" />
{t("aiTools.title")}
</h2>
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("common:close")}
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<AiToolsSettings />
</div>
</div>
);
}
@@ -1,7 +1,6 @@
/**
* @file AiToolsSettings.tsx
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
* @description Settings → Agents → AI tools (`/settings/agents`): every AI coding CLI in the server's catalog
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
@@ -1,6 +1,6 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance: the Dark / Light / System mode switch and
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
@@ -9,7 +9,8 @@
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { Monitor, Moon, Sun } from "lucide-react";
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
const THEME_LABELS: Record<string, string> = {
@@ -59,70 +60,60 @@ export function AppearanceSettings() {
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
return (
<section id="appearance" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Palette className="w-4 h-4 text-fg-muted" />
{t("appearance.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
<div className="card p-5 space-y-5">
<SettingsRow
label={t("appearance.mode")}
control={
<SettingsSegmented
ariaLabel={t("appearance.mode")}
value={mode}
onChange={setMode}
options={MODES.map(({ mode: m, Icon }) => ({
value: m,
label: (
<>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</>
),
}))}
/>
}
/>
<div className="card p-5 space-y-5">
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{MODES.map(({ mode: m, Icon }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={mode === m}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</button>
))}
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,131 @@
/**
* @file SettingsRow — the settings page's row primitives: a titled card
* (`SettingsSection`), a label/description | control row (`SettingsRow`), a
* switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`). Rows divide themselves with a hairline, none after the
* last, so a section is just its rows in order.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ReactNode } from "react";
export function SettingsSection({
title,
description,
children,
}: {
title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card p-5">
<h3 className="text-sm font-semibold text-fg-primary">{title}</h3>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
<div className="mt-2">{children}</div>
</section>
);
}
export function SettingsRow({
label,
description,
control,
alignTop = false,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
}) {
return (
<div
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
<div className="min-w-0">
<p className="text-sm text-fg-secondary">{label}</p>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
export function SettingsSwitchRow({
label,
description,
checked,
onChange,
disabled = false,
}: {
label: string;
description?: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<SettingsRow
label={label}
description={description}
control={
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
checked ? "bg-accent" : "bg-surface-4"
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
checked ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
}
/>
);
}
export function SettingsSegmented<T extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: { value: T; label: ReactNode }[];
value: T;
onChange: (v: T) => void;
ariaLabel?: string;
}) {
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
{options.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
value === o.value
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,61 @@
/**
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether the
* sidebar's project tree lists a repo's untracked worktrees, and which sources
* of them (`.claude/worktrees/*`, AgentHub-managed) it hides. Writes the tree's
* own localStorage prefs (`lib/projectTreePrefs.ts`), so the sidebar follows at
* once; a project's own override lives on its Settings → Projects page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../../lib/projectTreePrefs";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export const WORKTREE_SOURCES = ["claude", "agenthub"] as const;
/** Show/hide switches for each source over a `hideSources` list. */
export function SourceSwitches({
hidden,
onChange,
disabled = false,
}: {
hidden: string[];
onChange: (hidden: string[]) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("settings");
return (
<>
{WORKTREE_SOURCES.map((src) => (
<SettingsSwitchRow
key={src}
label={t(`worktrees.${src}`)}
description={t(`worktrees.${src}Desc`)}
checked={!hidden.includes(src)}
disabled={disabled}
onChange={(show) => onChange(show ? hidden.filter((s) => s !== src) : [...hidden, src])}
/>
))}
</>
);
}
export function WorktreeVisibilitySettings() {
const { t } = useTranslation("settings");
const prefs = useProjectTreePrefs();
return (
<SettingsSection title={t("worktrees.title")} description={t("worktrees.description")}>
<SettingsSwitchRow
label={t("worktrees.showUntracked")}
description={t("worktrees.showUntrackedDesc")}
checked={prefs.showUntracked}
onChange={(showUntracked) => writeProjectTreePrefs({ showUntracked })}
/>
<SourceSwitches
hidden={prefs.hideSources}
disabled={!prefs.showUntracked}
onChange={(hideSources) => writeProjectTreePrefs({ hideSources })}
/>
</SettingsSection>
);
}
@@ -1,16 +1,13 @@
/**
* @file AiToolsSettings.test.tsx
* @description The AI Tools modal: the Settings tab bar's button and the
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
* @description The AI Tools page: installed rows carry an
* enable switch and Set default, available rows fold behind a disclosure with
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
* disappears from the hub, every updater gets Update without a version
* a homepage link, Refresh re-detects, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -244,73 +241,6 @@ describe("AiToolsSettings — install and update", () => {
});
});
describe("SettingsHub — AI tool tabs", () => {
it("hides the Codex tab while Codex is disabled", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
});
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude" }),
tool("gemini", "Gemini"),
tool("qwen-code", "Qwen Code", { enabled: false }),
tool("aider", "Aider", { installed: false }),
],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(screen.queryByRole("dialog")).toBeNull();
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens the modal for the /settings#ai-tools deep link", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
});
});
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
@@ -24,7 +24,8 @@ describe("AppearanceSettings", () => {
const pressed = screen
.getAllByRole("button", { pressed: true })
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
expect(pressed).toHaveLength(2); // both Default cards
expect(screen.getByRole("radio", { name: /Dark/ })).toHaveAttribute("aria-checked", "true");
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
@@ -37,17 +38,17 @@ describe("AppearanceSettings", () => {
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /Canvas/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /^Light$/ }));
await user.click(screen.getByRole("radio", { name: /^Light$/ }));
expect(root.classList.contains("dark")).toBe(false);
expect(root.dataset.theme).toBe("canvas");
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
});
it("the System mode button is pressed after choosing it", async () => {
it("the System mode is checked after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /System/ }));
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("radio", { name: /System/ }));
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
@@ -0,0 +1,43 @@
/**
* @file WorktreeVisibilitySettings.test.tsx
* @description Settings → Worktrees writes the sidebar tree's own prefs key
* (`agenthub.sidebar.projects`), disables the source rows while untracked
* worktrees are hidden, and a project's override (`hiddenSourcesFor`) replaces
* the global list for that project only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
import { hiddenSourcesFor, readProjectTreePrefs } from "../../../lib/projectTreePrefs";
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
beforeEach(() => localStorage.clear());
describe("WorktreeVisibilitySettings", () => {
it("writes the sidebar's prefs key", async () => {
const user = userEvent.setup();
render(<WorktreeVisibilitySettings />);
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
expect(stored().hideSources).toEqual(["claude"]);
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
expect(stored().hideSources).toEqual([]);
await user.click(screen.getByRole("switch", { name: "Show untracked worktrees" }));
expect(stored().showUntracked).toBe(false);
expect(screen.getByRole("switch", { name: "AgentHub-managed worktrees" })).toBeDisabled();
});
it("lets a project's own list replace the global one", () => {
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], projectHideSources: { "/r": [] } })
);
const prefs = readProjectTreePrefs();
expect(hiddenSourcesFor(prefs, "/r")).toEqual([]);
expect(hiddenSourcesFor(prefs, "/other")).toEqual(["claude"]);
expect(hiddenSourcesFor(prefs, null)).toEqual(["claude"]);
});
});
+92 -9
View File
@@ -463,15 +463,6 @@
"remoteSourceBadgeTitle": "Collected from a remote machine"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
"tools": {
"label": "Tools",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"antigravity": "Antigravity",
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex — 9Router, OpenRouter, a self-hosted gateway. Save one here, then pick it when you create a workspace.",
@@ -639,5 +630,97 @@
"save": "Save",
"close": "Close",
"saved": "Saved. Backup: {{backup}}"
},
"settingsNav": {
"label": "Settings sections",
"back": "Back to Workspace",
"group": {
"general": "General",
"agents": "Agents",
"routing": "Routing",
"git": "Git",
"projects": "Projects"
},
"section": {
"general": {
"title": "General",
"description": "Workspace housekeeping, data and about."
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only."
},
"notifications": {
"title": "Notifications",
"description": "Browser notifications, alert rules and webhooks."
},
"agents": {
"title": "AI tools",
"description": "The AI coding CLIs AgentHub can run: enable, set the default, install and update."
},
"claudeCode": {
"title": "Claude Code",
"description": "Claude Code's own configuration, and what AgentHub reads from it."
},
"codex": {
"title": "Codex",
"description": "Codex's own configuration, and what AgentHub reads from it."
},
"tool": {
"description": "{{tool}}: path, usage, default model and config files."
},
"aiRouter": {
"title": "AI Router",
"description": "Endpoints that stand in front of Claude Code and Codex."
},
"pricing": {
"title": "Pricing",
"description": "Model prices for every vendor, used to estimate cost."
},
"gitCredentials": {
"title": "Git credentials",
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push."
},
"worktrees": {
"title": "Worktrees",
"description": "Which git worktrees the sidebar lists under each project."
},
"project": {
"description": "This project's workspaces and worktree settings."
}
}
},
"search": {
"placeholder": "Search settings",
"noMatch": "No setting matches “{{query}}”."
},
"worktrees": {
"title": "Untracked worktrees",
"description": "A worktree with no workspace on it is listed under its project with an Adopt button. Saved in this browser only.",
"showUntracked": "Show untracked worktrees",
"showUntrackedDesc": "List a repository's worktrees that have no workspace yet.",
"claude": "Claude Code worktrees",
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
"agenthub": "AgentHub-managed worktrees",
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
},
"project": {
"notFound": "No such project.",
"facts": "Project",
"root": "Root",
"mainBranch": "Main branch",
"worktreeCount": "Worktrees",
"workspaces": "Workspaces",
"noWorkspaces": "No workspace in this project yet.",
"sources": "Worktree sources",
"sourcesDesc": "Override the Worktrees page for this project only.",
"override": "Use this project's own setting",
"forget": "Forget workspaces",
"forgetDesc": "Remove every workspace of this project from AgentHub. Working trees, branches and data on disk are left alone.",
"forgetAll": "Forget all workspaces…",
"forgetTitle": "Forget {{count}} workspaces?",
"forgetBody": "AgentHub drops its record of each one and stops its runs. Nothing on disk is deleted.",
"forgetConfirm": "Forget all",
"forgetFailed": "Could not forget {{lane}}: {{message}}"
}
}
+92 -9
View File
@@ -463,15 +463,6 @@
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa"
},
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
"tools": {
"label": "Công cụ",
"dashboard": "Dashboard",
"claudeCode": "Claude Code",
"codex": "Codex",
"antigravity": "Antigravity",
"aiRouter": "AI Router",
"gitCredentials": "Git credentials"
},
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex — 9Router, OpenRouter, hoặc gateway tự dựng. Lưu ở đây rồi chọn lúc tạo workspace.",
@@ -639,5 +630,97 @@
"save": "Lưu",
"close": "Đóng",
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
},
"settingsNav": {
"label": "Các mục cài đặt",
"back": "Quay lại Không gian làm việc",
"group": {
"general": "Chung",
"agents": "Tác tử",
"routing": "Định tuyến",
"git": "Git",
"projects": "Dự án"
},
"section": {
"general": {
"title": "Chung",
"description": "Dọn dẹp workspace, dữ liệu và thông tin."
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
},
"notifications": {
"title": "Thông báo",
"description": "Thông báo trình duyệt, quy tắc cảnh báo và webhook."
},
"agents": {
"title": "AI tools",
"description": "Các CLI lập trình AI mà AgentHub chạy được: bật, đặt mặc định, cài và cập nhật."
},
"claudeCode": {
"title": "Claude Code",
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
},
"codex": {
"title": "Codex",
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
},
"tool": {
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
},
"aiRouter": {
"title": "AI Router",
"description": "Các endpoint đứng trước Claude Code và Codex."
},
"pricing": {
"title": "Định giá",
"description": "Giá mô hình của mọi nhà cung cấp, dùng để ước tính chi phí."
},
"gitCredentials": {
"title": "Git credentials",
"description": "Thông tin đăng nhập AgentHub đưa cho git khi fetch, pull hoặc push từ dashboard."
},
"worktrees": {
"title": "Worktree",
"description": "Những git worktree mà sidebar liệt kê dưới mỗi dự án."
},
"project": {
"description": "Các workspace và cài đặt worktree của dự án này."
}
}
},
"search": {
"placeholder": "Tìm cài đặt",
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
},
"worktrees": {
"title": "Worktree chưa theo dõi",
"description": "Worktree chưa có workspace được liệt kê dưới dự án của nó kèm nút Nhận. Chỉ lưu trong trình duyệt này.",
"showUntracked": "Hiện worktree chưa theo dõi",
"showUntrackedDesc": "Liệt kê các worktree của repo chưa có workspace.",
"claude": "Worktree của Claude Code",
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
"agenthub": "Worktree do AgentHub quản lý",
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
},
"project": {
"notFound": "Không có dự án này.",
"facts": "Dự án",
"root": "Thư mục gốc",
"mainBranch": "Nhánh chính",
"worktreeCount": "Worktree",
"workspaces": "Workspace",
"noWorkspaces": "Dự án này chưa có workspace.",
"sources": "Nguồn worktree",
"sourcesDesc": "Ghi đè trang Worktree cho riêng dự án này.",
"override": "Dùng cài đặt riêng của dự án",
"forget": "Quên workspace",
"forgetDesc": "Gỡ mọi workspace của dự án này khỏi AgentHub. Thư mục làm việc, nhánh và dữ liệu trên đĩa được giữ nguyên.",
"forgetAll": "Quên tất cả workspace…",
"forgetTitle": "Quên {{count}} workspace?",
"forgetBody": "AgentHub xoá bản ghi của từng workspace và dừng các lượt chạy. Không có gì trên đĩa bị xoá.",
"forgetConfirm": "Quên tất cả",
"forgetFailed": "Không quên được {{lane}}: {{message}}"
}
}
@@ -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);
}
+1 -1
View File
@@ -221,7 +221,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
</a>
)}
{tool.update && (
<Link to={`/settings?tool=${toolId}#ai-tools`} className="btn-ghost text-xs">
<Link to="/settings/agents" className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
+171
View File
@@ -0,0 +1,171 @@
/**
* @file ProjectSettings — Settings → Projects → <project>: a read-only card of
* the repository (root, main branch, worktree count), its workspaces linked to
* the Workspace page, this project's own override of the worktree sources the
* sidebar hides (`projectHideSources` in `lib/projectTreePrefs.ts`), and
* "Forget all workspaces", which runs the same `remove` as the row menu for
* each one — preflight, then `POST /api/lanes/:id/remove` with `confirm` and
* the preflight facts as `expect`. Forgetting drops AgentHub's record only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import type { Lane, Project } from "../lib/types";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { ConfirmModal } from "../components/ConfirmModal";
import { expectFor } from "../components/lanes/DestructiveLaneModal";
import {
SettingsRow,
SettingsSection,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import { SourceSwitches } from "../components/settings/WorktreeVisibilitySettings";
export function ProjectSettings({
project,
lanes,
onForgotten,
}: {
project: Project & { root: string };
lanes: Lane[];
/** Called after the forget loop ends, so the caller can re-read lanes. */
onForgotten: () => void;
}) {
const { t } = useTranslation("settings");
const prefs = useProjectTreePrefs();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const own = lanes.filter((l) => project.workspaces.includes(l.id));
const main = project.worktrees.find((w) => w.main);
const override = prefs.projectHideSources[project.root];
const setOverride = (hidden: string[] | null) => {
const next = { ...prefs.projectHideSources };
if (hidden) next[project.root] = hidden;
else delete next[project.root];
writeProjectTreePrefs({ projectHideSources: next });
};
const forgetAll = async () => {
setBusy(true);
setError(null);
try {
for (const lane of own) {
try {
const preflight = await api.lanes.preflight(lane.id, "remove");
await api.lanes.action(lane.id, "remove", {
confirm: true,
expect: expectFor(preflight),
});
} catch (err) {
setError(
t("project.forgetFailed", {
lane: lane.title || lane.cwd,
message: err instanceof Error ? err.message : String(err),
})
);
break;
}
}
} finally {
setBusy(false);
setConfirming(false);
onForgotten();
}
};
return (
<div className="space-y-4">
<SettingsSection title={t("project.facts")}>
<SettingsRow
label={t("project.root")}
control={
<span className="break-all font-mono text-xs text-fg-primary">{project.root}</span>
}
/>
<SettingsRow
label={t("project.mainBranch")}
control={<span className="font-mono text-xs text-fg-primary">{main?.branch ?? "—"}</span>}
/>
<SettingsRow
label={t("project.worktreeCount")}
control={<span className="text-xs text-fg-primary">{project.worktrees.length}</span>}
/>
</SettingsSection>
<SettingsSection title={t("project.workspaces")}>
{own.length === 0 ? (
<p className="py-2 text-xs text-fg-muted">{t("project.noWorkspaces")}</p>
) : (
<ul data-testid="project-workspaces">
{own.map((l) => (
<li key={l.id} className="border-b border-border/60 py-2 last:border-b-0">
<Link
to={`/workspace?lane=${l.id}`}
className="text-sm text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
<span className="ml-2 font-mono text-[11px] text-fg-muted">#{l.id}</span>
</li>
))}
</ul>
)}
</SettingsSection>
<SettingsSection title={t("project.sources")} description={t("project.sourcesDesc")}>
<SettingsSwitchRow
label={t("project.override")}
checked={!!override}
onChange={(on) => setOverride(on ? [...prefs.hideSources] : null)}
/>
<SourceSwitches
hidden={override ?? prefs.hideSources}
disabled={!override}
onChange={setOverride}
/>
</SettingsSection>
<SettingsSection title={t("project.forget")} description={t("project.forgetDesc")}>
<div className="py-2">
<button
type="button"
disabled={own.length === 0 || busy}
onClick={() => setConfirming(true)}
className="rounded-md border border-status-danger/40 px-3 py-1.5 text-xs text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("project.forgetAll")}
</button>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</div>
</SettingsSection>
<ConfirmModal
open={confirming}
title={t("project.forgetTitle", { count: own.length })}
message={t("project.forgetBody")}
confirmLabel={t("project.forgetConfirm")}
cancelLabel={t("common:cancel")}
busy={busy}
onConfirm={() => void forgetAll()}
onCancel={() => setConfirming(false)}
>
<ul className="max-h-48 overflow-y-auto text-xs text-fg-secondary">
{own.map((l) => (
<li key={l.id} className="font-mono">
#{l.id} {l.title || l.cwd}
</li>
))}
</ul>
</ConfirmModal>
</div>
);
}
File diff suppressed because it is too large Load Diff
+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>
);
}
@@ -1,7 +1,7 @@
/**
* @file AiToolConfig.test.tsx
* @description The generic per-tool Settings tab: the tool's path, homepage
* and an Update link into the AI Tools modal; its session count, cost and
* and an Update link to Settings → Agents; its session count, cost and
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
* catalog-declared config files, edited over the sha they were opened at with
* the server's refusal shown.
@@ -112,7 +112,7 @@ describe("AiToolConfig", () => {
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
"href",
"/settings?tool=gemini#ai-tools"
"/settings/agents"
);
expect(screen.getByText("pricing:google")).toBeInTheDocument();
});
@@ -0,0 +1,99 @@
/**
* @file ProjectSettings.test.tsx
* @description A project's settings page: its facts and workspaces, the
* per-project source override written under `projectHideSources`, and
* "Forget all workspaces" running preflight + `remove` (with the preflight
* facts as `expect`) for each of the project's workspaces only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
preflight: vi.fn(async (id: number) => ({
action: "remove",
head: `h${id}`,
dirty: 0,
untracked: 0,
unpushed: 0,
})),
action: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../lib/api";
import { ProjectSettings } from "../ProjectSettings";
const lane = (id: number, cwd: string) => ({ id, cwd, title: "" }) as unknown as Lane;
const project: Project & { root: string } = {
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [{ path: "/r", branch: "main", main: true, workspace_id: 1 }],
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
function renderPage(onForgotten = vi.fn()) {
render(
<MemoryRouter>
<ProjectSettings
project={project}
lanes={[lane(1, "/r"), lane(2, "/r/sub"), lane(3, "/elsewhere")]}
onForgotten={onForgotten}
/>
</MemoryRouter>
);
return onForgotten;
}
describe("ProjectSettings", () => {
it("shows the project's facts and only its own workspaces", () => {
renderPage();
expect(screen.getByText("main")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "/r/sub" })).toHaveAttribute(
"href",
"/workspace?lane=2"
);
expect(screen.queryByText("/elsewhere")).toBeNull();
});
it("writes a per-project source override", async () => {
const user = userEvent.setup();
renderPage();
expect(screen.getByRole("switch", { name: "Claude Code worktrees" })).toBeDisabled();
await user.click(screen.getByRole("switch", { name: "Use this project's own setting" }));
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
const stored = JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
expect(stored.projectHideSources).toEqual({ "/r": ["claude"] });
expect(stored.hideSources).toEqual([]);
});
it("forgets every workspace of the project after a confirm", async () => {
const user = userEvent.setup();
const onForgotten = renderPage();
await user.click(screen.getByRole("button", { name: "Forget all workspaces…" }));
await user.click(screen.getByRole("button", { name: "Forget all" }));
await waitFor(() => expect(onForgotten).toHaveBeenCalled());
expect(api.lanes.action).toHaveBeenCalledTimes(2);
expect(api.lanes.action).toHaveBeenCalledWith(1, "remove", {
confirm: true,
expect: { head: "h1", dirty: 0, untracked: 0, unpushed: 0 },
});
expect(api.lanes.action).toHaveBeenCalledWith(2, "remove", expect.anything());
});
});
@@ -0,0 +1,197 @@
/**
* @file SettingsHub.test.tsx
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, a project gets its page, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
import type { Project } from "../../lib/types";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
const tool = (id: string, over: Partial<AiTool> = {}) =>
({
id,
label: id[0]!.toUpperCase() + id.slice(1),
installed: true,
enabled: true,
launcher: null,
...over,
}) as AiTool;
let view: AiToolsView;
let projects: Project[];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
projects: { list: vi.fn(async () => ({ projects })) },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
});
import { SettingsHub } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderAt(at: string) {
return render(
<MemoryRouter initialEntries={[at]}>
<Routes>
<Route
path="/settings/*"
element={
<>
<SettingsHub />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => {
__resetAiTools();
view = {
default: "claude",
tools: [
tool("claude", { launcher: "claude" }),
tool("codex", { launcher: "codex" }),
tool("gemini"),
tool("qwen", { enabled: false }),
tool("aider", { installed: false }),
],
};
projects = [
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
];
});
describe("SettingsHub", () => {
it("renders the section its path names, marked current in the nav", async () => {
renderAt("/settings/appearance");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(within(nav()).getByRole("link", { name: "Appearance" })).toHaveAttribute(
"aria-current",
"page"
);
});
it.each([
["/settings", "/settings/general"],
["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
["/settings#ai-tools", "/settings/agents"],
])("redirects %s to %s", async (from, to) => {
renderAt(from);
await screen.findByRole("heading", { level: 1 });
expect(screen.getByTestId("where").textContent).toBe(to);
});
it("sends an unknown section to General once the lists have loaded", async () => {
renderAt("/settings/nope");
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
});
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
renderAt("/settings/agents/gemini");
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toContain("Claude Code");
expect(links).not.toContain("Codex");
expect(links).not.toContain("Qwen");
expect(links).not.toContain("Aider");
});
it("lists a project under Projects and opens its page", async () => {
renderAt(`/settings/projects/${encodeURIComponent("/r/app")}`);
expect(await screen.findByRole("heading", { level: 1, name: "app" })).toBeInTheDocument();
expect(screen.getByText("/r/app")).toBeInTheDocument();
expect(within(nav()).queryByText("Folders")).toBeNull();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" },
});
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
expect(screen.getByTestId("pricing-openai")).toBeInTheDocument();
});
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing");
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
"data-first-match",
"true"
);
await user.keyboard("{Enter}");
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
await user.keyboard("{Escape}");
expect(box).toHaveValue("");
expect(within(nav()).getAllByRole("link").length).toBeGreaterThan(3);
});
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup();
renderAt("/settings/general");
await screen.findByTestId("settings-general");
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus();
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -585,6 +585,9 @@ describe("screen snapshots", () => {
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Settings — notifications", async () => {
await snapshot(<Settings section="notifications" />, "/settings/notifications");
});
it("Not found", async () => {
await snapshot(<NotFound />, "/nope");
});