feat(HAI-078): add configurable task prefix to settings
- Add taskPrefix field to Settings type and use it in allocateId - Update store listTasks and handleFsChange to support any task prefix - Update triage duplicate regex to match configurable prefix - Add Task Prefix input to dashboard SettingsModal UI - Add tests for prefix-aware task allocation and settings modal
This commit is contained in:
@@ -12,6 +12,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
* 2. Add a corresponding case in renderSectionFields()
|
||||
*/
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "scheduling", label: "Scheduling" },
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
{ id: "commands", label: "Commands" },
|
||||
@@ -29,6 +30,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, recycleWorktrees: false, worktreeInitCommand: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(SETTINGS_SECTIONS[0].id);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
@@ -58,10 +60,12 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError) return;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||
taskPrefix: form.taskPrefix?.trim() || undefined,
|
||||
};
|
||||
await updateSettings(payload);
|
||||
addToast("Settings saved", "success");
|
||||
@@ -69,10 +73,36 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [form, onClose, addToast]);
|
||||
}, [form, prefixError, onClose, addToast]);
|
||||
|
||||
const renderSectionFields = () => {
|
||||
switch (activeSection) {
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">General</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="taskPrefix">Task Prefix</label>
|
||||
<input
|
||||
id="taskPrefix"
|
||||
type="text"
|
||||
placeholder="HAI"
|
||||
value={form.taskPrefix || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, taskPrefix: val || undefined }));
|
||||
if (val && !/^[A-Z]{1,10}$/.test(val)) {
|
||||
setPrefixError("Prefix must be 1–10 uppercase letters");
|
||||
} else {
|
||||
setPrefixError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{prefixError && <small className="field-error" style={{ color: "var(--color-error, #e74c3c)" }}>{prefixError}</small>}
|
||||
{!prefixError && <small>Prefix for new task IDs (e.g. HAI, PROJ)</small>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "scheduling":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Each label appears in the sidebar nav
|
||||
expect(screen.getAllByText("General").length).toBeGreaterThanOrEqual(1);
|
||||
const nav = screen.getAllByText("Scheduling");
|
||||
expect(nav.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Worktrees").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -42,21 +43,25 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getAllByText("Merge").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows Scheduling fields by default", async () => {
|
||||
it("shows General 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();
|
||||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||||
// Fields from other sections should not be visible
|
||||
expect(screen.queryByLabelText("Max Concurrent Tasks")).toBeNull();
|
||||
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 Scheduling
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
|
||||
|
||||
// Click Commands
|
||||
fireEvent.click(screen.getByText("Commands"));
|
||||
expect(screen.getByLabelText("Test Command")).toBeTruthy();
|
||||
@@ -68,7 +73,11 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Scheduling (default)
|
||||
// General (default)
|
||||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||||
|
||||
// Scheduling
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
|
||||
|
||||
@@ -113,10 +122,62 @@ describe("SettingsModal", () => {
|
||||
expect(payload.recycleWorktrees).toBe(true);
|
||||
});
|
||||
|
||||
it("Task Prefix field saves correctly when set", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "PROJ" } });
|
||||
expect(input.value).toBe("PROJ");
|
||||
|
||||
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.taskPrefix).toBe("PROJ");
|
||||
});
|
||||
|
||||
it("Task Prefix field submits undefined when empty (uses default)", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
|
||||
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.taskPrefix).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Task Prefix shows validation error for invalid input", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "bad" } });
|
||||
|
||||
expect(screen.getByText("Prefix must be 1–10 uppercase letters")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Task Prefix validation error prevents save", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "bad" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
// Should not have called updateSettings due to validation error
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("groupOverlappingFiles input has type checkbox", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Serialize tasks with overlapping files");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
|
||||
Reference in New Issue
Block a user