feat(KB-048): add collapsible list sections to dashboard

- Add section expansion state management with localStorage persistence
- Update section headers with chevron toggle controls
- Implement conditional task row rendering based on section state
- Add Expand All / Collapse All toolbar controls
- Add CSS styles for chevron rotation animation and section headers
- Add comprehensive tests for collapsible section behavior
This commit is contained in:
gsxdsm
2026-03-29 19:51:41 -07:00
parent 2ad581d829
commit 3b71d032a6
111 changed files with 23013 additions and 2936 deletions

View File

@@ -12,6 +12,7 @@ function makeMockStore() {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
stopWatching: vi.fn(),
updateSettings: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
@@ -19,6 +20,8 @@ function makeMockStore() {
pollIntervalMs: 60_000,
}),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({ column: "in-review", paused: false }),
updateTask: vi.fn().mockResolvedValue({}),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
@@ -613,3 +616,424 @@ describe("runDashboard — enginePaused (soft pause)", () => {
expect(mergedIds).toContain("KB-EP2");
});
});
describe("runDashboard — --paused flag", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(_store: unknown, _cwd: unknown, opts: unknown) => {
capturedExecutorOpts = opts as Record<string, unknown>;
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
},
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
await runDashboard(0, { open: false, paused: true });
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
});
it("logs a message when starting in paused mode", async () => {
await runDashboard(0, { open: false, paused: true });
expect(consoleSpy).toHaveBeenCalledWith(
"[engine] Starting in paused mode — automation disabled",
);
});
it("does NOT set enginePaused when paused option is absent", async () => {
await runDashboard(0, { open: false });
// updateSettings should not be called with enginePaused during normal startup
const enginePausedCalls = mockStore.updateSettings.mock.calls.filter(
(call: any[]) => call[0]?.enginePaused !== undefined,
);
expect(enginePausedCalls).toHaveLength(0);
});
it("does NOT log paused message when starting normally", async () => {
await runDashboard(0, { open: false });
const pausedMessageCalls = consoleSpy.mock.calls.filter(
(args) => args[0] === "[engine] Starting in paused mode — automation disabled",
);
expect(pausedMessageCalls).toHaveLength(0);
});
});
describe("runDashboard — --paused flag", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@kb/engine");
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
await runDashboard(0, { open: false, paused: true });
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mockStore.updateSettings).toHaveBeenCalledTimes(1);
});
it("does NOT call store.updateSettings when paused flag is absent", async () => {
await runDashboard(0, { open: false });
expect(mockStore.updateSettings).not.toHaveBeenCalled();
});
it("logs paused mode message when starting with paused: true", async () => {
await runDashboard(0, { open: false, paused: true });
expect(consoleSpy).toHaveBeenCalledWith(
"[engine] Starting in paused mode — automation disabled",
);
});
it("does NOT log paused mode message when paused flag is absent", async () => {
await runDashboard(0, { open: false });
const pausedMessageCalls = consoleSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("paused mode"),
);
expect(pausedMessageCalls).toHaveLength(0);
});
});
describe("runDashboard — --dev mode", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(_store: unknown, _cwd: unknown, opts: unknown) => {
capturedExecutorOpts = opts as Record<string, unknown>;
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
},
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("does NOT start TriageProcessor in dev mode", async () => {
const { TriageProcessor } = await import("@kb/engine");
await runDashboard(0, { open: false, dev: true });
expect(TriageProcessor).not.toHaveBeenCalled();
});
it("does NOT start TaskExecutor in dev mode", async () => {
const { TaskExecutor } = await import("@kb/engine");
await runDashboard(0, { open: false, dev: true });
expect(TaskExecutor).not.toHaveBeenCalled();
});
it("does NOT start Scheduler in dev mode", async () => {
const { Scheduler } = await import("@kb/engine");
await runDashboard(0, { open: false, dev: true });
expect(Scheduler).not.toHaveBeenCalled();
});
it("starts the server correctly in dev mode", async () => {
const { createServer } = await import("@kb/dashboard");
await runDashboard(4040, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Server should have been created and listen called
expect(createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(4040);
// Banner should show the port
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
);
});
it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Should show disabled message
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✗ disabled (dev mode)"),
);
});
it("does NOT show triage/scheduler details in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Should NOT show triage/scheduler details
const triageCall = consoleSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("• triage"),
);
const schedulerCall = consoleSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("• scheduler"),
);
expect(triageCall).toBeUndefined();
expect(schedulerCall).toBeUndefined();
});
it("starts all engine components when dev is false (default)", async () => {
const { TriageProcessor, TaskExecutor, Scheduler } = await import("@kb/engine");
await runDashboard(0, { open: false });
expect(TriageProcessor).toHaveBeenCalled();
expect(TaskExecutor).toHaveBeenCalled();
expect(Scheduler).toHaveBeenCalled();
});
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
await runDashboard(0, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Should show active message
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✓ active"),
);
});
});
describe("runDashboard — merge conflict retry logic", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
// Default mock store.getTask implementation
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 0,
}));
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(_store: unknown, _cwd: unknown, opts: unknown) => {
capturedExecutorOpts = opts as Record<string, unknown>;
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
},
);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("increments mergeRetries and re-enqueues on conflict error", async () => {
const { aiMergeTask } = await import("@kb/engine");
// Simulate merge failure with conflict
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected in package-lock.json"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
mockStore.listTasks.mockResolvedValue([
{ id: "KB-RETRY", column: "in-review", paused: false },
]);
await runDashboard(0, { open: false });
// Wait for retry scheduling
await new Promise((r) => setTimeout(r, 100));
// Should have incremented mergeRetries
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-RETRY",
expect.objectContaining({ mergeRetries: 1 }),
);
// Should log retry attempt
const retryLog = consoleSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("retry 1/3"),
);
expect(retryLog).toBeDefined();
});
it("gives up after max retries (3) exceeded", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
// Task already has 3 retries
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 3,
}));
mockStore.listTasks.mockResolvedValue([
{ id: "KB-MAX", column: "in-review", paused: false, mergeRetries: 3 },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
// Should log max retries exceeded
const maxRetryLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" && call[0].includes("max retries (3) exceeded"),
);
expect(maxRetryLog).toBeDefined();
// Should reset mergeRetries on the task
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-MAX",
expect.objectContaining({ status: null }),
);
});
it("skips retry when autoResolveConflicts is disabled", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Merge conflict detected"),
);
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: false, // Disabled
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
mockStore.listTasks.mockResolvedValue([
{ id: "KB-NO-AUTO", column: "in-review", paused: false },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
// Should log that auto-resolve is disabled
const disabledLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("autoResolveConflicts disabled"),
);
expect(disabledLog).toBeDefined();
});
it("clears mergeRetries on successful merge after retries", async () => {
const { aiMergeTask } = await import("@kb/engine");
(aiMergeTask as ReturnType<typeof vi.fn>).mockResolvedValue({ merged: true });
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
autoResolveConflicts: true,
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
// Task had previous retries
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
id,
column: "in-review",
paused: false,
mergeRetries: 2,
}));
mockStore.listTasks.mockResolvedValue([
{ id: "KB-SUCCESS", column: "in-review", paused: false, mergeRetries: 2 },
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
// Should clear mergeRetries on success
expect(mockStore.updateTask).toHaveBeenCalledWith(
"KB-SUCCESS",
expect.objectContaining({ mergeRetries: 0 }),
);
});
});