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:
5
.changeset/add-spec-edit-revision-dashboard.md
Normal file
5
.changeset/add-spec-edit-revision-dashboard.md
Normal 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.
|
||||||
20
README.md
20
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
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({}),
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user