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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+546
-746
File diff suppressed because it is too large
Load Diff
+332
-103
@@ -1,145 +1,374 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex have their own; every other installed, enabled catalog tool gets the generic `AiToolConfig` tab, Antigravity's with its hooks card) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
|
||||
* @description The Settings page: `/settings/<section>`, a left nav grouped
|
||||
* General / Agents / Routing / Git / Projects (a `<select>` below `md`), a
|
||||
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
|
||||
* focuses it, Enter opens the first match, Escape clears), and one page per
|
||||
* section. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. Each project in `GET /api/projects` gets `projects/<encoded root>`.
|
||||
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Bot,
|
||||
Boxes,
|
||||
Coins,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Palette,
|
||||
Puzzle,
|
||||
Router,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolsModal } from "../components/settings/AiToolsModal";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { api } from "../lib/api";
|
||||
import type { Project } from "../lib/types";
|
||||
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
|
||||
|
||||
interface ToolTab {
|
||||
const GROUPS = ["general", "agents", "routing", "git", "projects"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
labelKey?: string;
|
||||
/** A catalog tool's own label, used instead of `labelKey`. */
|
||||
label?: string;
|
||||
group: Group;
|
||||
title: string;
|
||||
description: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
Panel?: ComponentType;
|
||||
/** Set on a generic catalog-tool tab: rendered as `<AiToolConfig toolId>`. */
|
||||
toolId?: string;
|
||||
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */
|
||||
aiTool?: string;
|
||||
/** Full content width (config editors and tables), else `max-w-3xl`. */
|
||||
wide?: boolean;
|
||||
render: () => ReactNode;
|
||||
}
|
||||
|
||||
const DASHBOARD_TAB: ToolTab = {
|
||||
id: "dashboard",
|
||||
labelKey: "tools.dashboard",
|
||||
Icon: SettingsIcon,
|
||||
Panel: Settings,
|
||||
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
|
||||
const LEGACY_TOOL: Record<string, string> = {
|
||||
dashboard: "general",
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-router",
|
||||
"git-credentials": "git-credentials",
|
||||
};
|
||||
|
||||
/** Add a tool here to give it a tab; that is the whole extension point. */
|
||||
const TOOL_TABS: ToolTab[] = [
|
||||
DASHBOARD_TAB,
|
||||
{
|
||||
id: "claude-code",
|
||||
labelKey: "tools.claudeCode",
|
||||
Icon: Boxes,
|
||||
Panel: CcConfig,
|
||||
aiTool: "claude",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
labelKey: "tools.codex",
|
||||
Icon: SquareTerminal,
|
||||
Panel: CodexConfig,
|
||||
aiTool: "codex",
|
||||
},
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
labelKey: "tools.gitCredentials",
|
||||
Icon: KeyRound,
|
||||
Panel: GitCredentialsConfig,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Settings page shell with one tab per configuration surface.
|
||||
* @returns The tab bar plus the active tab's panel.
|
||||
*/
|
||||
export function SettingsHub() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [params, setParams] = useSearchParams();
|
||||
const requested = params.get("tool");
|
||||
const aiTools = useAiTools();
|
||||
const fixed = TOOL_TABS.filter(
|
||||
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
|
||||
);
|
||||
// Every other installed, enabled tool, after Codex; a fixed tab's id wins.
|
||||
const catalogTabs: ToolTab[] = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled && !x.launcher && !fixed.some((f) => f.id === x.id))
|
||||
.map((x) => ({
|
||||
id: x.id,
|
||||
label: x.label,
|
||||
Icon: x.id === "antigravity" ? Orbit : Puzzle,
|
||||
toolId: x.id,
|
||||
}));
|
||||
const at = fixed.findIndex((tab) => tab.id === "ai-router");
|
||||
const tabs = [...fixed.slice(0, at), ...catalogTabs, ...fixed.slice(at)];
|
||||
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [aiToolsOpen, setAiToolsOpen] = useState(false);
|
||||
const aiTools = useAiTools();
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const laneKey = lanes.map((l) => l.id).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash === "#ai-tools") setAiToolsOpen(true);
|
||||
}, [location.hash]);
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then((r) => live && setProjects(r.projects))
|
||||
.catch(() => live && setProjects([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneKey]);
|
||||
|
||||
const closeAiTools = useCallback(() => {
|
||||
setAiToolsOpen(false);
|
||||
// Drop the deep link, or a reload would open the modal again.
|
||||
if (location.hash) navigate({ search: location.search }, { replace: true });
|
||||
}, [location.hash, location.search, navigate]);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== "f") return;
|
||||
// A config editor's own find keeps the shortcut.
|
||||
const el = e.target;
|
||||
if (el instanceof Element && el.closest("textarea, [contenteditable='true'], .cm-editor"))
|
||||
return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: "general",
|
||||
group: "general",
|
||||
Icon: SettingsIcon,
|
||||
...s("general"),
|
||||
render: () => <Settings section="general" />,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
group: "general",
|
||||
Icon: Palette,
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
Icon: Bell,
|
||||
...s("notifications"),
|
||||
render: () => <Settings section="notifications" />,
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
group: "agents",
|
||||
Icon: Bot,
|
||||
wide: true,
|
||||
...s("agents"),
|
||||
render: () => <AiToolsSettings />,
|
||||
},
|
||||
...(enabled("claude")
|
||||
? [
|
||||
{
|
||||
id: "agents/claude-code",
|
||||
group: "agents" as const,
|
||||
Icon: Boxes,
|
||||
wide: true,
|
||||
...s("claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(enabled("codex")
|
||||
? [
|
||||
{
|
||||
id: "agents/codex",
|
||||
group: "agents" as const,
|
||||
Icon: SquareTerminal,
|
||||
wide: true,
|
||||
...s("codex"),
|
||||
render: () => <CodexConfig />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...aiTools.tools
|
||||
.filter(
|
||||
(x) => x.installed && x.enabled && !x.launcher && x.id !== "claude" && x.id !== "codex"
|
||||
)
|
||||
.map((x) => ({
|
||||
id: `agents/${x.id}`,
|
||||
group: "agents" as const,
|
||||
Icon: x.id === "antigravity" ? Orbit : Puzzle,
|
||||
title: x.label,
|
||||
description: t("settingsNav.section.tool.description", { tool: x.label }),
|
||||
wide: true,
|
||||
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
||||
})),
|
||||
{
|
||||
id: "ai-router",
|
||||
group: "routing",
|
||||
Icon: Router,
|
||||
wide: true,
|
||||
...s("aiRouter"),
|
||||
render: () => <AiRouterConfig />,
|
||||
},
|
||||
{
|
||||
id: "pricing",
|
||||
group: "routing",
|
||||
Icon: Coins,
|
||||
wide: true,
|
||||
...s("pricing"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<PricingSettings vendor="claude" allowReset id="pricing-claude" />
|
||||
<PricingSettings vendor="openai" id="pricing-openai" />
|
||||
<PricingSettings vendor="google" id="pricing-google" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "git-credentials",
|
||||
group: "git",
|
||||
Icon: KeyRound,
|
||||
...s("gitCredentials"),
|
||||
render: () => <GitCredentialsConfig />,
|
||||
},
|
||||
{
|
||||
id: "worktrees",
|
||||
group: "git",
|
||||
Icon: GitBranch,
|
||||
...s("worktrees"),
|
||||
render: () => <WorktreeVisibilitySettings />,
|
||||
},
|
||||
...(projects ?? []).flatMap((p) =>
|
||||
p.root === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: `projects/${encodeURIComponent(p.root)}`,
|
||||
group: "projects" as const,
|
||||
Icon: FolderGit2,
|
||||
title: p.name,
|
||||
description: t("settingsNav.section.project.description"),
|
||||
render: () => (
|
||||
<ProjectSettings
|
||||
project={{ ...p, root: p.root as string }}
|
||||
lanes={lanes}
|
||||
onForgotten={() => void refreshLanes()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
),
|
||||
];
|
||||
|
||||
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
|
||||
...SETTINGS_INDEX.filter(
|
||||
(e) =>
|
||||
e.section === sec.id ||
|
||||
(e.section === "agents/*" &&
|
||||
sec.id.startsWith("agents/") &&
|
||||
!SETTINGS_INDEX.some((x) => x.section === sec.id))
|
||||
).map((e) => ({
|
||||
section: sec.id,
|
||||
title: t(e.titleKey),
|
||||
description: e.descKey ? t(e.descKey) : undefined,
|
||||
keywords: e.keywords,
|
||||
})),
|
||||
]);
|
||||
|
||||
const rest = location.pathname.replace(/^\/settings\/?/, "").replace(/\/$/, "");
|
||||
if (!rest) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tool = params.get("tool");
|
||||
const to =
|
||||
location.hash === "#ai-tools"
|
||||
? "agents"
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "general";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
}
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool or project page exists only once its list has loaded.
|
||||
if (!aiTools.loaded || projects === null) return null;
|
||||
return <Navigate to="/settings/general" replace />;
|
||||
}
|
||||
|
||||
const ranked = query.trim() ? rankSettings(query, entries) : null;
|
||||
const shown = ranked ? sections.filter((x) => ranked.some((r) => r.section === x.id)) : sections;
|
||||
const first = ranked?.[0]?.section ?? null;
|
||||
const go = (id: string) => navigate(`/settings/${id}`);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageTabs
|
||||
ariaLabel={t("tools.label", "Tools")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
|
||||
tabs={tabs.map(({ id, labelKey, label, Icon }) => ({
|
||||
id,
|
||||
Icon,
|
||||
label: label ?? t(labelKey ?? id),
|
||||
}))}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiToolsOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
{t("aiTools.title")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t("settingsNav.back")}
|
||||
</Link>
|
||||
|
||||
{active.toolId ? (
|
||||
<AiToolConfig key={active.toolId} toolId={active.toolId} />
|
||||
) : (
|
||||
active.Panel && <active.Panel />
|
||||
)}
|
||||
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
|
||||
<label className="relative block md:w-56">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && first) go(first);
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<nav
|
||||
aria-label={t("settingsNav.label")}
|
||||
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
|
||||
>
|
||||
{ranked && shown.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
|
||||
)}
|
||||
{GROUPS.map((g) => {
|
||||
const items = shown.filter((x) => x.group === g);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={g} className="mb-3">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t(`settingsNav.group.${g}`)}
|
||||
</p>
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
|
||||
id === active.id
|
||||
? "bg-accent/15 text-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${active.wide ? "" : "max-w-3xl"}`}>
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="mb-4 w-full rounded-lg border border-border bg-surface-1 px-2 py-1.5 text-sm text-fg-primary md:hidden"
|
||||
>
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.title}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
|
||||
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
|
||||
<div className="mb-6 mt-4 border-b border-border" />
|
||||
{active.render()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user