test(KB-601): add test for pause/unpause with missing directory
This commit is contained in:
74
packages/dashboard/app/components/MergeDetails.tsx
Normal file
74
packages/dashboard/app/components/MergeDetails.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
interface MergeDetailsProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
function shortSha(sha?: string): string {
|
||||
if (!sha) return "Unknown";
|
||||
return sha.slice(0, 7);
|
||||
}
|
||||
|
||||
export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
if (task.column !== "done" || !task.mergeDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = task.mergeDetails;
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Merge Details</h4>
|
||||
<div className="pr-card" style={{ border: "1px solid var(--border, #333)", borderRadius: 8, padding: 12 }}>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Status</span>
|
||||
<span className="detail-log-outcome">{details.mergeConfirmed === false ? "Recorded without local merge confirmation" : "Merged successfully"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Commit</span>
|
||||
<span className="detail-log-outcome">{shortSha(details.commitSha)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Files changed</span>
|
||||
<span className="detail-log-outcome">{details.filesChanged ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Insertions / Deletions</span>
|
||||
<span className="detail-log-outcome">+{details.insertions ?? 0} / -{details.deletions ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
{details.mergedAt ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Merged at</span>
|
||||
<span className="detail-log-outcome">{new Date(details.mergedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{details.prNumber ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">PR</span>
|
||||
<span className="detail-log-outcome">#{details.prNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{details.mergeCommitMessage ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Message</span>
|
||||
</div>
|
||||
<div className="detail-log-outcome">{details.mergeCommitMessage}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
packages/dashboard/app/components/TaskComments.tsx
Normal file
166
packages/dashboard/app/components/TaskComments.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@kb/core";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface TaskCommentsProps {
|
||||
task: Task;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
currentAuthor?: string;
|
||||
}
|
||||
|
||||
function formatCommentTimestamp(comment: TaskComment): string {
|
||||
const timestamp = comment.updatedAt || comment.createdAt;
|
||||
const label = new Date(timestamp).toLocaleString();
|
||||
return comment.updatedAt ? `${label} (edited)` : label;
|
||||
}
|
||||
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const comments = useMemo(() => task.comments || [], [task.comments]);
|
||||
|
||||
async function handleAddComment() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
} catch (error: any) {
|
||||
addToast(error.message || "Failed to add comment", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit(commentId: string) {
|
||||
const text = editingText.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await updateTaskComment(task.id, commentId, text);
|
||||
setEditingId(null);
|
||||
setEditingText("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment updated", "success");
|
||||
} catch (error: any) {
|
||||
addToast(error.message || "Failed to update comment", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(commentId: string) {
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
const updated = await deleteTaskComment(task.id, commentId);
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment deleted", "success");
|
||||
} catch (error: any) {
|
||||
addToast(error.message || "Failed to delete comment", "error");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Comments</h4>
|
||||
{comments.length === 0 ? (
|
||||
<div className="detail-log-empty">No comments yet.</div>
|
||||
) : (
|
||||
<div className="detail-activity-list">
|
||||
{comments.map((comment) => {
|
||||
const canEdit = comment.author === currentAuthor;
|
||||
const isEditing = editingId === comment.id;
|
||||
return (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
|
||||
<div>
|
||||
<strong>{comment.author}</strong>
|
||||
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
|
||||
{formatCommentTimestamp(comment)}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && !isEditing ? (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
setEditingId(comment.id);
|
||||
setEditingText(comment.text);
|
||||
}}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => void handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
>
|
||||
{deletingId === comment.id ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
|
||||
<textarea
|
||||
value={editingText}
|
||||
onChange={(event) => setEditingText(event.target.value)}
|
||||
rows={3}
|
||||
className="spec-editor-feedback"
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditingText("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleSaveEdit(comment.id)}
|
||||
disabled={submitting || !editingText.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-log-outcome" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add a comment"
|
||||
className="spec-editor-feedback"
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
|
||||
{submitting ? "Posting…" : "Add Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { SteeringTab } from "./SteeringTab";
|
||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -103,7 +105,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -675,6 +677,12 @@ export function TaskDetailModal({
|
||||
>
|
||||
Steering
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("comments")}
|
||||
>
|
||||
Comments
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "model" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("model")}
|
||||
@@ -697,6 +705,8 @@ export function TaskDetailModal({
|
||||
</div>
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : activeTab === "comments" ? (
|
||||
<TaskComments task={task} addToast={addToast} />
|
||||
) : activeTab === "activity" ? (
|
||||
<div className="detail-section detail-activity">
|
||||
<h4>Activity</h4>
|
||||
@@ -733,6 +743,7 @@ export function TaskDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MergeDetails task={task} />
|
||||
<div className="detail-section detail-step-progress">
|
||||
<h4>Progress</h4>
|
||||
{task.steps && task.steps.length > 0 ? (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MergeDetails } from "../MergeDetails";
|
||||
|
||||
const makeTask = (overrides: any = {}) => ({
|
||||
id: "KB-001",
|
||||
description: "Task",
|
||||
column: "done",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("MergeDetails", () => {
|
||||
it("renders nothing when task is not done", () => {
|
||||
const { container } = render(<MergeDetails task={makeTask({ column: "in-review", mergeDetails: { commitSha: "abc1234" } })} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders merge metadata for done task", () => {
|
||||
render(
|
||||
<MergeDetails
|
||||
task={makeTask({
|
||||
mergeDetails: {
|
||||
commitSha: "abcdef123456",
|
||||
filesChanged: 5,
|
||||
insertions: 10,
|
||||
deletions: 2,
|
||||
mergedAt: "2026-01-01T01:00:00.000Z",
|
||||
prNumber: 42,
|
||||
mergeCommitMessage: "feat(KB-001): merge kb/kb-001",
|
||||
mergeConfirmed: true,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Merge Details")).toBeTruthy();
|
||||
expect(screen.getByText("abcdef1")).toBeTruthy();
|
||||
expect(screen.getByText("5")).toBeTruthy();
|
||||
expect(screen.getByText("+10 / -2")).toBeTruthy();
|
||||
expect(screen.getByText("#42")).toBeTruthy();
|
||||
expect(screen.getByText("feat(KB-001): merge kb/kb-001")).toBeTruthy();
|
||||
expect(screen.getByText("Merged successfully")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskComments } from "../TaskComments";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../../api";
|
||||
|
||||
const makeTask = (overrides: any = {}) => ({
|
||||
id: "KB-001",
|
||||
description: "Task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("TaskComments", () => {
|
||||
it("renders empty state", () => {
|
||||
render(<TaskComments task={makeTask()} addToast={vi.fn()} />);
|
||||
expect(screen.getByText("No comments yet.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a comment", async () => {
|
||||
const onTaskUpdated = vi.fn();
|
||||
vi.mocked(addTaskComment).mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] }));
|
||||
|
||||
render(<TaskComments task={makeTask()} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
||||
fireEvent.change(screen.getByPlaceholderText("Add a comment"), { target: { value: "Hello" } });
|
||||
fireEvent.click(screen.getByText("Add Comment"));
|
||||
|
||||
await waitFor(() => expect(addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user"));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits own comment", async () => {
|
||||
const onTaskUpdated = vi.fn();
|
||||
vi.mocked(updateTaskComment).mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] }));
|
||||
|
||||
render(<TaskComments task={makeTask({ comments: [{ id: "c1", text: "Original", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] })} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
fireEvent.change(screen.getByDisplayValue("Original"), { target: { value: "Updated" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated"));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes own comment", async () => {
|
||||
const onTaskUpdated = vi.fn();
|
||||
vi.mocked(deleteTaskComment).mockResolvedValue(makeTask({ comments: [] }));
|
||||
|
||||
render(<TaskComments task={makeTask({ comments: [{ id: "c1", text: "Original", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] })} addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
||||
fireEvent.click(screen.getByText("Delete"));
|
||||
|
||||
await waitFor(() => expect(deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1"));
|
||||
expect(onTaskUpdated).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,22 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("PROMPT.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Comments tab", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Comments")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Retry button when task status is 'failed'", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
@@ -1149,7 +1165,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(5); // Definition, Activity, Agent Log, Steering, Model (Spec combined into Definition)
|
||||
expect(tabs.length).toBe(6); // Definition, Activity, Agent Log, Steering, Comments, Model
|
||||
// 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
|
||||
@@ -1157,6 +1173,7 @@ describe("TaskDetailModal", () => {
|
||||
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);
|
||||
expect(tabs[5].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("");
|
||||
@@ -1634,7 +1651,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows all 5 tabs in correct order (Spec tab removed)", () => {
|
||||
it("shows all 6 tabs in correct order with comments", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
@@ -1648,12 +1665,13 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(5);
|
||||
expect(tabs.length).toBe(6);
|
||||
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("Model");
|
||||
expect(tabs[4].textContent).toBe("Comments");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
});
|
||||
|
||||
it("shows empty state and Edit button when no prompt", () => {
|
||||
|
||||
Reference in New Issue
Block a user