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:
@@ -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 ---
|
||||
|
||||
/** Available AI model info returned by the models endpoint */
|
||||
|
||||
184
packages/dashboard/app/components/SpecEditor.tsx
Normal file
184
packages/dashboard/app/components/SpecEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } 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 { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { SteeringTab } from "./SteeringTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { SpecEditor } from "./SpecEditor";
|
||||
|
||||
function getStepStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
@@ -74,12 +75,14 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: 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 [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) setDepSearch("");
|
||||
}, [showDepDropdown]);
|
||||
@@ -267,6 +270,39 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [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
|
||||
.filter((t) => t.id !== task.id && !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
@@ -324,8 +360,24 @@ export function TaskDetailModal({
|
||||
>
|
||||
Steering
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "spec" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("spec")}
|
||||
>
|
||||
Spec
|
||||
</button>
|
||||
</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">
|
||||
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
||||
</div>
|
||||
@@ -558,7 +610,7 @@ export function TaskDetailModal({
|
||||
<PrSection
|
||||
taskId={task.id}
|
||||
prInfo={task.prInfo}
|
||||
hasGitHubToken={githubTokenConfigured}
|
||||
hasGitHubToken={githubTokenConfigured ?? false}
|
||||
onPrCreated={(prInfo) => {
|
||||
// Update task locally to show new PR
|
||||
(task as TaskDetail).prInfo = prInfo;
|
||||
|
||||
455
packages/dashboard/app/components/__tests__/SpecEditor.test.tsx
Normal file
455
packages/dashboard/app/components/__tests__/SpecEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
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 { 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
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user