test(KB-601): add test for pause/unpause with missing directory
This commit is contained in:
@@ -39,7 +39,7 @@ if (isBunBinary) {
|
||||
|
||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
|
||||
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
|
||||
|
||||
@@ -69,6 +69,8 @@ Usage:
|
||||
fn task attach <id> <file> Attach a file to a task
|
||||
fn task pause <id> Pause a task (stops all automation)
|
||||
fn task unpause <id> Unpause a task (resumes automation)
|
||||
fn task comment <id> [message] Add task comment (prompts if message omitted)
|
||||
fn task comments <id> List task comments
|
||||
fn task steer <id> [message] Add steering comment (prompts if message omitted)
|
||||
fn task retry <id> Retry a failed task (clears error, moves to todo)
|
||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||
@@ -296,6 +298,25 @@ async function main() {
|
||||
await runTaskUnpause(id);
|
||||
break;
|
||||
}
|
||||
case "comment": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task comment <id> [message] [--author <name>]"); process.exit(1); }
|
||||
const authorIdx = args.indexOf("--author");
|
||||
const author = authorIdx !== -1 && authorIdx + 1 < args.length ? args[authorIdx + 1] : undefined;
|
||||
const messageParts = args.slice(3).filter((arg, index, arr) => {
|
||||
const absoluteIndex = index + 3;
|
||||
return absoluteIndex !== authorIdx && absoluteIndex !== authorIdx + 1;
|
||||
});
|
||||
const message = messageParts.join(" ");
|
||||
await runTaskComment(id, message || undefined, author || process.env.USER || "user");
|
||||
break;
|
||||
}
|
||||
case "comments": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task comments <id>"); process.exit(1); }
|
||||
await runTaskComments(id);
|
||||
break;
|
||||
}
|
||||
case "steer": {
|
||||
const id = args[2];
|
||||
const message = args.slice(3).join(" ");
|
||||
|
||||
@@ -52,7 +52,7 @@ vi.mock("@fusion/core/gh-cli", () => ({
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskPrCreate, type LogsOptions } from "./task.js";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, type LogsOptions } from "./task.js";
|
||||
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
|
||||
@@ -1204,6 +1204,44 @@ describe("runTaskDelete", () => {
|
||||
|
||||
// --- Retry Tests ---
|
||||
|
||||
describe("runTaskComment", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("adds a task comment with explicit author", async () => {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
addTaskComment: vi.fn().mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "alice", createdAt: new Date().toISOString() }] })),
|
||||
}));
|
||||
|
||||
await runTaskComment("KB-001", "Hello", "alice");
|
||||
|
||||
const store = (TaskStore as unknown as ReturnType<typeof vi.fn>).mock.results.at(-1)?.value;
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "alice");
|
||||
expect(logSpy).toHaveBeenCalledWith(" ✓ Comment added to KB-001");
|
||||
});
|
||||
|
||||
it("lists task comments", async () => {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getTask: vi.fn().mockResolvedValue(makeTask({ comments: [{ id: "c1", text: "Hello", author: "alice", createdAt: "2026-01-01T00:00:00.000Z" }] })),
|
||||
}));
|
||||
|
||||
await runTaskComments("KB-001");
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(" Comments for KB-001:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTaskRetry", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockGetTask: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -895,6 +895,58 @@ export async function runTaskImportFromGitHub(
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskComment(id: string, message?: string, author = "user") {
|
||||
const store = await getStore();
|
||||
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
text = await rl.question("Comment: ");
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
console.error("Error: Comment is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length > 2000) {
|
||||
console.error("Error: Comment must be between 1 and 2000 characters");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = await store.addTaskComment(id, trimmed, author || "user");
|
||||
const latestComment = task.comments?.[task.comments.length - 1];
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Comment added to ${task.id}`);
|
||||
if (latestComment) {
|
||||
console.log(` ID: ${latestComment.id}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskComments(id: string) {
|
||||
const store = await getStore();
|
||||
const task = await store.getTask(id);
|
||||
const comments = task.comments || [];
|
||||
|
||||
console.log();
|
||||
if (comments.length === 0) {
|
||||
console.log(` No comments on ${id}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Comments for ${id}:`);
|
||||
for (const comment of comments) {
|
||||
console.log(` ${comment.id} · ${comment.author} · ${new Date(comment.updatedAt || comment.createdAt).toLocaleString()}`);
|
||||
console.log(` ${comment.text}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskSteer(id: string, message?: string) {
|
||||
const store = await getStore();
|
||||
|
||||
|
||||
@@ -166,11 +166,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
|
||||
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
|
||||
enabledWorkflowSteps
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -214,9 +214,11 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
|
||||
toJson(task.log || []),
|
||||
toJson(task.attachments || []),
|
||||
toJson(task.steeringComments || []),
|
||||
toJson(task.comments || []),
|
||||
toJson(task.workflowStepResults || []),
|
||||
toJsonNullable(task.prInfo),
|
||||
toJsonNullable(task.issueInfo),
|
||||
toJsonNullable(task.mergeDetails),
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
);
|
||||
|
||||
@@ -92,9 +92,11 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
log TEXT DEFAULT '[]',
|
||||
attachments TEXT DEFAULT '[]',
|
||||
steeringComments TEXT DEFAULT '[]',
|
||||
comments TEXT DEFAULT '[]',
|
||||
workflowStepResults TEXT DEFAULT '[]',
|
||||
prInfo TEXT,
|
||||
issueInfo TEXT,
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
|
||||
@@ -1017,6 +1017,72 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task comments", () => {
|
||||
it("adds a task comment to a task", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.addTaskComment(task.id, "Please review this", "alice");
|
||||
|
||||
expect(updated.comments).toHaveLength(1);
|
||||
expect(updated.comments![0].text).toBe("Please review this");
|
||||
expect(updated.comments![0].author).toBe("alice");
|
||||
expect(updated.comments![0].id).toBeDefined();
|
||||
expect(updated.comments![0].createdAt).toBeDefined();
|
||||
expect(updated.comments![0].updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates an existing task comment", async () => {
|
||||
const task = await createTestTask();
|
||||
const added = await store.addTaskComment(task.id, "First draft", "alice");
|
||||
const commentId = added.comments![0].id;
|
||||
|
||||
const updated = await store.updateTaskComment(task.id, commentId, "Updated draft");
|
||||
|
||||
expect(updated.comments).toHaveLength(1);
|
||||
expect(updated.comments![0].text).toBe("Updated draft");
|
||||
expect(updated.comments![0].updatedAt).toBeDefined();
|
||||
expect(updated.log.some((entry) => entry.action === "Comment updated")).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes a task comment", async () => {
|
||||
const task = await createTestTask();
|
||||
const added = await store.addTaskComment(task.id, "Disposable", "alice");
|
||||
const commentId = added.comments![0].id;
|
||||
|
||||
const updated = await store.deleteTaskComment(task.id, commentId);
|
||||
|
||||
expect(updated.comments).toBeUndefined();
|
||||
expect(updated.log.some((entry) => entry.action === "Comment deleted")).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when updating a missing task comment", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await expect(store.updateTaskComment(task.id, "missing", "Nope")).rejects.toThrow(
|
||||
`Comment missing not found on task ${task.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when deleting a missing task comment", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await expect(store.deleteTaskComment(task.id, "missing")).rejects.toThrow(
|
||||
`Comment missing not found on task ${task.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists task comments independently from steering comments", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.addTaskComment(task.id, "General note", "alice");
|
||||
await store.addSteeringComment(task.id, "Execution note");
|
||||
|
||||
const reopened = await store.getTask(task.id);
|
||||
expect(reopened.comments).toHaveLength(1);
|
||||
expect(reopened.comments![0].text).toBe("General note");
|
||||
expect(reopened.steeringComments).toHaveLength(1);
|
||||
expect(reopened.steeringComments![0].text).toBe("Execution note");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSteeringComment", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const task = await createTestTask();
|
||||
@@ -1226,6 +1292,43 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task comments and merge details types", () => {
|
||||
it("keeps task comments distinct from steering comments on new tasks", async () => {
|
||||
const task = await createTestTask();
|
||||
const reopened = await store.getTask(task.id);
|
||||
|
||||
expect(reopened.comments).toBeUndefined();
|
||||
expect(reopened.steeringComments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports the task comment and merge details shapes", async () => {
|
||||
const comment: NonNullable<Task["comments"]>[number] = {
|
||||
id: `comment-${Date.now()}`,
|
||||
text: "Looks good",
|
||||
author: "alice",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const mergeDetails: NonNullable<Task["mergeDetails"]> = {
|
||||
commitSha: "abc123def456",
|
||||
filesChanged: 3,
|
||||
insertions: 10,
|
||||
deletions: 2,
|
||||
mergeCommitMessage: "feat(KB-001): merge kb/kb-001",
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: 42,
|
||||
};
|
||||
const taskShape: Pick<Task, "comments" | "mergeDetails"> = {
|
||||
comments: [comment],
|
||||
mergeDetails,
|
||||
};
|
||||
|
||||
expect(taskShape.comments).toEqual([comment]);
|
||||
expect(taskShape.mergeDetails).toEqual(mergeDetails);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePrInfo", () => {
|
||||
it("adds PR info to a task without existing PR", async () => {
|
||||
const task = await createTestTask();
|
||||
@@ -3169,6 +3272,38 @@ describe("TaskStore", () => {
|
||||
expect(logs[0].type).toBe("task:deleted");
|
||||
});
|
||||
|
||||
it("captures merge details when merging a task", async () => {
|
||||
const task = await store.createTask({ description: "Test merge details" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.updateTask(task.id, {
|
||||
worktree: "/tmp/test-worktree",
|
||||
});
|
||||
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`git checkout -b kb/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
|
||||
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await store.mergeTask(task.id);
|
||||
expect(result.mergeConfirmed ?? result.merged).toBeDefined();
|
||||
expect(result.task.mergeDetails).toBeDefined();
|
||||
if (result.merged) {
|
||||
expect(result.task.mergeDetails?.commitSha).toBeTruthy();
|
||||
expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id);
|
||||
expect(result.task.mergeDetails?.mergedAt).toBeDefined();
|
||||
}
|
||||
} catch {
|
||||
// merge may fail depending on repo state; skip strict assertions in that case
|
||||
}
|
||||
});
|
||||
|
||||
it("records activity on task:merged", async () => {
|
||||
const task = await store.createTask({ description: "Test merged event" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
@@ -141,9 +141,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
|
||||
attachments: (() => { const a = fromJson<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
|
||||
steeringComments: (() => { const s = fromJson<import("./types.js").SteeringComment[]>(row.steeringComments); return s && s.length > 0 ? s : undefined; })(),
|
||||
comments: (() => { const c = fromJson<import("./types.js").TaskComment[]>(row.comments); return c && c.length > 0 ? c : undefined; })(),
|
||||
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
|
||||
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
|
||||
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
|
||||
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
|
||||
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
|
||||
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
|
||||
};
|
||||
@@ -160,11 +162,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
|
||||
enabledWorkflowSteps
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -196,9 +198,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJson(task.log || []),
|
||||
toJson(task.attachments || []),
|
||||
toJson(task.steeringComments || []),
|
||||
toJson(task.comments || []),
|
||||
toJson(task.workflowStepResults || []),
|
||||
toJsonNullable(task.prInfo),
|
||||
toJsonNullable(task.issueInfo),
|
||||
toJsonNullable(task.mergeDetails),
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
);
|
||||
@@ -1162,6 +1166,58 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
private collectMergeDetails(id: string, branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
|
||||
const mergedAt = new Date().toISOString();
|
||||
let commitSha: string | undefined;
|
||||
let filesChanged: number | undefined;
|
||||
let insertions: number | undefined;
|
||||
let deletions: number | undefined;
|
||||
|
||||
try {
|
||||
commitSha = execSync("git rev-parse HEAD", {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim() || undefined;
|
||||
} catch {
|
||||
commitSha = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const statsOutput = execSync("git show --shortstat --format= HEAD", {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const normalized = statsOutput.replace(/\n/g, " ");
|
||||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||||
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
|
||||
filesChanged = filesMatch ? Number.parseInt(filesMatch[1], 10) : 0;
|
||||
insertions = insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0;
|
||||
deletions = deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0;
|
||||
} catch {
|
||||
filesChanged = undefined;
|
||||
insertions = undefined;
|
||||
deletions = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha,
|
||||
filesChanged,
|
||||
insertions,
|
||||
deletions,
|
||||
mergeCommitMessage: commitMessage,
|
||||
mergedAt,
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
|
||||
resolutionMethod: task.mergeDetails?.resolutionMethod,
|
||||
attemptsMade: task.mergeDetails?.attemptsMade,
|
||||
autoResolvedCount: task.mergeDetails?.autoResolvedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an in-review task's branch into the current branch,
|
||||
* clean up the worktree, and move the task to done.
|
||||
@@ -1196,6 +1252,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} catch {
|
||||
// No branch — might have been manually merged. Just move to done.
|
||||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||||
task.mergeDetails = {
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: false,
|
||||
prNumber: task.prInfo?.number,
|
||||
};
|
||||
await this.moveToDone(task, dir);
|
||||
result.task = { ...task, column: "done" };
|
||||
this.emit("task:merged", result);
|
||||
@@ -1203,16 +1264,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
// 2. Merge the branch
|
||||
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
|
||||
try {
|
||||
execSync(`git merge --squash "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git commit --no-edit -m "feat(${id}): merge ${branch}"`, {
|
||||
execSync(`git commit --no-edit -m "${mergeCommitMessage}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
result.merged = true;
|
||||
const mergeDetails = this.collectMergeDetails(id, branch, task, mergeCommitMessage);
|
||||
task.mergeDetails = mergeDetails;
|
||||
Object.assign(result, mergeDetails);
|
||||
} catch (err: any) {
|
||||
// Squash conflict — reset and report
|
||||
try {
|
||||
@@ -1806,6 +1871,95 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.emit("agent:log", entry);
|
||||
}
|
||||
|
||||
async addTaskComment(id: string, text: string, author: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
|
||||
if (!task.log) {
|
||||
task.log = [];
|
||||
}
|
||||
|
||||
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const comment: import("./types.js").TaskComment = {
|
||||
id: commentId,
|
||||
text,
|
||||
author,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (!task.comments) {
|
||||
task.comments = [];
|
||||
}
|
||||
task.comments.push(comment);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: `Comment added by ${author}`,
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
async updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const comments = task.comments || [];
|
||||
const comment = comments.find((entry) => entry.id === commentId);
|
||||
|
||||
if (!comment) {
|
||||
throw new Error(`Comment ${commentId} not found on task ${id}`);
|
||||
}
|
||||
|
||||
comment.text = text;
|
||||
comment.updatedAt = new Date().toISOString();
|
||||
task.comments = comments;
|
||||
task.updatedAt = comment.updatedAt;
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: "Comment updated",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTaskComment(id: string, commentId: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const currentComments = task.comments || [];
|
||||
const nextComments = currentComments.filter((entry) => entry.id !== commentId);
|
||||
|
||||
if (nextComments.length === currentComments.length) {
|
||||
throw new Error(`Comment ${commentId} not found on task ${id}`);
|
||||
}
|
||||
|
||||
task.comments = nextComments.length > 0 ? nextComments : undefined;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: "Comment deleted",
|
||||
});
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a steering comment to a task.
|
||||
* Steering comments are user-provided feedback injected into the AI execution context.
|
||||
|
||||
@@ -333,6 +333,34 @@ export interface SteeringComment {
|
||||
author: "user" | "agent";
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
text: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskCommentInput {
|
||||
text: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface MergeDetails {
|
||||
commitSha?: string;
|
||||
filesChanged?: number;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
mergeCommitMessage?: string;
|
||||
mergedAt?: string;
|
||||
mergeConfirmed?: boolean;
|
||||
prNumber?: number;
|
||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
|
||||
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
|
||||
attemptsMade?: 1 | 2 | 3;
|
||||
autoResolvedCount?: number;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -360,8 +388,10 @@ export interface Task {
|
||||
baseBranch?: string;
|
||||
attachments?: TaskAttachment[];
|
||||
steeringComments?: SteeringComment[];
|
||||
comments?: TaskComment[];
|
||||
/** PR information for tasks linked to GitHub pull requests */
|
||||
prInfo?: PrInfo;
|
||||
mergeDetails?: MergeDetails;
|
||||
/** Issue information for tasks imported from GitHub issues */
|
||||
issueInfo?: IssueInfo;
|
||||
log: TaskLogEntry[];
|
||||
@@ -723,21 +753,13 @@ export interface BoardConfig {
|
||||
nextWorkflowStepId?: number;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
export interface MergeResult extends MergeDetails {
|
||||
task: Task;
|
||||
branch: string;
|
||||
merged: boolean;
|
||||
worktreeRemoved: boolean;
|
||||
branchDeleted: boolean;
|
||||
error?: string;
|
||||
/** Strategy that successfully resolved the merge, if any */
|
||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs";
|
||||
/** Alias for resolutionStrategy — how conflicts were resolved (for metrics/debugging) */
|
||||
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs";
|
||||
/** Number of retry attempts made (1 = first attempt succeeded, 2-3 = retries needed) */
|
||||
attemptsMade?: 1 | 2 | 3;
|
||||
/** Number of files auto-resolved (for tracking mixed resolution scenarios) */
|
||||
autoResolvedCount?: number;
|
||||
}
|
||||
|
||||
export const COLUMN_LABELS: Record<Column, string> = {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
logoutProvider,
|
||||
fetchModels,
|
||||
addSteeringComment,
|
||||
addTaskComment,
|
||||
updateTaskComment,
|
||||
deleteTaskComment,
|
||||
fetchTaskComments,
|
||||
fetchGitRemotes,
|
||||
refineTask,
|
||||
fetchBatchStatus,
|
||||
@@ -134,6 +138,75 @@ describe("updateTask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task comments api", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "KB-001",
|
||||
description: "Test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
};
|
||||
|
||||
it("fetches task comments", async () => {
|
||||
const comments = FAKE_TASK.comments!;
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, comments));
|
||||
|
||||
const result = await fetchTaskComments("KB-001");
|
||||
|
||||
expect(result).toEqual(comments);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a task comment", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addTaskComment("KB-001", "Hello", "user");
|
||||
|
||||
expect(result).toEqual(FAKE_TASK);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Hello", author: "user" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a task comment", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
await updateTaskComment("KB-001", "c1", "Updated");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text: "Updated" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a task comment", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
await deleteTaskComment("KB-001", "c1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchModels", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Task,
|
||||
TaskDetail,
|
||||
TaskAttachment,
|
||||
TaskComment,
|
||||
TaskCreateInput,
|
||||
AgentLogEntry,
|
||||
Column,
|
||||
@@ -254,6 +255,30 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
}
|
||||
|
||||
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
}
|
||||
|
||||
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, author }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTaskComment(id: string, commentId: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTaskComment(id: string, commentId: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments/${commentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -52,6 +52,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
@@ -1407,6 +1410,63 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(res.body.error).toBe("not found");
|
||||
});
|
||||
|
||||
describe("task comment routes", () => {
|
||||
it("GET /tasks/:id/comments — returns task comments", async () => {
|
||||
const comments = [{ id: "c1", text: "Hello", author: "alice", createdAt: "2026-01-01T00:00:00.000Z" }];
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, comments }),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await GET(app, "/api/tasks/KB-001/comments");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(comments);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/comments — adds a task comment", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }] };
|
||||
const store = createMockStore({ addTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/tasks/KB-001/comments", JSON.stringify({ text: "Hello" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||||
});
|
||||
|
||||
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [{ id: "c1", text: "Updated", author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:01:00.000Z" }] };
|
||||
const store = createMockStore({ updateTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "PATCH", "/api/tasks/KB-001/comments/c1", JSON.stringify({ text: "Updated" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, comments: [] };
|
||||
const store = createMockStore({ deleteTaskComment: vi.fn().mockResolvedValue(updatedTask) });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/steer", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const mockComment = {
|
||||
|
||||
@@ -1654,6 +1654,72 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/:id/comments", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
res.json(task.comments || []);
|
||||
} catch (err: any) {
|
||||
const status = err.code === "ENOENT" ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tasks/:id/comments", async (req, res) => {
|
||||
try {
|
||||
const { text, author } = req.body;
|
||||
if (!text || typeof text !== "string") {
|
||||
res.status(400).json({ error: "text is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
if (text.length === 0 || text.length > 2000) {
|
||||
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
|
||||
return;
|
||||
}
|
||||
if (author !== undefined && typeof author !== "string") {
|
||||
res.status(400).json({ error: "author must be a string" });
|
||||
return;
|
||||
}
|
||||
const task = await store.addTaskComment(req.params.id, text, author?.trim() || "user");
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.code === "ENOENT" ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/tasks/:id/comments/:commentId", async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!text || typeof text !== "string") {
|
||||
res.status(400).json({ error: "text is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
if (text.length === 0 || text.length > 2000) {
|
||||
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
|
||||
return;
|
||||
}
|
||||
const task = await store.updateTaskComment(req.params.id, req.params.commentId, text);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
: err.message?.includes("not found") ? 404
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/tasks/:id/comments/:commentId", async (req, res) => {
|
||||
try {
|
||||
const task = await store.deleteTaskComment(req.params.id, req.params.commentId);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
: err.message?.includes("not found") ? 404
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add steering comment to task
|
||||
router.post("/tasks/:id/steer", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user