feat(HAI-031): add sidebar navigation to settings modal

- Add sidebar navigation structure with section links to SettingsModal
- Implement sidebar and form layout styles in styles.css
- Add SettingsModal sidebar navigation tests for section switching
- Update SettingsModal component with multi-section support
This commit is contained in:
Dustin Byrne
2026-03-25 22:43:12 -04:00
parent c51fa74732
commit 67c3caf239
3 changed files with 253 additions and 30 deletions

View File

@@ -3,6 +3,23 @@ import type { Settings } from "@hai/core";
import { fetchSettings, updateSettings } from "../api";
import type { ToastType } from "../hooks/useToast";
/**
* Settings sections configuration.
*
* Each section groups related settings fields under a sidebar nav item.
* To add a new section:
* 1. Add an entry to SETTINGS_SECTIONS with a unique id and label
* 2. Add a corresponding case in renderSectionFields()
*/
const SETTINGS_SECTIONS = [
{ id: "scheduling", label: "Scheduling" },
{ id: "worktrees", label: "Worktrees" },
{ id: "commands", label: "Commands" },
{ id: "merge", label: "Merge" },
] as const;
type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
interface SettingsModalProps {
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
@@ -11,6 +28,7 @@ interface SettingsModalProps {
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: "" });
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<SectionId>(SETTINGS_SECTIONS[0].id);
useEffect(() => {
fetchSettings()
@@ -53,19 +71,12 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
}
}, [form, onClose, addToast]);
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal">
<div className="modal-header">
<h3>Settings</h3>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
{loading ? (
<div style={{ padding: "20px", textAlign: "center" }}>Loading</div>
) : (
<div className="settings-form">
const renderSectionFields = () => {
switch (activeSection) {
case "scheduling":
return (
<>
<h4 className="settings-section-heading">Scheduling</h4>
<div className="form-group">
<label htmlFor="maxConcurrent">Max Concurrent Tasks</label>
<input
@@ -79,20 +90,6 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
}
/>
</div>
<div className="form-group">
<label htmlFor="maxWorktrees">Max Worktrees</label>
<input
id="maxWorktrees"
type="number"
min={1}
max={20}
value={form.maxWorktrees}
onChange={(e) =>
setForm((f) => ({ ...f, maxWorktrees: Number(e.target.value) }))
}
/>
<small>Limits total git worktrees including in-review tasks</small>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
@@ -120,6 +117,26 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
</label>
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
</div>
</>
);
case "worktrees":
return (
<>
<h4 className="settings-section-heading">Worktrees</h4>
<div className="form-group">
<label htmlFor="maxWorktrees">Max Worktrees</label>
<input
id="maxWorktrees"
type="number"
min={1}
max={20}
value={form.maxWorktrees}
onChange={(e) =>
setForm((f) => ({ ...f, maxWorktrees: Number(e.target.value) }))
}
/>
<small>Limits total git worktrees including in-review tasks</small>
</div>
<div className="form-group">
<label htmlFor="worktreeInitCommand">Worktree Init Command</label>
<input
@@ -133,6 +150,12 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
/>
<small>Shell command to run in each new worktree after creation</small>
</div>
</>
);
case "commands":
return (
<>
<h4 className="settings-section-heading">Commands</h4>
<div className="form-group">
<label htmlFor="testCommand">Test Command</label>
<input
@@ -159,6 +182,58 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
/>
<small>Command used to build the project injected into generated task specs</small>
</div>
</>
);
case "merge":
return (
<>
<h4 className="settings-section-heading">Merge</h4>
<div className="form-group">
<label htmlFor="autoMerge" style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
id="autoMerge"
type="checkbox"
checked={form.autoMerge}
onChange={(e) =>
setForm((f) => ({ ...f, autoMerge: e.target.checked }))
}
/>
Auto-merge completed tasks
</label>
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</div>
</>
);
}
};
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal modal-lg">
<div className="modal-header">
<h3>Settings</h3>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
{loading ? (
<div style={{ padding: "20px", textAlign: "center" }}>Loading</div>
) : (
<div className="settings-layout">
<nav className="settings-sidebar">
{SETTINGS_SECTIONS.map((section) => (
<button
key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
>
{section.label}
</button>
))}
</nav>
<div className="settings-content">
{renderSectionFields()}
</div>
</div>
)}
<div className="modal-actions">

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SettingsModal } from "../SettingsModal";
import type { Settings } from "@hai/core";
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
};
vi.mock("../../api", () => ({
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
}));
import { fetchSettings, updateSettings } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("SettingsModal", () => {
it("renders all sidebar section labels", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Each label appears in the sidebar nav
const nav = screen.getAllByText("Scheduling");
expect(nav.length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Worktrees").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Commands").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Merge").length).toBeGreaterThanOrEqual(1);
});
it("shows Scheduling fields by default", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
// Fields from other sections should not be visible
expect(screen.queryByLabelText("Max Worktrees")).toBeNull();
expect(screen.queryByLabelText("Test Command")).toBeNull();
});
it("switches section when clicking sidebar item", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click Commands
fireEvent.click(screen.getByText("Commands"));
expect(screen.getByLabelText("Test Command")).toBeTruthy();
expect(screen.getByLabelText("Build Command")).toBeTruthy();
expect(screen.queryByLabelText("Max Concurrent Tasks")).toBeNull();
});
it("all settings fields are present across all sections", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Scheduling (default)
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
// Worktrees
fireEvent.click(screen.getByText("Worktrees"));
expect(screen.getByLabelText("Max Worktrees")).toBeTruthy();
expect(screen.getByLabelText("Worktree Init Command")).toBeTruthy();
// Commands
fireEvent.click(screen.getByText("Commands"));
expect(screen.getByLabelText("Test Command")).toBeTruthy();
expect(screen.getByLabelText("Build Command")).toBeTruthy();
// Merge
fireEvent.click(screen.getByText("Merge"));
expect(screen.getByText("Auto-merge completed tasks")).toBeTruthy();
});
it("save button calls updateSettings with form data", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.maxConcurrent).toBe(2);
expect(payload.pollIntervalMs).toBe(15000);
});
});

View File

@@ -372,14 +372,62 @@ html, body {
}
.form-group textarea { resize: vertical; }
/* === Settings Form === */
.settings-form {
/* === Settings Layout === */
.settings-layout {
display: flex;
flex-direction: row;
min-height: 300px;
overflow: hidden;
}
.settings-sidebar {
width: 170px;
min-width: 170px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
gap: 2px;
}
.settings-nav-item {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
border-radius: var(--radius);
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.settings-nav-item:hover {
background: var(--bg);
color: var(--text);
}
.settings-nav-item.active {
background: var(--bg);
color: var(--todo);
font-weight: 600;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
}
.settings-section-heading {
font-size: 14px;
font-weight: 600;
padding: 12px 20px 0;
margin: 0;
color: var(--text);
}
/* === Detail Modal === */
.detail-title-row { display: flex; align-items: center; gap: 10px; }