This merge adds two substantial features: a rooms and chat sidebar system (FN-3806) with a new `CreateRoomModal` and enriched `ChatView`, wired through plugin routes and a new `listTasksModifiedSince` core API; and a durable review addressing workflow (FN-3282) that persists snapshots, renders addre Fusion-Task-Id: FN-3282
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import "./TaskReviewTab.css";
|
|
import type { Task, TaskDetail } from "@fusion/core";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
|
import type { SelectedReviewItem } 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;
|
|
}
|
|
|
|
const REVIEW_LOAD_ERROR_MESSAGE = "Failed to load review data.";
|
|
const DIRECT_MODE_EMPTY_MESSAGE = "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.";
|
|
|
|
type ReviewState = NonNullable<TaskDetail["reviewState"]>;
|
|
type ReviewItem = ReviewState["items"][number];
|
|
type AddressingRecord = ReviewState["addressing"][number];
|
|
|
|
type DisplayReviewItem = {
|
|
id: string;
|
|
summary: string;
|
|
body: string;
|
|
path?: string;
|
|
createdAt?: string;
|
|
status: "queued" | "in-progress" | "addressed" | "failed";
|
|
addressing?: AddressingRecord;
|
|
item?: ReviewItem;
|
|
};
|
|
|
|
function formatTimestamp(value?: string): string {
|
|
if (!value) return "Never";
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
function formatRefreshSource(source?: "manual" | "auto" | "initial-load"): string {
|
|
if (source === "manual") return "Manual";
|
|
if (source === "auto") return "Background";
|
|
return "Initial load";
|
|
}
|
|
|
|
function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
|
|
const addressingById = new Map(review.addressing.map((record) => [record.itemId, record] as const));
|
|
const items = review.items.map((item) => {
|
|
const addressing = addressingById.get(item.id);
|
|
return {
|
|
id: item.id,
|
|
summary: item.summary ?? item.body.slice(0, 120),
|
|
body: item.body,
|
|
path: item.path,
|
|
createdAt: item.createdAt,
|
|
status: addressing?.status ?? "queued",
|
|
addressing,
|
|
item,
|
|
} satisfies DisplayReviewItem;
|
|
});
|
|
|
|
const existingIds = new Set(items.map((item) => item.id));
|
|
const snapshots = review.addressing
|
|
.filter((record) => !existingIds.has(record.itemId) && record.snapshot)
|
|
.map((record) => ({
|
|
id: record.itemId,
|
|
summary: record.snapshot?.summary ?? record.itemId,
|
|
body: record.snapshot?.body ?? record.snapshot?.summary ?? record.itemId,
|
|
path: record.snapshot?.filePath,
|
|
createdAt: record.selectedAt,
|
|
status: record.status,
|
|
addressing: record,
|
|
} satisfies DisplayReviewItem));
|
|
|
|
return [...items, ...snapshots];
|
|
}
|
|
|
|
export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) {
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [revising, setRevising] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
|
|
const [review, setReview] = useState(task.reviewState ?? null);
|
|
|
|
const canRevise = selected.length > 0 && !revising;
|
|
const isPrMode = review?.source === "pull-request";
|
|
const displayItems = useMemo(() => (review ? getDisplayReviewItems(review) : []), [review]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
void fetchTaskReview(task.id, projectId)
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setReview(result.reviewState);
|
|
setEmptyMessage(result.emptyMessage ?? null);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setError(REVIEW_LOAD_ERROR_MESSAGE);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [task.id, projectId]);
|
|
|
|
const summaryText = useMemo(() => {
|
|
if (!review) return "No review feedback captured yet.";
|
|
if (review.source === "pull-request") {
|
|
const prSummary = review.summary as { reviewDecision?: string } | undefined;
|
|
return `${prSummary?.reviewDecision ?? "REVIEW_REQUIRED"} · ${displayItems.length} review item(s)`;
|
|
}
|
|
const reviewerSummary = review.summary as { summary?: string } | undefined;
|
|
return `${reviewerSummary?.summary ?? "reviewer-agent"} · ${displayItems.length} review item(s)`;
|
|
}, [review, displayItems.length]);
|
|
|
|
const decisionLabel = !review
|
|
? undefined
|
|
: review.source === "pull-request"
|
|
? (review.summary as { reviewDecision?: string } | undefined)?.reviewDecision
|
|
: (review.summary as { verdict?: string } | undefined)?.verdict;
|
|
|
|
const refreshStatus = refreshing ? "refreshing" : (review?.refreshStatus ?? "ready");
|
|
const refreshToneClass = refreshStatus === "error"
|
|
? "status-dot status-dot--error"
|
|
: refreshStatus === "refreshing"
|
|
? "status-dot status-dot--pending"
|
|
: "status-dot status-dot--online";
|
|
|
|
const toggleSelected = (id: string) => setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
|
|
|
|
const onRefresh = async () => {
|
|
try {
|
|
setError(null);
|
|
setRefreshing(true);
|
|
const result = await refreshTaskReview(task.id, projectId);
|
|
setReview(result.reviewState);
|
|
onTaskUpdated?.({ ...task, reviewState: result.reviewState, prInfo: result.prInfo ?? task.prInfo } as Task);
|
|
if (result.reviewState.refreshStatus === "error") {
|
|
const refreshMessage = result.reviewState.refreshError ?? "Failed to refresh review data.";
|
|
setError(refreshMessage);
|
|
addToast(refreshMessage, "error");
|
|
return;
|
|
}
|
|
addToast("Review refreshed", "success");
|
|
} catch (refreshError) {
|
|
const message = refreshError instanceof Error ? refreshError.message : REVIEW_LOAD_ERROR_MESSAGE;
|
|
setError(message);
|
|
addToast(message, "error");
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const onRevise = async () => {
|
|
try {
|
|
if (!review) return;
|
|
setError(null);
|
|
setRevising(true);
|
|
const selectedItems: SelectedReviewItem[] = displayItems
|
|
.filter((item) => selected.includes(item.id))
|
|
.map((item) => {
|
|
if (!item.item) {
|
|
return {
|
|
id: item.id,
|
|
source: review.source === "pull-request" ? "pr-review" : "reviewer-agent",
|
|
threadId: item.addressing?.snapshot?.threadId,
|
|
filePath: item.addressing?.snapshot?.filePath,
|
|
lineNumber: item.addressing?.snapshot?.lineNumber,
|
|
author: item.addressing?.snapshot?.authorLogin,
|
|
summary: item.summary,
|
|
body: item.body,
|
|
url: item.addressing?.snapshot?.url,
|
|
};
|
|
}
|
|
|
|
const itemRecord = item.item as unknown as Record<string, unknown>;
|
|
return {
|
|
id: item.item.id,
|
|
source: review.source === "pull-request" ? "pr-review" : "reviewer-agent",
|
|
threadId: typeof itemRecord.threadId === "string" ? itemRecord.threadId : undefined,
|
|
filePath: item.item.path,
|
|
lineNumber: typeof itemRecord.line === "number" ? itemRecord.line : undefined,
|
|
author: item.item.author?.login,
|
|
summary: item.item.summary ?? item.item.body.slice(0, 120),
|
|
body: item.item.body,
|
|
url: typeof itemRecord.url === "string" ? itemRecord.url : undefined,
|
|
};
|
|
});
|
|
|
|
const result = await reviseTaskReviewItems(task.id, selectedItems, projectId);
|
|
setReview(result.reviewState);
|
|
onTaskUpdated?.({ ...result.task, reviewState: result.reviewState } as Task);
|
|
setSelected([]);
|
|
addToast("Same-task AI revision started from selected review feedback", "success");
|
|
} catch (reviseError) {
|
|
const message = reviseError instanceof Error ? reviseError.message : "Failed to queue revision";
|
|
setError(message);
|
|
addToast(message, "error");
|
|
} finally {
|
|
setRevising(false);
|
|
}
|
|
};
|
|
|
|
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--${decisionLabel}`}>{decisionLabel}</span> : null}
|
|
</div>
|
|
<div className="task-review-tab__actions">
|
|
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || loading}>{refreshing ? "Refreshing…" : "Refresh"}</button>
|
|
<button className="btn btn-primary btn-sm" disabled={!canRevise} onClick={onRevise}>{revising ? "Queueing…" : "Request revision"}</button>
|
|
</div>
|
|
</div>
|
|
<div className="task-review-tab__meta task-review-tab__refresh-meta" aria-live="polite">
|
|
<span className={refreshToneClass} aria-hidden="true" />
|
|
<span>{refreshStatus === "error" ? "Refresh failed" : refreshStatus === "refreshing" ? "Refreshing" : "Up to date"} · Last refreshed: {formatTimestamp(review?.lastRefreshedAt)} · {formatRefreshSource(review?.refreshSource)}</span>
|
|
</div>
|
|
{loading ? <div className="task-review-tab__meta">Loading review data…</div> : null}
|
|
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
|
|
{!loading && !error && !isPrMode && displayItems.length === 0 ? <div className="task-review-tab__empty">{emptyMessage ?? DIRECT_MODE_EMPTY_MESSAGE}</div> : null}
|
|
{!loading && !error && displayItems.length > 0 ? (
|
|
<ul className="task-review-tab__list">
|
|
{displayItems.map((item) => (
|
|
<li key={item.id} className="task-review-tab__item card">
|
|
<label className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
|
|
<div className="task-review-tab__summary-wrap">
|
|
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
|
|
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span>
|
|
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
|
|
</div>
|
|
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
|
|
{item.addressing ? (
|
|
<div className="task-review-tab__meta">Selected: {formatTimestamp(item.addressing.selectedAt)}{item.addressing.startedAt ? ` · Started: ${formatTimestamp(item.addressing.startedAt)}` : ""}{item.addressing.completedAt ? ` · Completed: ${formatTimestamp(item.addressing.completedAt)}` : ""}{item.addressing.error ? ` · Error: ${item.addressing.error}` : ""}</div>
|
|
) : null}
|
|
<pre className="task-review-tab__body">{item.body}</pre>
|
|
</label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{isPrMode && !loading && !error && displayItems.length === 0 ? <div className="task-review-tab__empty">No review items yet.</div> : null}
|
|
</div>
|
|
);
|
|
}
|