test(KB-601): add test for pause/unpause with missing directory
This commit is contained in:
5
.changeset/add-task-comments-and-merge-details.md
Normal file
5
.changeset/add-task-comments-and-merge-details.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@dustinbyrne/kb": patch
|
||||
---
|
||||
|
||||
Add general task comments in the dashboard and CLI, and surface richer merge details for completed tasks.
|
||||
25
README.md
25
README.md
@@ -88,6 +88,8 @@ fn task archive KB-001 # Archive a done task
|
||||
fn task unarchive KB-001 # Restore an archived task
|
||||
fn task delete KB-001 [--force] # Delete a task
|
||||
fn task retry KB-001 # Retry a failed task
|
||||
fn task comment KB-001 "Looks good" # Add a general task comment
|
||||
fn task comments KB-001 # List task comments
|
||||
fn task steer KB-001 "Use TypeScript" # Add steering comment
|
||||
fn task pause KB-001 # Pause automation for task
|
||||
fn task unpause KB-001 # Resume automation for task
|
||||
@@ -500,6 +502,29 @@ When a task has a linked PR, Fusion automatically monitors it for new review com
|
||||
|
||||
Uses `gh` CLI authentication when available, falls back to `GITHUB_TOKEN` if set.
|
||||
|
||||
## Task Comments vs Steering Comments
|
||||
|
||||
Fusion now supports two distinct kinds of discussion on a task:
|
||||
|
||||
- **Task comments** — General collaboration notes for humans. Use these for questions, decisions, progress notes, or context you want preserved on the task. You can add them from the dashboard Comments tab or from the CLI with `fn task comment <id> "message"`.
|
||||
- **Steering comments** — Execution guidance aimed at the AI worker. These are used for actionable direction like “change this approach” or “use TypeScript here,” and are also populated automatically from actionable PR review feedback.
|
||||
|
||||
Use task comments for conversation. Use steering comments when you want to influence implementation behavior.
|
||||
|
||||
## Merge Details
|
||||
|
||||
When a task is merged successfully, Fusion now stores richer merge metadata on the completed task and shows it in the task detail view. This includes:
|
||||
|
||||
- merge commit SHA
|
||||
- files changed count
|
||||
- insertions and deletions
|
||||
- merge timestamp
|
||||
- merge commit message
|
||||
- linked PR number when available
|
||||
- merge confirmation status
|
||||
|
||||
This gives completed tasks a clearer audit trail in both the board UI and stored task metadata.
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
Workflow steps are reusable quality gates that run after task implementation but before the task moves to in-review.
|
||||
|
||||
@@ -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