feat(KB-014): add spec editing and AI revision to dashboard

- Add SpecEditor component with view/edit modes and AI revision UI
- Add spec revision API endpoint with column transition validation
- Enhance TriageProcessor for re-specification with feedback support
- Integrate Spec tab in TaskDetailModal with full edit capabilities
- Add comprehensive tests for SpecEditor and revision workflows
This commit is contained in:
gsxdsm
2026-03-29 18:42:41 -07:00
parent 70a28b54a7
commit e79e28e717
11 changed files with 1523 additions and 2432 deletions

View File

@@ -0,0 +1,455 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { SpecEditor } from "../SpecEditor";
describe("SpecEditor", () => {
const mockContent = `# KB-001 - Test Task
**Created:** 2026-01-01
**Size:** M
## Mission
Test mission description.
## Steps
### Step 1: Implementation
- [ ] Do something
`;
const mockOnSave = vi.fn().mockResolvedValue(undefined);
const mockOnRequestRevision = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
it("renders markdown content in view mode by default", () => {
render(<SpecEditor content={mockContent} />);
// Should show "View" button as active
const viewButton = screen.getByText("View");
expect(viewButton.classList.contains("btn-primary")).toBe(true);
// Should render markdown content (without leading heading)
expect(screen.getByText("Test mission description.")).toBeTruthy();
// Should not show textarea in view mode
expect(screen.queryByRole("textbox")).toBeNull();
});
it("strips leading heading from markdown rendering", () => {
render(<SpecEditor content={mockContent} />);
// The h1 heading should be stripped
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
// But other content should be rendered
expect(screen.getByText("Mission")).toBeTruthy();
});
it("shows empty state when no content", () => {
render(<SpecEditor content="" />);
expect(screen.getByText("(no specification)")).toBeTruthy();
});
it("switches to edit mode when clicking Edit button", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
// Edit button should now be active
const editButton = screen.getByText("Edit");
expect(editButton.classList.contains("btn-primary")).toBe(true);
// Textarea should be visible with full content (including heading)
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(textarea).toBeTruthy();
expect(textarea.value).toBe(mockContent);
});
it("switches back to view mode when clicking View button", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
// Enter edit mode
fireEvent.click(screen.getByText("Edit"));
expect(screen.getByRole("textbox")).toBeTruthy();
// Click View to cancel
fireEvent.click(screen.getByText("View"));
// Should be back in view mode
expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.getByText("Test mission description.")).toBeTruthy();
});
it("disables Edit button when already in edit mode", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const editButton = screen.getByText("Edit") as HTMLButtonElement;
expect(editButton.disabled).toBe(true);
});
it("disables View button when in view mode", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
const viewButton = screen.getByText("View") as HTMLButtonElement;
expect(viewButton.disabled).toBe(true);
});
it("calls onSave with edited content when clicking Save", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
// Enter edit mode
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
const newContent = "# Updated Content\n\nNew description.";
fireEvent.change(textarea, { target: { value: newContent } });
// Save button should be enabled after changes
const saveButton = screen.getByText("Save") as HTMLButtonElement;
expect(saveButton.disabled).toBe(false);
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(newContent);
});
});
it("disables save button when content is unchanged", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const saveButton = screen.getByText("Save") as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
});
it("disables save button when isSaving is true", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} isSaving={true} />);
fireEvent.click(screen.getByText("Edit"));
// Make a change
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Changed" } });
const saveButton = screen.getByText("Saving…") as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
});
it("shows saving state on save button", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} isSaving={true} />);
fireEvent.click(screen.getByText("Edit"));
expect(screen.getByText("Saving…")).toBeTruthy();
});
it("resets content to original when canceling edit", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Changed content" } });
fireEvent.click(screen.getByText("Cancel"));
// Re-enter edit mode
fireEvent.click(screen.getByText("Edit"));
// Should show original content
expect((screen.getByRole("textbox") as HTMLTextAreaElement). value).toBe(mockContent);
});
it("does not show edit controls in readOnly mode", () => {
render(<SpecEditor content={mockContent} readOnly={true} />);
expect(screen.queryByText("Edit")).toBeNull();
expect(screen.queryByText("Save")).toBeNull();
expect(screen.queryByText("Cancel")).toBeNull();
});
it("shows AI revision section when onRequestRevision is provided", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
expect(screen.getByText("Ask AI to Revise")).toBeTruthy();
expect(screen.getByPlaceholderText(/e.g., 'Add more details/)).toBeTruthy();
expect(screen.getByText("Request AI Revision")).toBeTruthy();
});
it("does not show AI revision section when onRequestRevision is not provided", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
expect(screen.queryByText("Ask AI to Revise")).toBeNull();
expect(screen.queryByText("Request AI Revision")).toBeNull();
});
it("does not show AI revision section in readOnly mode", () => {
render(
<SpecEditor
content={mockContent}
readOnly={true}
onRequestRevision={mockOnRequestRevision}
/>
);
expect(screen.queryByText("Ask AI to Revise")).toBeNull();
});
it("calls onRequestRevision with feedback when clicking Request AI Revision", async () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
const feedbackText = "Please add more details about error handling";
fireEvent.change(feedbackInput, { target: { value: feedbackText } });
fireEvent.click(screen.getByText("Request AI Revision"));
await waitFor(() => {
expect(mockOnRequestRevision).toHaveBeenCalledWith(feedbackText);
});
});
it("clears feedback after successful revision request", async () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
fireEvent.change(feedbackInput, { target: { value: "Some feedback" } });
fireEvent.click(screen.getByText("Request AI Revision"));
await waitFor(() => {
expect(feedbackInput.value).toBe("");
});
});
it("disables revision request button when feedback is empty", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
const button = screen.getByText("Request AI Revision") as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it("disables revision request button when isRequesting is true", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
isRequesting={true}
/>
);
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
fireEvent.change(feedbackInput, { target: { value: "Some feedback" } });
const button = screen.getByText("Requesting…") as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it("shows requesting state on revision button", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
isRequesting={true}
/>
);
expect(screen.getByText("Requesting…")).toBeTruthy();
});
it("shows character count for feedback", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
fireEvent.change(feedbackInput, { target: { value: "Hello" } });
expect(screen.getByText("5/2000")).toBeTruthy();
});
it("enforces maxLength on feedback textarea", () => {
render(
<SpecEditor
content={mockContent}
onSave={mockOnSave}
onRequestRevision={mockOnRequestRevision}
/>
);
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
expect(feedbackInput.getAttribute("maxLength")).toBe("2000");
});
it("triggers save on Ctrl+Enter keyboard shortcut in edit mode", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
const newContent = "# Updated Content\n\nNew description.";
fireEvent.change(textarea, { target: { value: newContent } });
// Simulate Ctrl+Enter
await act(async () => {
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
});
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(newContent);
});
});
it("triggers save on Cmd+Enter keyboard shortcut in edit mode", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
const newContent = "# Updated Content\n\nNew description.";
fireEvent.change(textarea, { target: { value: newContent } });
// Simulate Cmd+Enter
await act(async () => {
fireEvent.keyDown(document, { key: "Enter", metaKey: true });
});
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(newContent);
});
});
it("does not trigger save on Ctrl+Enter when not in edit mode", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
// Not in edit mode
await act(async () => {
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
});
expect(mockOnSave).not.toHaveBeenCalled();
});
it("does not trigger save on Ctrl+Enter when save button is disabled", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
// No changes made, so save should be disabled
await act(async () => {
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
});
expect(mockOnSave).not.toHaveBeenCalled();
});
it("prevents default on Ctrl+Enter keyboard shortcut", async () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Changed" } });
const keyDownEvent = new KeyboardEvent("keydown", {
key: "Enter",
ctrlKey: true,
bubbles: true,
});
const preventDefaultSpy = vi.spyOn(keyDownEvent, "preventDefault");
document.dispatchEvent(keyDownEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it("updates content when prop changes while in view mode", () => {
const { rerender } = render(<SpecEditor content={mockContent} />);
expect(screen.getByText("Test mission description.")).toBeTruthy();
const newContent = "# New Task\n\nUpdated description.";
rerender(<SpecEditor content={newContent} />);
expect(screen.getByText("Updated description.")).toBeTruthy();
});
it("does not update textarea content when prop changes while in edit mode", () => {
const { rerender } = render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "User edited content" } });
// Re-render with different content
rerender(<SpecEditor content="# Different\n\nDifferent content." onSave={mockOnSave} />);
// Textarea should still show user's edits
expect(textarea.value).toBe("User edited content");
});
it("shows keyboard hint in edit mode", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
fireEvent.click(screen.getByText("Edit"));
// Check for keyboard hint text (use a more specific query)
expect(screen.getByText(/Press/)).toBeTruthy();
// Check for individual kbd elements
const kbdElements = screen.getAllByText("Enter");
expect(kbdElements.length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/Ctrl/)).toBeTruthy();
expect(screen.getByText(/Cmd/)).toBeTruthy();
});
it("does not show keyboard hint in view mode", () => {
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
expect(screen.queryByText(/Ctrl/)).toBeNull();
});
});

View File

@@ -9,6 +9,7 @@ vi.mock("../../api", () => ({
updateTask: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../hooks/useAgentLogs", () => ({
@@ -36,6 +37,7 @@ const noopMove = vi.fn(async () => ({}) as Task);
const noopDelete = vi.fn(async () => ({}) as Task);
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
const noopRetry = vi.fn(async () => ({}) as Task);
const noopOpenDetail = vi.fn();
describe("TaskDetailModal", () => {
it("renders markdown-body without detail-prompt class when prompt exists", () => {
@@ -46,6 +48,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -63,6 +66,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -80,6 +84,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -98,6 +103,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -113,6 +119,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
@@ -129,6 +136,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
@@ -145,6 +153,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -164,6 +173,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -191,6 +201,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -208,6 +219,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -221,6 +233,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -248,6 +261,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
@@ -285,6 +299,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -323,6 +338,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -379,6 +395,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
@@ -409,6 +426,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -424,6 +442,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -448,6 +467,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -476,6 +496,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -502,6 +523,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -532,6 +554,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -560,6 +583,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -581,6 +605,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -609,6 +634,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -638,6 +664,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -667,6 +694,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -694,6 +722,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -735,6 +764,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -767,6 +797,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -790,6 +821,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -812,6 +844,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -839,6 +872,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -855,6 +889,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -877,6 +912,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -900,6 +936,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -926,6 +963,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -952,6 +990,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -972,6 +1011,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -994,6 +1034,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1021,6 +1062,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1042,6 +1084,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1058,6 +1101,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1074,6 +1118,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1094,18 +1139,20 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(4);
expect(tabs.length).toBe(5); // Definition, Activity, Agent Log, Steering, Spec
// Tabs should use class-based styling, not inline styles
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[3].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[4].classList.contains("detail-tab-active")).toBe(false);
// Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
@@ -1119,6 +1166,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1138,6 +1186,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1154,6 +1203,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1170,6 +1220,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1196,6 +1247,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1278,11 +1330,11 @@ describe("TaskDetailModal", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
onOpenDetail={noop}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
@@ -1383,4 +1435,105 @@ describe("TaskDetailModal", () => {
});
});
});
describe("Spec tab", () => {
it("shows Spec tab alongside other tabs", () => {
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Spec")).toBeTruthy();
});
it("switches to Spec tab when clicked", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Test\n\nSpec content." })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially not showing Spec content
expect(container.querySelector(".spec-editor")).toBeNull();
// Click Spec tab
fireEvent.click(screen.getByText("Spec"));
// Should show SpecEditor
expect(container.querySelector(".spec-editor")).toBeTruthy();
});
it("Spec tab shows SpecEditor with task prompt", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Test Task\n\nTest specification." })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Spec"));
// Should show the spec content (without leading heading)
expect(container.querySelector(".spec-editor")).toBeTruthy();
});
it("shows all 5 tabs in correct order", () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(5);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Activity");
expect(tabs[2].textContent).toBe("Agent Log");
expect(tabs[3].textContent).toBe("Steering");
expect(tabs[4].textContent).toBe("Spec");
});
it("shows empty state in Spec tab when no prompt", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Spec"));
// Should show spec editor (view mode with empty state)
expect(container.querySelector(".spec-editor")).toBeTruthy();
});
});
});