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,5 @@
---
"@dustinbyrne/kb": minor
---
Add spec editing and AI revision from dashboard. New "Spec" tab in task detail view allows manual editing of PROMPT.md and requesting AI revisions with natural language feedback.

View File

@@ -224,6 +224,26 @@ kb can create GitHub Pull Requests directly from the dashboard for tasks in the
The dashboard shows real-time PR status (open, closed, merged) with a refresh button to fetch the latest state from GitHub. The dashboard shows real-time PR status (open, closed, merged) with a refresh button to fetch the latest state from GitHub.
### Spec Editing & AI Revision
The dashboard includes a **Spec** tab for managing task specifications directly in the UI:
**Manual Edit:**
1. Open any task and click the **Spec** tab
2. Click **Edit** to modify the PROMPT.md content directly
3. Save changes with the **Save** button (or Ctrl/Cmd+Enter)
**Request AI Revision:**
1. In the Spec tab, use the **"Ask AI to Revise"** section
2. Enter feedback describing what needs to change (e.g., "Add more details about error handling", "Split this into smaller steps")
3. Click **"Request AI Revision"**
4. The task moves to **Triage** for re-specification by the AI
**Limitations:**
- AI revision is only available for tasks in **Todo** or **In Progress** columns
- Tasks in **In Review** or **Done** must be moved back to Todo/In Progress first
- Maximum feedback length is 2000 characters
### PR Comment Monitoring ### PR Comment Monitoring
When a task has a linked PR, kb automatically monitors it for new review comments: When a task has a linked PR, kb automatically monitors it for new review comments:

View File

@@ -117,6 +117,13 @@ export function addSteeringComment(id: string, text: string): Promise<Task> {
}); });
} }
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
return api<Task>(`/tasks/${id}/spec/revise`, {
method: "POST",
body: JSON.stringify({ feedback }),
});
}
// --- Models API --- // --- Models API ---
/** Available AI model info returned by the models endpoint */ /** Available AI model info returned by the models endpoint */

View File

@@ -0,0 +1,184 @@
import { useState, useCallback, useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface SpecEditorProps {
content: string;
readOnly?: boolean;
onSave?: (content: string) => Promise<void>;
onRequestRevision?: (feedback: string) => Promise<void>;
isSaving?: boolean;
isRequesting?: boolean;
}
export function SpecEditor({
content,
readOnly = false,
onSave,
onRequestRevision,
isSaving = false,
isRequesting = false,
}: SpecEditorProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(content);
const [feedback, setFeedback] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const feedbackRef = useRef<HTMLTextAreaElement>(null);
// Update edit content when external content changes (only when not editing)
useEffect(() => {
if (!isEditing) {
setEditContent(content);
}
}, [content, isEditing]);
const hasChanges = editContent !== content;
const canSave = isEditing && hasChanges && !isSaving && onSave;
const handleSave = useCallback(async () => {
if (!canSave) return;
await onSave(editContent);
setIsEditing(false);
}, [canSave, onSave, editContent]);
const handleRequestRevision = useCallback(async () => {
if (!onRequestRevision || !feedback.trim() || isRequesting) return;
await onRequestRevision(feedback.trim());
setFeedback("");
}, [onRequestRevision, feedback, isRequesting]);
// Keyboard shortcut: Ctrl/Cmd+Enter to save in edit mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditing && (e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
if (canSave) {
void handleSave();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isEditing, canSave, handleSave]);
const handleEnterEditMode = () => {
setIsEditing(true);
setEditContent(content);
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditContent(content);
};
const stripLeadingHeading = (text: string): string => {
return text.replace(/^#\s+[^\n]*\n+/, "");
};
return (
<div className="spec-editor">
{/* Toolbar */}
<div className="spec-editor-toolbar">
<div className="spec-editor-mode-toggle">
<button
className={`btn btn-sm ${!isEditing ? "btn-primary" : ""}`}
onClick={() => isEditing ? handleCancelEdit() : undefined}
disabled={!isEditing}
>
View
</button>
{!readOnly && (
<button
className={`btn btn-sm ${isEditing ? "btn-primary" : ""}`}
onClick={handleEnterEditMode}
disabled={isEditing}
>
Edit
</button>
)}
</div>
{isEditing && (
<div className="spec-editor-actions">
<button
className="btn btn-sm"
onClick={handleCancelEdit}
disabled={isSaving}
>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleSave()}
disabled={!canSave}
>
{isSaving ? "Saving…" : "Save"}
</button>
</div>
)}
</div>
{/* Content Area */}
<div className="spec-editor-content">
{isEditing ? (
<textarea
ref={textareaRef}
className="spec-editor-textarea"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
disabled={isSaving}
placeholder="Enter task specification in Markdown..."
rows={20}
/>
) : content ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{stripLeadingHeading(content)}
</ReactMarkdown>
</div>
) : (
<div className="spec-editor-empty">(no specification)</div>
)}
</div>
{/* Keyboard hint for edit mode */}
{isEditing && (
<div className="spec-editor-hint">
Press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> (or <kbd>Cmd</kbd>+<kbd>Enter</kbd>) to save
</div>
)}
{/* AI Revision Section */}
{!readOnly && onRequestRevision && (
<div className="spec-editor-revision">
<h4>Ask AI to Revise</h4>
<p className="spec-editor-revision-help">
Provide feedback for the AI to improve this specification. The task will move to triage for re-specification.
</p>
<textarea
ref={feedbackRef}
className="spec-editor-feedback"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'..."
disabled={isRequesting}
rows={4}
maxLength={2000}
/>
<div className="spec-editor-revision-actions">
<span className="spec-editor-char-count">
{feedback.length}/2000
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleRequestRevision()}
disabled={!feedback.trim() || isRequesting}
>
{isRequesting ? "Requesting…" : "Request AI Revision"}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -3,12 +3,13 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core"; import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core"; import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail } from "../api"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs"; import { useAgentLogs } from "../hooks/useAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer"; import { AgentLogViewer } from "./AgentLogViewer";
import { SteeringTab } from "./SteeringTab"; import { SteeringTab } from "./SteeringTab";
import { PrSection } from "./PrSection"; import { PrSection } from "./PrSection";
import { SpecEditor } from "./SpecEditor";
function getStepStatusColor(status: string): string { function getStepStatusColor(status: string): string {
switch (status) { switch (status) {
@@ -74,12 +75,14 @@ export function TaskDetailModal({
addToast, addToast,
githubTokenConfigured, githubTokenConfigured,
}: TaskDetailModalProps) { }: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering">("definition"); const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "spec">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []); const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []); const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
const [showDepDropdown, setShowDepDropdown] = useState(false); const [showDepDropdown, setShowDepDropdown] = useState(false);
const [depSearch, setDepSearch] = useState(""); const [depSearch, setDepSearch] = useState("");
const [isSavingSpec, setIsSavingSpec] = useState(false);
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
useEffect(() => { useEffect(() => {
if (!showDepDropdown) setDepSearch(""); if (!showDepDropdown) setDepSearch("");
}, [showDepDropdown]); }, [showDepDropdown]);
@@ -267,6 +270,39 @@ export function TaskDetailModal({
} }
}, [onOpenDetail, addToast]); }, [onOpenDetail, addToast]);
const handleSaveSpec = useCallback(async (newContent: string) => {
setIsSavingSpec(true);
try {
await updateTask(task.id, { prompt: newContent });
addToast("Spec updated", "success");
// Update local task data
task.prompt = newContent;
} catch (err: any) {
addToast(err.message, "error");
throw err;
} finally {
setIsSavingSpec(false);
}
}, [task, addToast]);
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
setIsRequestingRevision(true);
try {
await requestSpecRevision(task.id, feedback);
addToast("AI revision requested. Task moved to triage.", "success");
// Task has been moved to triage, close modal
onClose();
} catch (err: any) {
if (err.message?.includes("in-review") || err.message?.includes("done")) {
addToast("Cannot request revision: Task must be in 'todo' or 'in-progress' column.", "error");
} else {
addToast(err.message, "error");
}
} finally {
setIsRequestingRevision(false);
}
}, [task.id, addToast, onClose]);
const availableTasks = tasks const availableTasks = tasks
.filter((t) => t.id !== task.id && !dependencies.includes(t.id)) .filter((t) => t.id !== task.id && !dependencies.includes(t.id))
.sort((a, b) => { .sort((a, b) => {
@@ -324,8 +360,24 @@ export function TaskDetailModal({
> >
Steering Steering
</button> </button>
<button
className={`detail-tab${activeTab === "spec" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("spec")}
>
Spec
</button>
</div> </div>
{activeTab === "agent-log" ? ( {activeTab === "spec" ? (
<div className="detail-section">
<SpecEditor
content={task.prompt || ""}
onSave={handleSaveSpec}
onRequestRevision={handleRequestSpecRevision}
isSaving={isSavingSpec}
isRequesting={isRequestingRevision}
/>
</div>
) : activeTab === "agent-log" ? (
<div className="detail-section"> <div className="detail-section">
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} /> <AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
</div> </div>
@@ -558,7 +610,7 @@ export function TaskDetailModal({
<PrSection <PrSection
taskId={task.id} taskId={task.id}
prInfo={task.prInfo} prInfo={task.prInfo}
hasGitHubToken={githubTokenConfigured} hasGitHubToken={githubTokenConfigured ?? false}
onPrCreated={(prInfo) => { onPrCreated={(prInfo) => {
// Update task locally to show new PR // Update task locally to show new PR
(task as TaskDetail).prInfo = prInfo; (task as TaskDetail).prInfo = prInfo;

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({}), updateTask: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(), fetchTaskDetail: vi.fn(),
fetchAgentLogs: vi.fn().mockResolvedValue([]), fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
})); }));
vi.mock("../../hooks/useAgentLogs", () => ({ vi.mock("../../hooks/useAgentLogs", () => ({
@@ -36,6 +37,7 @@ const noopMove = vi.fn(async () => ({}) as Task);
const noopDelete = vi.fn(async () => ({}) as Task); const noopDelete = vi.fn(async () => ({}) as Task);
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult); const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
const noopRetry = vi.fn(async () => ({}) as Task); const noopRetry = vi.fn(async () => ({}) as Task);
const noopOpenDetail = vi.fn();
describe("TaskDetailModal", () => { describe("TaskDetailModal", () => {
it("renders markdown-body without detail-prompt class when prompt exists", () => { it("renders markdown-body without detail-prompt class when prompt exists", () => {
@@ -46,6 +48,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -63,6 +66,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -80,6 +84,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -98,6 +103,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -113,6 +119,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry} onRetryTask={noopRetry}
addToast={noop} addToast={noop}
/>, />,
@@ -129,6 +136,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry} onRetryTask={noopRetry}
addToast={noop} addToast={noop}
/>, />,
@@ -145,6 +153,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -164,6 +173,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -191,6 +201,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -208,6 +219,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -221,6 +233,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -248,6 +261,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast} addToast={addToast}
/>, />,
); );
@@ -285,6 +299,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -323,6 +338,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -379,6 +395,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast} addToast={addToast}
/>, />,
); );
@@ -409,6 +426,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -424,6 +442,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -448,6 +467,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -476,6 +496,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -502,6 +523,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -532,6 +554,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -560,6 +583,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -581,6 +605,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -609,6 +634,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -638,6 +664,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -667,6 +694,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -694,6 +722,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -735,6 +764,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -767,6 +797,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -790,6 +821,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -812,6 +844,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -839,6 +872,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -855,6 +889,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -877,6 +912,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -900,6 +936,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -926,6 +963,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -952,6 +990,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -972,6 +1011,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -994,6 +1034,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1021,6 +1062,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1042,6 +1084,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1058,6 +1101,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1074,6 +1118,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1094,18 +1139,20 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
const tabs = container.querySelectorAll(".detail-tab"); 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 // 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")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active 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[1].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[2].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[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) // Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe(""); expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe(""); expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
@@ -1119,6 +1166,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1138,6 +1186,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1154,6 +1203,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1170,6 +1220,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1196,6 +1247,7 @@ describe("TaskDetailModal", () => {
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} addToast={noop}
/>, />,
); );
@@ -1278,11 +1330,11 @@ describe("TaskDetailModal", () => {
const { container } = render( const { container } = render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ dependencies: ["KB-001", "KB-002"] })} task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
onOpenDetail={noop}
onClose={noop} onClose={noop}
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop} 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();
});
});
}); });

View File

@@ -1451,3 +1451,210 @@ describe("POST /github/issues/import", () => {
}); });
}); });
}); });
// --- Spec Revision route tests ---
describe("POST /tasks/:id/spec/revise", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("requests spec revision and moves task from todo to triage", async () => {
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Please add more details about error handling" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"AI spec revision requested",
"Please add more details about error handling"
);
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "triage");
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "needs-respecify" });
});
it("requests spec revision and moves task from in-progress to triage", async () => {
const inProgressTask = { ...FAKE_TASK_DETAIL, column: "in-progress" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inProgressTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Split this into smaller steps" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "triage");
});
it("returns 400 when task is already in triage", async () => {
const triageTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(triageTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot request spec revision");
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 400 when task is in in-review", async () => {
const inReviewTask = { ...FAKE_TASK_DETAIL, column: "in-review" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(inReviewTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("in-review");
expect(res.body.error).toContain("Move task to 'todo' or 'in-progress' first");
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 400 when task is in done", async () => {
const doneTask = { ...FAKE_TASK_DETAIL, column: "done" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("done");
expect(store.moveTask).not.toHaveBeenCalled();
});
it("returns 400 when feedback is missing", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("feedback is required");
});
it("returns 400 when feedback is empty string", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("feedback is required");
});
it("returns 400 when feedback exceeds 2000 characters", async () => {
const longFeedback = "a".repeat(2001);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: longFeedback }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("feedback must be between 1 and 2000");
});
it("returns 404 when task not found", async () => {
const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT";
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-999/spec/revise",
JSON.stringify({ feedback: "Some feedback" }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(404);
});
it("queues multiple revision requests as multiple log entries", async () => {
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
// First request
await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "First feedback" }),
{ "Content-Type": "application/json" }
);
// Second request
await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/spec/revise",
JSON.stringify({ feedback: "Second feedback" }),
{ "Content-Type": "application/json" }
);
expect(store.logEntry).toHaveBeenCalledTimes(2);
expect(store.logEntry).toHaveBeenNthCalledWith(1, "KB-001", "AI spec revision requested", "First feedback");
expect(store.logEntry).toHaveBeenNthCalledWith(2, "KB-001", "AI spec revision requested", "Second feedback");
});
});

View File

@@ -3,7 +3,7 @@ import multer from "multer";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult } from "@kb/core"; import type { TaskStore, Column, MergeResult } from "@kb/core";
import { COLUMNS, type PrInfo } from "@kb/core"; import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
import type { ServerOptions } from "./server.js"; import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo } from "./github.js"; import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
@@ -407,6 +407,50 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
// Request AI revision of task spec
router.post("/tasks/:id/spec/revise", async (req, res) => {
try {
const { feedback } = req.body;
if (!feedback || typeof feedback !== "string") {
res.status(400).json({ error: "feedback is required and must be a string" });
return;
}
if (feedback.length === 0 || feedback.length > 2000) {
res.status(400).json({ error: "feedback must be between 1 and 2000 characters" });
return;
}
// Get current task state
const task = await store.getTask(req.params.id);
// Check if task can transition to triage
const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage");
if (!canTransition) {
res.status(400).json({
error: `Cannot request spec revision for tasks in '${task.column}' column. ` +
`Move task to 'todo' or 'in-progress' first.`,
});
return;
}
// Log the revision request
await store.logEntry(task.id, "AI spec revision requested", feedback);
// Move to triage for re-specification (only valid for todo/in-progress)
const updated = await store.moveTask(task.id, "triage");
// Update status to indicate needs re-specification
await store.updateTask(task.id, { status: "needs-respecify" });
res.json(updated);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404
: err.message?.includes("Invalid transition") ? 400
: 500;
res.status(status).json({ error: err.message });
}
});
// Update task // Update task
router.patch("/tasks/:id", async (req, res) => { router.patch("/tasks/:id", async (req, res) => {
try { try {

File diff suppressed because it is too large Load Diff

View File

@@ -461,11 +461,33 @@ export class TriageProcessor {
detail.attachments, detail.attachments,
); );
// Check if this is a re-specification request
const isRespecify = task.status === "needs-respecify";
let existingPrompt: string | undefined;
let feedback: string | undefined;
if (isRespecify) {
// Get the existing prompt content
existingPrompt = detail.prompt;
// Extract feedback from the most recent "AI spec revision requested" log entry
const revisionLogEntry = [...task.log]
.reverse()
.find((entry) => entry.action === "AI spec revision requested");
feedback = revisionLogEntry?.outcome;
triageLog.log(
`${task.id} re-specifying with feedback: ${feedback?.slice(0, 100)}...`,
);
}
const agentPrompt = buildSpecificationPrompt( const agentPrompt = buildSpecificationPrompt(
detail, detail,
promptPath, promptPath,
settings, settings,
attachmentContents, attachmentContents,
existingPrompt,
feedback,
); );
await session.prompt( await session.prompt(
agentPrompt, agentPrompt,
@@ -491,7 +513,11 @@ export class TriageProcessor {
task.id, task.id,
`Spec review not approved (${verdictDesc}) — specification not approved`, `Spec review not approved (${verdictDesc}) — specification not approved`,
); );
await this.store.updateTask(task.id, { status: null }); // For re-specification, keep the needs-respecify status so it can be retried
// For new specs, clear the status
await this.store.updateTask(task.id, {
status: isRespecify ? "needs-respecify" : null,
});
return; return;
} }
@@ -540,7 +566,15 @@ export class TriageProcessor {
await this.store.updateTask(task.id, taskUpdates); await this.store.updateTask(task.id, taskUpdates);
await this.store.moveTask(task.id, "todo"); await this.store.moveTask(task.id, "todo");
triageLog.log(`${task.id} specified and moved to todo`);
// Log completion for re-specification
if (isRespecify) {
await this.store.logEntry(task.id, "Spec revised by AI", feedback);
triageLog.log(`${task.id} re-specified and moved to todo`);
} else {
triageLog.log(`${task.id} specified and moved to todo`);
}
this.options.onSpecifyComplete?.(task); this.options.onSpecifyComplete?.(task);
} }
} finally { } finally {
@@ -564,7 +598,9 @@ export class TriageProcessor {
// Pause (global or engine) — clear specifying status without reporting an error // Pause (global or engine) — clear specifying status without reporting an error
this.pauseAborted.delete(task.id); this.pauseAborted.delete(task.id);
triageLog.log(`${task.id} aborted by pause — clearing status`); triageLog.log(`${task.id} aborted by pause — clearing status`);
await this.store.updateTask(task.id, { status: null }).catch(() => {}); // For re-specification, restore needs-respecify status
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : undefined;
await this.store.updateTask(task.id, { status: restoreStatus }).catch(() => {});
} else { } else {
// Check if the error is a usage-limit error and trigger global pause // Check if the error is a usage-limit error and trigger global pause
if (this.options.usageLimitPauser && isUsageLimitError(err.message)) { if (this.options.usageLimitPauser && isUsageLimitError(err.message)) {
@@ -574,7 +610,9 @@ export class TriageProcessor {
err.message, err.message,
); );
} }
await this.store.updateTask(task.id, { status: null }).catch(() => {}); // For re-specification, restore needs-respecify status so it can be retried
const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : undefined;
await this.store.updateTask(task.id, { status: restoreStatus }).catch(() => {});
triageLog.error(`${task.id} specification failed:`, err.message); triageLog.error(`${task.id} specification failed:`, err.message);
this.options.onSpecifyError?.(task, err); this.options.onSpecifyError?.(task, err);
} }
@@ -918,7 +956,11 @@ export function buildSpecificationPrompt(
promptPath: string, promptPath: string,
settings?: Settings, settings?: Settings,
attachmentContents?: AttachmentContent[], attachmentContents?: AttachmentContent[],
existingPrompt?: string,
feedback?: string,
): string { ): string {
const isRevision = existingPrompt && feedback;
let commandsSection = ""; let commandsSection = "";
if (settings?.testCommand || settings?.buildCommand) { if (settings?.testCommand || settings?.buildCommand) {
const lines = ["## Project Commands"]; const lines = ["## Project Commands"];
@@ -948,19 +990,36 @@ export function buildSpecificationPrompt(
attachmentsSection = "\n\n" + parts.join("\n"); attachmentsSection = "\n\n" + parts.join("\n");
} }
return `Specify this task and write the result to \`${promptPath}\`. let revisionSection = "";
if (isRevision) {
revisionSection = `
## Revision Instructions
You are revising an existing task specification based on user feedback.
**Important:** Keep the same overall PROMPT.md structure (headings, sections, format) but improve the content to address the feedback below. Do not drastically change the file structure unless necessary.
## Existing Specification
\`\`\`markdown
${existingPrompt}
\`\`\`
## User Feedback
${feedback}
Please revise the specification above to address this feedback. Write the complete revised PROMPT.md to \`${promptPath}\`.`;
}
return `${isRevision ? "Revise" : "Specify"} this task and write the result to \`${promptPath}\`.
## Task ## Task
- **ID:** ${task.id} - **ID:** ${task.id}
- **Title:** ${task.title || "(none)"} - **Title:** ${task.title || "(none)"}
- **Description:** ${task.description} - **Description:** ${task.description}
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""} ${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}
## Instructions ## Instructions
1. Read the project structure to understand context (package.json, source files, etc.) ${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
2. Write a complete PROMPT.md specification to \`${promptPath}\` following the format in your system prompt
3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions
4. Name actual files, functions, and patterns from the codebase — be specific
Use the write tool to write the specification file.${commandsSection}${attachmentsSection}`; Use the write tool to write the specification file.${commandsSection}${attachmentsSection}`;
} }