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