feat(FN-3276): add task review tab with metadata persistence and desktop la

This merge lands three major features and a significant dashboard enhancement. FN-3276 adds a full Review tab to the task detail modal with multi-step lifecycle: review metadata persistence in the task store, new task workflow routes for refresh and same-task revision, and the review tab UI surface

Fusion-Task-Id: FN-3276
This commit is contained in:
Fusion
2026-05-07 21:28:56 -07:00
committed by gsxdsm
parent a1354685d0
commit ecbf1f829c
23 changed files with 905 additions and 24 deletions

View File

@@ -4088,6 +4088,76 @@ describe("TaskStore", () => {
expect(restored.sourceIssue).toEqual(sourceIssue);
});
it("persists review metadata on create, update, and reload", async () => {
const review: NonNullable<Task["review"]> = {
mode: "direct",
source: "reviewer-agent",
decision: "changes-requested",
summary: "Address reviewer findings",
latestRefreshAt: new Date().toISOString(),
selectedItemIds: ["rvw-1"],
items: [
{
id: "rvw-1",
source: "reviewer-agent",
status: "queued",
summary: "Fix failing assertion",
body: "Assertion in task detail modal test is stale.",
reviewer: "reviewer",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
};
const created = await store.createTask({ description: "Task with review metadata" });
const updated = await store.updateTask(created.id, { review });
expect(updated.review).toEqual(review);
const reloaded = await store.getTask(created.id);
expect(reloaded.review).toEqual(review);
const cleared = await store.updateTask(created.id, { review: null });
expect(cleared.review).toBeUndefined();
});
it("preserves review metadata through archive and unarchive", async () => {
const review: NonNullable<Task["review"]> = {
mode: "pull-request",
source: "github-pr",
decision: "pending",
summary: "PR review feedback",
latestRefreshAt: new Date().toISOString(),
selectedItemIds: ["gh-1"],
items: [
{
id: "gh-1",
source: "github-pr",
status: "in-progress",
summary: "Address thread in src/file.ts",
filePath: "src/file.ts",
line: 42,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
};
const task = await store.createTask({ description: "Archive review persistence" });
await store.updateTask(task.id, { review });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const archived = await store.getTask(task.id);
expect(archived.review).toEqual(review);
const restored = await store.unarchiveTask(task.id);
expect(restored.review).toEqual(review);
});
it("sets and clears mission linkage fields via updateTask", async () => {
const task = await createTestTask();

View File

@@ -201,6 +201,7 @@ CREATE TABLE IF NOT EXISTS tasks (
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
review TEXT,
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
@@ -1189,6 +1190,7 @@ export class Database {
if (this.hasTable("tasks")) {
this.addColumnIfMissing("tasks", "executionStartBranch", "TEXT");
this.addColumnIfMissing("tasks", "review", "TEXT");
}
if (version >= SCHEMA_VERSION) return;

View File

@@ -90,6 +90,7 @@ interface TaskRow {
attachments: string | null;
steeringComments: string | null;
comments: string | null;
review: string | null;
workflowStepResults: string | null;
prInfo: string | null;
issueInfo: string | null;
@@ -751,6 +752,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
return deduped.length > 0 ? deduped : undefined;
})(),
review: fromJson<import("./types.js").TaskReview>(row.review) ?? 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),
@@ -813,6 +815,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceIssue: slim ? undefined : entry.sourceIssue,
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
review: slim ? undefined : entry.review,
log: slim ? [] : entry.log ?? [],
timedExecutionMs: slim ? this.computeTimedExecutionMs(entry.log) : undefined,
createdAt: entry.createdAt,
@@ -936,6 +939,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceIssue: task.sourceIssue,
attachments: task.attachments,
comments: task.comments,
review: task.review,
prompt,
...agentLogFields,
log: [{ timestamp: archivedAt, action: "Task archived" }],
@@ -1014,7 +1018,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"dependencies", "steps", "comments", "review", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
@@ -1064,7 +1068,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"comments", "review", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1107,11 +1111,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
comments, review, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -1166,6 +1170,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments = excluded.attachments,
steeringComments = excluded.steeringComments,
comments = excluded.comments,
review = excluded.review,
workflowStepResults = excluded.workflowStepResults,
prInfo = excluded.prInfo,
issueInfo = excluded.issueInfo,
@@ -1249,6 +1254,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJsonNullable(task.review),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
@@ -3170,7 +3176,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3440,6 +3446,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.executionCompletedAt !== undefined) {
task.executionCompletedAt = updates.executionCompletedAt;
}
if (updates.review === null) {
task.review = undefined;
} else if (updates.review !== undefined) {
task.review = updates.review;
}
if (updates.workflowStepResults === null) {
task.workflowStepResults = undefined;
} else if (updates.workflowStepResults !== undefined) {
@@ -6088,6 +6099,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
size: entry.size,
reviewLevel: entry.reviewLevel,
prInfo: entry.prInfo,
review: entry.review,
issueInfo: entry.issueInfo,
sourceIssue: entry.sourceIssue,
attachments: entry.attachments,

View File

@@ -699,6 +699,37 @@ export interface TaskCommentInput {
author: string;
}
export type TaskReviewMode = "pull-request" | "direct";
export type TaskReviewSource = "github-pr" | "reviewer-agent";
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
export interface TaskReviewItem {
id: string;
source: TaskReviewSource;
status: TaskReviewItemStatus;
summary: string;
body?: string;
filePath?: string;
line?: number;
commentUrl?: string;
reviewer?: string;
createdAt: string;
updatedAt: string;
addressedAt?: string;
failedReason?: string;
}
export interface TaskReview {
mode: TaskReviewMode;
source: TaskReviewSource;
decision: TaskReviewDecision;
summary?: string;
latestRefreshAt?: string;
selectedItemIds?: string[];
items: TaskReviewItem[];
}
export interface TaskDocument {
/** UUID primary key */
id: string;
@@ -918,6 +949,8 @@ export interface Task {
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** Structured review metadata shown in the Review tab. */
review?: TaskReview;
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
mergeDetails?: MergeDetails;
@@ -2339,6 +2372,8 @@ export interface ArchivedTaskEntry {
attachments?: TaskAttachment[];
/** User and agent comments remain searchable in the archive DB. */
comments?: TaskComment[];
/** Structured review metadata shown in the Review tab. */
review?: TaskReview;
/** Reconstructed prompt content at archive time, without attachment blobs. */
prompt?: string;
/** Agent log retention mode used when this archive entry was written. */

View File

@@ -250,6 +250,20 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
throw new Error("Request failed");
}
export interface UpdateTaskReviewRequest {
review: TaskDetail["review"] | null;
}
export interface RefreshTaskReviewResponse {
review: NonNullable<TaskDetail["review"]>;
automationStatus: string | null;
}
export interface ReviseTaskReviewResponse {
task: Task;
review: NonNullable<TaskDetail["review"]>;
}
export interface CreateTaskRequestOptions {
transportNodeId?: string;
localNodeId?: string;
@@ -5084,6 +5098,21 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
});
}
/** Refresh normalized task review data (PR mode or direct mode) */
export function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
return api<RefreshTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
method: "POST",
});
}
/** Request an in-place revision pass for selected review items */
export function reviseTaskReviewItems(taskId: string, itemIds: string[], projectId?: string): Promise<ReviseTaskReviewResponse> {
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/revise`, projectId), {
method: "POST",
body: JSON.stringify({ itemIds }),
});
}
/** Return task to agent - clear assignee and status, move to todo */
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {

View File

@@ -25,6 +25,7 @@ import { AgentLogViewer } from "./AgentLogViewer";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { TaskComments } from "./TaskComments";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
import { TaskForm, type PendingImage } from "./TaskForm";
@@ -227,7 +228,7 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
export interface TaskDetailModalProps {
task: Task | TaskDetail;
@@ -1987,6 +1988,12 @@ export function TaskDetailContent({
Changes
</button>
)}
<button
className={`detail-tab${activeTab === "review" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("review")}
>
Review
</button>
<button
className={`detail-tab${activeTab === "comments" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("comments")}
@@ -2113,6 +2120,8 @@ export function TaskDetailContent({
</div>
) : activeTab === "changes" ? (
<TaskChangesTab taskId={task.id} worktree={task.worktree} projectId={projectId} column={task.column} mergeDetails={task.mergeDetails} modifiedFiles={task.modifiedFiles} />
) : activeTab === "review" ? (
<TaskReviewTab task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
) : activeTab === "comments" ? (
<TaskComments task={task} addToast={addToast} projectId={projectId} onTaskUpdated={onTaskUpdated} />
) : activeTab === "documents" ? (

View File

@@ -0,0 +1,106 @@
.task-review-tab {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.task-review-tab__header {
display: flex;
gap: var(--space-md);
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.task-review-tab__summary-wrap {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.task-review-tab__summary {
margin: 0;
color: var(--text-muted);
}
.task-review-tab__decision {
padding: 0 var(--space-sm);
border-radius: var(--radius-pill);
font-size: 0.75rem;
text-transform: capitalize;
}
.task-review-tab__decision--approved {
color: var(--color-success);
}
.task-review-tab__decision--changes-requested {
color: var(--color-error);
}
.task-review-tab__decision--commented,
.task-review-tab__decision--pending {
color: var(--color-warning);
}
.task-review-tab__actions {
display: flex;
gap: var(--space-sm);
}
.task-review-tab__list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding: 0;
list-style: none;
}
.task-review-tab__item {
padding: var(--space-md);
}
.task-review-tab__row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-sm);
align-items: start;
}
.task-review-tab__item-summary {
color: var(--text);
}
.task-review-tab__status {
color: var(--text-muted);
text-transform: capitalize;
}
.task-review-tab__status--failed {
color: var(--color-error);
}
.task-review-tab__status--queued,
.task-review-tab__status--in-progress {
color: var(--color-warning);
}
.task-review-tab__status--addressed {
color: var(--color-success);
}
.task-review-tab__empty {
color: var(--text-muted);
}
@media (max-width: 768px) {
.task-review-tab__row {
grid-template-columns: auto 1fr;
}
.task-review-tab__status {
grid-column: 2;
}
}

View File

@@ -0,0 +1,85 @@
import "./TaskReviewTab.css";
import type { Task, TaskDetail } from "@fusion/core";
import { useMemo, useState } from "react";
import { refreshTaskReview, reviseTaskReviewItems } from "../api";
import type { ToastType } from "../hooks/useToast";
interface Props {
task: Task | TaskDetail;
projectId?: string;
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
const [selected, setSelected] = useState<string[]>(task.review?.selectedItemIds ?? []);
const review = task.review;
const canRevise = selected.length > 0;
const summaryText = useMemo(() => {
if (!review) return "No review feedback captured yet.";
return review.summary ?? `${review.items.length} review item(s)`;
}, [review]);
const decisionLabel = review?.decision ? review.decision.replace("-", " ") : undefined;
const toggleSelected = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]));
};
const onRefresh = async () => {
try {
const result = await refreshTaskReview(task.id, projectId);
onTaskUpdated?.({ ...task, review: result.review } as Task);
addToast("Review refreshed", "success");
} catch (error) {
addToast(error instanceof Error ? error.message : "Failed to refresh review", "error");
}
};
const onRevise = async () => {
try {
const result = await reviseTaskReviewItems(task.id, selected, projectId);
onTaskUpdated?.({ ...result.task, review: result.review } as Task);
addToast("Queued same-task revision", "success");
} catch (error) {
addToast(error instanceof Error ? error.message : "Failed to queue revision", "error");
}
};
return (
<div className="task-review-tab">
<div className="task-review-tab__header">
<div className="task-review-tab__summary-wrap">
<p className="task-review-tab__summary">{summaryText}</p>
{decisionLabel ? (
<span className={`task-review-tab__decision task-review-tab__decision--${review?.decision}`}>{decisionLabel}</span>
) : null}
</div>
<div className="task-review-tab__actions">
<button className="btn btn-sm" onClick={onRefresh}>Refresh</button>
<button className="btn btn-primary btn-sm" disabled={!canRevise} onClick={onRevise}>Request revision</button>
</div>
</div>
{review?.items?.length ? (
<ul className="task-review-tab__list">
{review.items.map((item) => (
<li key={item.id} className="task-review-tab__item card">
<label className="task-review-tab__row">
<input
type="checkbox"
checked={selected.includes(item.id)}
onChange={() => toggleSelected(item.id)}
/>
<span className="task-review-tab__item-summary">{item.summary}</span>
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
</label>
</li>
))}
</ul>
) : (
<div className="task-review-tab__empty">No review items yet.</div>
)}
</div>
);
}

View File

@@ -737,25 +737,26 @@ describe("TaskDetailModal", () => {
);
// For an in-progress task (no workflow steps, no merge commit), the
// top-level tabs are: Definition, Logs, Changes, Comments, Documents,
// Model, Workflow, Stats, Routing.
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
// top-level tabs are: Definition, Logs, Changes, Review, Comments,
// Documents, Model, Workflow, Stats, Routing.
const tabTexts = ["Definition", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Documents");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[6].textContent).toBe("Workflow");
expect(tabs[7].textContent).toBe("Stats");
expect(tabs[8].textContent).toBe("Routing");
expect(tabs[3].textContent).toBe("Review");
expect(tabs[4].textContent).toBe("Comments");
expect(tabs[5].textContent).toBe("Documents");
expect(tabs[6].textContent).toBe("Model");
expect(tabs[7].textContent).toBe("Workflow");
expect(tabs[8].textContent).toBe("Stats");
expect(tabs[9].textContent).toBe("Routing");
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
expect(container.querySelectorAll(".detail-tab").length).toBe(9);
expect(container.querySelectorAll(".detail-tab").length).toBe(10);
// Workflow tab should always appear even when no workflow steps are configured
expect(screen.getByText("Workflow")).toBeInTheDocument();
// Commits tab should NOT appear for non-done tasks

View File

@@ -185,7 +185,7 @@ describe("TaskDetailModal", () => {
// In-progress tasks show exactly 9 tabs:
// Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
@@ -214,7 +214,7 @@ describe("TaskDetailModal", () => {
// In-progress task with workflow steps: 9 tabs (Workflow after Model, Stats then Routing)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
@@ -244,7 +244,7 @@ describe("TaskDetailModal", () => {
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing (9 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
@@ -277,7 +277,7 @@ describe("TaskDetailModal", () => {
// Done task with workflow steps and commit SHA: 9 tabs (no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");

View File

@@ -290,7 +290,7 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("PROMPT.md")).toBeNull();
});
it("renders Comments tab", () => {
it("renders Review and Comments tabs", () => {
render(
<TaskDetailModal
task={makeTask()}
@@ -303,6 +303,7 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByText("Review")).toBeTruthy();
expect(screen.getByText("Comments")).toBeTruthy();
});

View File

@@ -37,7 +37,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy();
expect(container.querySelector(".detail-body")).toBeTruthy();
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(9);
expect(tabs.length).toBe(10);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks

View File

@@ -35,6 +35,8 @@ vi.mock("../../api", async (importOriginal) => {
pauseTask: vi.fn().mockResolvedValue({}),
unpauseTask: vi.fn().mockResolvedValue({}),
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
refreshTaskReview: vi.fn().mockResolvedValue({ review: undefined, automationStatus: null }),
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), review: undefined }),
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskReviewTab } from "../TaskReviewTab";
import { makeTask } from "./TaskDetailModal.test-helpers";
const refreshTaskReview = vi.fn();
const reviseTaskReviewItems = vi.fn();
vi.mock("../../api", () => ({
refreshTaskReview,
reviseTaskReviewItems,
}));
describe("TaskReviewTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders empty state when review is missing", () => {
render(<TaskReviewTab task={makeTask({ review: undefined })} addToast={vi.fn()} />);
expect(screen.getByText("No review items yet.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
});
it("calls refresh endpoint", async () => {
const task = makeTask({ review: { mode: "direct", source: "reviewer-agent", decision: "pending", items: [] } });
refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
});
it("renders PR decision and status modifiers", () => {
const task = makeTask({
review: {
mode: "pull-request",
source: "github-pr",
decision: "changes-requested",
summary: "Needs updates",
items: [
{
id: "ri-1",
source: "github-pr",
status: "failed",
summary: "Fix null handling",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
});
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
expect(screen.getByText("changes requested")).toBeInTheDocument();
expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed");
});
it("renders review items and queues revision for selected entries", async () => {
const task = makeTask({
review: {
mode: "pull-request",
source: "github-pr",
decision: "changes-requested",
summary: "Needs updates",
items: [
{
id: "ri-1",
source: "github-pr",
status: "queued",
summary: "Fix null handling",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
});
reviseTaskReviewItems.mockResolvedValue({ task, review: task.review });
refreshTaskReview.mockResolvedValue({ review: task.review, automationStatus: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Request revision" }));
expect(reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined);
});
});

View File

@@ -2027,5 +2027,71 @@ describe("GET /tasks/:id/file-diffs", () => {
});
});
describe("POST /tasks/:id/review/refresh", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("refreshes PR-backed review payload", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-1",
baseBranch: "main",
commentCount: 0,
},
});
vi.spyOn(GitHubClient.prototype, "getPrReviewSnapshot").mockResolvedValue({
decision: "CHANGES_REQUESTED",
checks: [],
summary: "needs work",
items: [],
});
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ review: expect.objectContaining({ mode: "pull-request" }) }),
);
});
it("refreshes direct-mode review payload without PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
review: {
mode: "direct",
source: "reviewer-agent",
decision: "pending",
items: [],
},
});
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/review/refresh", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect((store.updateTask as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect(res.body.review.mode).toBe("direct");
});
});
// --- Git Management route tests ---
// These are integration tests that run against the actual git repository

View File

@@ -2137,3 +2137,59 @@ describe("POST /subtasks/*", () => {
});
});
describe("POST /tasks/:id/review/revise", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("queues selected review items and moves task to todo", async () => {
const taskWithReview = {
...FAKE_TASK_DETAIL,
id: "FN-001",
column: "in-review",
steps: [],
review: {
mode: "direct",
source: "reviewer-agent",
decision: "changes-requested",
items: [
{
id: "ri-1",
source: "reviewer-agent",
status: "failed",
summary: "Fix tests",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
};
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithReview);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...taskWithReview, column: "todo" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/FN-001/review/revise",
JSON.stringify({ itemIds: ["ri-1"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ review: expect.objectContaining({ selectedItemIds: ["ri-1"] }) }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true });
});
});

View File

@@ -73,6 +73,27 @@ export interface PrCheckStatus {
state: PrCheckState;
}
export interface PrReviewItem {
id: string;
source: "github-pr";
status: "queued" | "in-progress" | "addressed" | "failed";
summary: string;
body?: string;
filePath?: string;
line?: number;
reviewer?: string;
commentUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface PrReviewSnapshot {
decision: ReviewDecision;
checks: PrCheckStatus[];
items: PrReviewItem[];
summary?: string;
}
export interface PrMergeStatus {
prInfo: PrInfo;
reviewDecision: ReviewDecision;
@@ -109,6 +130,15 @@ export type BadgeBatchResponse = Record<
>;
// gh CLI JSON output types
interface GhReviewJson {
id: string;
state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "PENDING" | string;
body?: string | null;
submittedAt?: string | null;
author?: { login?: string | null } | null;
url?: string | null;
}
interface GhPrViewJson {
id?: string;
number: number;
@@ -126,6 +156,7 @@ interface GhPrViewJson {
updatedAt: string;
url: string;
}>;
reviews?: GhReviewJson[];
}
interface GhPrListJson {
@@ -521,6 +552,54 @@ export class GitHubClient {
});
}
async getPrReviewSnapshot(owner: string | undefined, repo: string | undefined, number: number): Promise<PrReviewSnapshot> {
const { owner: resolvedOwner, repo: resolvedRepo } = this.resolveRepo(owner, repo);
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr",
"view",
String(number),
"--repo",
`${resolvedOwner}/${resolvedRepo}`,
"--json",
"reviewDecision,reviews,comments",
]);
const checks = (await this.getPrMergeStatus(resolvedOwner, resolvedRepo, number)).checks;
const commentItems: PrReviewItem[] = (pr.comments ?? []).map((comment) => ({
id: `gh-comment-${comment.id}`,
source: "github-pr",
status: "queued",
summary: comment.body.trim().slice(0, 160) || `Comment from @${comment.author?.login ?? "reviewer"}`,
body: comment.body,
reviewer: comment.author?.login ?? undefined,
commentUrl: comment.url,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}));
const reviewItems: PrReviewItem[] = (pr.reviews ?? []).map((review) => {
const createdAt = review.submittedAt ?? new Date().toISOString();
return {
id: `gh-review-${review.id}`,
source: "github-pr",
status: "queued",
summary: (review.body ?? "").trim().slice(0, 160) || `Review ${review.state} by @${review.author?.login ?? "reviewer"}`,
body: review.body ?? undefined,
reviewer: review.author?.login ?? undefined,
commentUrl: review.url ?? undefined,
createdAt,
updatedAt: createdAt,
};
});
return {
decision: pr.reviewDecision ?? null,
checks,
items: [...reviewItems, ...commentItems],
summary: `PR #${number} has ${reviewItems.length} review(s) and ${commentItems.length} comment(s).`,
};
}
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
if (this.hasGhAuth()) {
try {

View File

@@ -3115,6 +3115,65 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/**
* POST /api/tasks/:id/review/refresh
* Refresh normalized review payload for task Review tab.
*/
router.post("/tasks/:id/review/refresh", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
let review = task.review;
if (task.prInfo) {
const badgeParsed = parseBadgeUrl(task.prInfo.url);
const owner = badgeParsed?.owner ?? getCurrentRepo(scopedStore.getRootDir())?.owner;
const repo = badgeParsed?.repo ?? getCurrentRepo(scopedStore.getRootDir())?.repo;
if (!owner || !repo) {
throw badRequest("Could not determine GitHub repository for PR review refresh");
}
const client = new GitHubClient();
const snapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
review = {
mode: "pull-request",
source: "github-pr",
decision:
snapshot.decision === "APPROVED"
? "approved"
: snapshot.decision === "CHANGES_REQUESTED"
? "changes-requested"
: "pending",
summary: snapshot.summary,
latestRefreshAt: new Date().toISOString(),
selectedItemIds: task.review?.selectedItemIds ?? [],
items: snapshot.items,
};
} else {
const existing = task.review;
review = {
mode: existing?.mode ?? "direct",
source: existing?.source ?? "reviewer-agent",
decision: existing?.decision ?? "pending",
summary: existing?.summary,
latestRefreshAt: new Date().toISOString(),
selectedItemIds: existing?.selectedItemIds ?? [],
items: existing?.items ?? [],
};
}
await scopedStore.updateTask(task.id, { review });
res.json({ review, automationStatus: task.status ?? null });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err);
}
});
/**
* POST /api/tasks/:id/issue/refresh
* Force refresh issue status from GitHub API.

View File

@@ -1,4 +1,6 @@
import { createReadStream } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
import {
COLUMNS,
@@ -1757,6 +1759,95 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Queue same-task revision pass for selected review items
router.post("/tasks/:id/review/revise", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
const itemIds = Array.isArray(req.body?.itemIds)
? req.body.itemIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (itemIds.length === 0) {
throw badRequest("itemIds must be a non-empty array of review item IDs");
}
if (!task.review) {
throw badRequest("Task has no review payload");
}
const now = new Date().toISOString();
const selectedSet = new Set(itemIds);
const selectedSummaries: string[] = [];
const updatedItems = task.review.items.map((item) => {
if (!selectedSet.has(item.id)) return item;
selectedSummaries.push(`- ${item.summary}`);
return {
...item,
status: "queued" as const,
updatedAt: now,
failedReason: undefined,
};
});
const review = {
...task.review,
selectedItemIds: itemIds,
items: updatedItems,
};
await scopedStore.updateTask(task.id, {
review,
status: null,
error: null,
sessionFile: null,
});
if (selectedSummaries.length > 0) {
const fusionDir = typeof scopedStore.getFusionDir === "function"
? scopedStore.getFusionDir()
: join(scopedStore.getRootDir(), ".fusion");
const promptPath = join(fusionDir, "tasks", task.id, "PROMPT.md");
try {
const promptContent = await readFile(promptPath, "utf-8");
const sectionHeader = "## Workflow Revision Instructions";
const sectionContent = `${sectionHeader}\n\nAddress the following selected review feedback items in this same task run:\n\n${selectedSummaries.join("\n")}\n`;
const sectionRegex = new RegExp(`${sectionHeader}[\\s\\S]*?(?=\\n## |\\n# |$)`, "m");
const nextPrompt = promptContent.includes(sectionHeader)
? promptContent.replace(sectionRegex, sectionContent)
: `${promptContent}\n\n${sectionContent}`;
await writeFile(promptPath, nextPrompt, "utf-8");
} catch {
// non-fatal: task may not have prompt yet
}
}
if (selectedSummaries.length > 0) {
await scopedStore.addTaskComment(
task.id,
`Review revision requested for selected items:\n\n${selectedSummaries.join("\n")}`,
"user",
);
}
const lastDoneStep = [...task.steps]
.map((step, index) => ({ step, index }))
.reverse()
.find(({ step }) => step.status === "done" || step.status === "in-progress");
if (lastDoneStep) {
await scopedStore.updateStep(task.id, lastDoneStep.index, "pending");
}
const moved = await scopedStore.moveTask(task.id, "todo", { preserveProgress: true });
await scopedStore.logEntry(task.id, "Review revision requested", `${itemIds.length} item(s) queued for same-task revision`);
res.json({ task: moved, review });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// Return task to agent - clear assignee and status, move to todo
router.post("/tasks/:id/return-to-agent", async (req, res) => {
try {

View File

@@ -4,7 +4,10 @@ import type { TaskStore, Task } from "@fusion/core";
const mockStore = {
addTaskComment: vi.fn<(id: string, text: string, author?: string) => Promise<Task>>(),
getTask: vi.fn<(id: string) => Promise<Task>>().mockResolvedValue({ id: "FN-001", review: undefined } as Task),
updateTask: vi.fn<(id: string, updates: Partial<Task>) => Promise<Task>>().mockResolvedValue({ id: "FN-001" } as Task),
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
moveTask: vi.fn<(id: string, column: Task["column"]) => Promise<Task>>().mockResolvedValue({ id: "FN-001", column: "in-progress" } as Task),
} as unknown as TaskStore;
describe("PrCommentHandler", () => {
@@ -195,6 +198,27 @@ describe("PrCommentHandler", () => {
});
});
describe("handleChangesRequested", () => {
it("persists review item feedback when changes are requested", async () => {
(mockStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001", column: "in-review", review: undefined } as Task);
await handler.handleChangesRequested("FN-001", mockPrInfo, "reviewer", "Please add tests");
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({
review: expect.objectContaining({
mode: "pull-request",
items: expect.arrayContaining([
expect.objectContaining({ source: "github-pr", status: "queued" }),
]),
}),
}),
);
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
});
describe("createFollowUpTask", () => {
it("creates follow-up task for unaddressed feedback", async () => {
await handler.createFollowUpTask("FN-001", mockPrInfo, [

View File

@@ -86,6 +86,7 @@ export class PrCommentHandler {
try {
await this.store.addTaskComment(taskId, text, "agent");
await this.upsertReviewItem(taskId, prInfo, comment, "queued");
prMonitorLog.log(`Added comment for PR review #${comment.id}`);
} catch (err) {
prMonitorLog.error(`Failed to add comment for ${taskId}:`, err);
@@ -179,6 +180,19 @@ export class PrCommentHandler {
].join("\n");
await this.store.addTaskComment(taskId, feedbackText, "agent");
await this.upsertReviewItem(
taskId,
prInfo,
{
id: Date.now(),
body: reviewBody || "(no review body)",
user: { login: reviewerLogin },
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
html_url: prInfo.url,
},
"queued",
);
await this.store.moveTask(taskId, "in-progress");
await this.store.logEntry(
taskId,
@@ -231,4 +245,51 @@ Please review the PR comments and address any remaining issues.`;
prMonitorLog.error(`Failed to create follow-up task:`, err);
}
}
private async upsertReviewItem(
taskId: string,
prInfo: PrInfo,
comment: PrComment,
status: "queued" | "in-progress" | "addressed" | "failed",
): Promise<void> {
const task = await this.store.getTask(taskId);
const now = new Date().toISOString();
const current = task.review ?? {
mode: "pull-request",
source: "github-pr",
decision: "pending",
items: [],
selectedItemIds: [],
};
const itemId = `gh-comment-${comment.id}`;
const existingIndex = current.items.findIndex((item: { id: string }) => item.id === itemId);
const nextItem = {
id: itemId,
source: "github-pr" as const,
status,
summary: comment.body.trim().slice(0, 160) || `Feedback from @${comment.user.login}`,
body: comment.body,
reviewer: comment.user.login,
commentUrl: comment.html_url,
createdAt: comment.created_at,
updatedAt: now,
};
const nextItems = [...current.items];
if (existingIndex >= 0) {
nextItems[existingIndex] = { ...nextItems[existingIndex], ...nextItem };
} else {
nextItems.push(nextItem);
}
await this.store.updateTask(taskId, {
review: {
...current,
mode: "pull-request",
source: "github-pr",
summary: `PR #${prInfo.number} feedback items: ${nextItems.length}`,
latestRefreshAt: now,
items: nextItems,
},
});
}
}