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
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -56,7 +56,11 @@ Internal build — all rights reserved.
**Configure**
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General / Agents / Routing / Git / Projects, a search box (Ctrl+F)
over every section, a page per installed AI tool, per-vendor model pricing,
which untracked worktrees the sidebar lists, and a page per project.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
about — read for all of them, edit/create/delete for the text-file surfaces
+4
View File
@@ -49,6 +49,10 @@ Internal build — all rights reserved.
**Cấu hình**
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
trái chia nhóm Chung / Tác tử / Định tuyến / Git / Dự án, ô tìm kiếm (Ctrl+F)
trên mọi mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung
cấp, worktree chưa theo dõi nào được hiện trên sidebar, và một trang cho mỗi dự án.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
+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");
});
+4 -4
View File
@@ -696,7 +696,7 @@ DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
this install; the **Git credentials** tab of Settings
(`/settings?tool=git-credentials`) and the git bar's login form both write
(`/settings/git-credentials`) and the git bar's login form both write
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
@@ -1966,7 +1966,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
### Claude Config Explorer
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/overview
@@ -2060,7 +2060,7 @@ Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]`
### Codex Config Explorer
The `/api/codex-config/*` namespace powers the **Codex** tab of Settings (`/settings?tool=codex`) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: `"user"` or `"project"` only, no `"project-local"` and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (`server/lib/codex-discovery.js`); MCP servers, plugins, and marketplaces have no on-disk reader and are read *and* mutated entirely through the `codex` CLI (`server/lib/codex-cli.js`), because the CLI owns `config.toml`'s schema.
The `/api/codex-config/*` namespace powers the **Codex** page of Settings (`/settings/agents/codex`) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: `"user"` or `"project"` only, no `"project-local"` and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (`server/lib/codex-discovery.js`); MCP servers, plugins, and marketplaces have no on-disk reader and are read *and* mutated entirely through the `codex` CLI (`server/lib/codex-cli.js`), because the CLI owns `config.toml`'s schema.
```http
GET /api/codex-config/overview
@@ -2159,7 +2159,7 @@ DELETE /api/ai-routers/:name Delete one saved profile
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the workspaces" below.
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a workspace happens in the "+ Add workspace" modal, or later from the workspace's card in Workspace.
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** page of Settings (`/settings/ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a workspace happens in the "+ Add workspace" modal, or later from the workspace's card in Workspace.
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
+3 -2
View File
@@ -392,7 +392,8 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's project tree shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's project tree picks the workspace** — on every page, between the nav and the footer (`client/src/components/ProjectTree.tsx`). Workspaces are grouped under **Projects**: one per git repository, keyed by the repo's MAIN worktree root, with every workspace outside a repo under **Folders** (last). The grouping comes from `GET /api/projects` (see `docs/API.md`), read when the set of workspaces changes or on the header's Refresh — never on every status frame, so it shells out to git only then, and caches what it read. A row (`WorkspaceRow.tsx`) shows a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name, a `primary` badge on the repo's own root, the branch (or the path relative to the repo), the tool's icon and the id as `#23` — the number the CLI addresses it by. Clicking a row opens `/workspace?lane=<id>`: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The project's `+` opens the Add-workspace modal prefilled with that repo; the PROJECTS header has Refresh and a plain `+`. In the collapsed rail the tree is a single icon that opens the sidebar.
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Untracked worktrees** — a repo's worktrees that no workspace sits on (from `git worktree list`) are listed dimmed under the project, with **Adopt** (`POST /api/lanes/ensure {exact: true}` with the default AI tool, then selected). Shown by default; the tree's preferences (`agenthub.sidebar.projects` in `localStorage`: collapsed projects, `showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) can hide them, and the project row then says "N hidden" — a click shows them for this visit.
- **Untracked worktrees** — a repo's worktrees that no workspace sits on (from `git worktree list`) are listed dimmed under the project, with **Adopt** (`POST /api/lanes/ensure {exact: true}` with the default AI tool, then selected). Shown by default; the tree's preferences (`agenthub.sidebar.projects` in `localStorage`: collapsed projects, `showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) can hide them — Settings → Git → **Worktrees** (`/settings/worktrees`) for every project, and a project's own page (`/settings/projects/<encoded root>`) to override the sources for that project only (`projectHideSources`) — and the project row then says "N hidden" — a click shows them for this visit.
- **A project's settings page** — Settings → Projects → <repo> shows the repository's root, main branch and worktree count, links each of its workspaces, holds the per-project worktree override above, and offers **Forget all workspaces…**: after one confirm listing them it runs the same `remove` as the row menu for each (preflight, then `POST /api/lanes/:id/remove` with those facts as `expect`) and stops at the first refusal. Forgetting drops AgentHub's records only — nothing on disk is deleted.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row onto another within its project, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END of its project; a stored id whose workspace is gone is skipped.
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
@@ -1714,7 +1715,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file: