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