import "./TaskReviewTab.css"; import { getErrorMessage, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { addressPrFeedback, fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api"; import type { SelectedReviewItem } from "../api"; import type { ToastType } from "../hooks/useToast"; import { linkifyFilePaths } from "../utils/filePathLinkify"; import { resolveReviewCommentAuthor } from "../utils/githubCommentAuthor"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import { LoadingSpinner } from "./LoadingSpinner"; import { MailboxMessageContent } from "./MailboxMessageContent"; interface Props { task: Task | TaskDetail; projectId?: string; onTaskUpdated?: (task: Task) => void; onRequestCreatePr?: () => void; prAuthAvailable?: boolean; autoMergeEnabled?: boolean; addToast: (message: string, type?: ToastType) => void; } const REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY = "fn-task-review-markdown"; type AuthorTypeFilter = "all" | "human" | "bot"; const AUTHOR_TYPE_FILTERS: AuthorTypeFilter[] = ["all", "human", "bot"]; type ReviewState = NonNullable; type ReviewItem = ReviewState["items"][number]; type AddressingRecord = ReviewState["addressing"][number]; type DisplayReviewItem = { id: string; summary: string; body: string; author?: string; path?: string; createdAt?: string; status: "queued" | "in-progress" | "addressed" | "failed"; addressing?: AddressingRecord; item?: ReviewItem; }; function readBooleanPref(key: string, defaultValue: boolean): boolean { if (typeof window === "undefined") return defaultValue; try { const raw = window.localStorage.getItem(key); if (raw === null) return defaultValue; return raw === "true"; } catch { return defaultValue; } } function writeBooleanPref(key: string, value: boolean): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value ? "true" : "false"); } catch { // ignore storage failures (quota, private mode, etc.) } } const HTML_COMMENT_PATTERN = //g; /* FNXC:TaskReview 2026-06-27-00:00: Review comments can include GitHub template comments. Plain-text mode must hide the same `` content as the shared sanitized markdown renderer so switching modes never leaks hidden reviewer templates. */ function stripHtmlComments(value: string): string { return value.replace(HTML_COMMENT_PATTERN, "").trim(); } function formatTimestamp(value?: string, t?: (key: string, defaultValue: string) => string): string { if (!value) return t?.("taskReview.never", "Never") ?? "Never"; return new Date(value).toLocaleString(); } function formatRefreshSource(source?: "manual" | "auto" | "initial-load", t?: (key: string, defaultValue: string) => string): string { if (source === "manual") return t?.("taskReview.refreshSourceManual", "Manual") ?? "Manual"; if (source === "auto") return t?.("taskReview.refreshSourceBackground", "Background") ?? "Background"; return t?.("taskReview.refreshSourceInitialLoad", "Initial load") ?? "Initial load"; } function summarizeChecks(checks: PrCheckStatus[] | undefined, t: (key: string, defaultValue: string, options?: Record) => string): string { const safeChecks = checks ?? []; if (safeChecks.length === 0) return t("taskReview.noChecksReported", "No checks reported"); const requiredChecks = safeChecks.filter((check) => check.required).length; const successfulChecks = safeChecks.filter((check) => check.state === "success").length; const blockingChecks = safeChecks.filter((check) => check.required && check.state !== "success" && check.state !== "skipped" && check.state !== "neutral").length; return t("taskReview.checksSummary", "{{successful}}/{{total}} checks passing · {{required}} required · {{blocking}} blocking", { successful: successfulChecks, total: safeChecks.length, required: requiredChecks, blocking: blockingChecks, }); } function getCheckTone(check: PrCheckStatus): "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" { if (check.state === "success" || check.state === "skipped" || check.state === "neutral") return "APPROVED"; if (check.state === "pending" || check.state === "stale") return "REVIEW_REQUIRED"; return "CHANGES_REQUESTED"; } 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, author: item.author?.login, 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, author: record.snapshot?.authorLogin, path: record.snapshot?.filePath, createdAt: record.selectedAt, status: record.status, addressing: record, } satisfies DisplayReviewItem)); return [...items, ...snapshots]; } export function TaskReviewTab({ task, projectId, onTaskUpdated, onRequestCreatePr, prAuthAvailable, autoMergeEnabled = false, addToast, }: Props) { const { t } = useTranslation("app"); const [selected, setSelected] = useState([]); const [refreshing, setRefreshing] = useState(false); const [revising, setRevising] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [emptyMessage, setEmptyMessage] = useState(null); const [review, setReview] = useState(task.reviewState ?? null); const [renderMarkdown, setRenderMarkdown] = useState(() => readBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, true)); const [authorTypeFilter, setAuthorTypeFilter] = useState("all"); const [brokenAvatars, setBrokenAvatars] = useState>(new Set()); const [autoMergePreference, setAutoMergePreference] = useState<"follow-default" | "on" | "off">( task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default", ); const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false); const [addressingPrFeedback, setAddressingPrFeedback] = useState(false); const isPrMode = review?.source === "pull-request"; const prSummary = isPrMode ? review?.summary as TaskReviewSummary | undefined : undefined; const displayItems = useMemo(() => (review ? getDisplayReviewItems(review) : []), [review]); const filteredDisplayItems = useMemo(() => { if (authorTypeFilter === "all") return displayItems; return displayItems.filter((item) => { const authorInfo = resolveReviewCommentAuthor(item.author, { reviewSource: review?.source }); return authorTypeFilter === "bot" ? authorInfo.authorIsBot : !authorInfo.authorIsBot; }); }, [authorTypeFilter, displayItems]); const visibleItemIds = useMemo(() => new Set(filteredDisplayItems.map((item) => item.id)), [filteredDisplayItems]); const canRevise = selected.length > 0 && !revising; const canAddressPrFeedback = isPrMode && Boolean(getTaskPrimaryPrInfo(task)) && (task.column === "in-review" || task.column === "in-progress") && (canStartPrFeedbackAddressing(task) || displayItems.length > 0); useEffect(() => { writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); }, [renderMarkdown]); useEffect(() => { /* FNXC:TaskReview 2026-06-27-00:00: Author-type filtering makes hidden review comments non-actionable. Prune selected ids to the currently visible Human/Bot/All set so Request revision never submits an item the user filtered out of view. */ setSelected((current) => current.filter((id) => visibleItemIds.has(id))); }, [visibleItemIds]); useEffect(() => { setAutoMergePreference(task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default"); }, [task.autoMerge]); 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(t("taskReview.loadError", "Failed to load review data.")); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [task.id, projectId, t]); const summaryText = useMemo(() => { if (!review) return t("taskReview.noCapturedFeedback", "No review feedback captured yet."); if (review.source === "pull-request") { const prSummary = review.summary as { reviewDecision?: string } | undefined; return t("taskReview.prSummaryLine", "{{decision}} · {{count}} review item(s)", { decision: prSummary?.reviewDecision ?? "REVIEW_REQUIRED", count: displayItems.length, }); } const reviewerSummary = review.summary as { summary?: string } | undefined; return t("taskReview.reviewerSummaryLine", "{{reviewer}} · {{count}} review item(s)", { reviewer: reviewerSummary?.summary ?? "reviewer-agent", count: displayItems.length, }); }, [review, displayItems.length, t]); 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 ?? t("taskReview.refreshDataFailed", "Failed to refresh review data."); setError(refreshMessage); addToast(refreshMessage, "error"); return; } addToast(t("taskReview.refreshed", "Review refreshed"), "success"); } catch (refreshError) { const message = refreshError instanceof Error ? refreshError.message : t("taskReview.loadError", "Failed to load review data."); setError(message); addToast(message, "error"); } finally { setRefreshing(false); } }; const onAutoMergePreferenceChange = async (nextPreference: "follow-default" | "on" | "off") => { const previousPreference = autoMergePreference; setAutoMergePreference(nextPreference); setIsSavingAutoMergePreference(true); try { const autoMerge = nextPreference === "follow-default" ? null : nextPreference === "on"; const updatedTask = await updateTask(task.id, { autoMerge }, projectId); setAutoMergePreference(updatedTask.autoMerge === true ? "on" : updatedTask.autoMerge === false ? "off" : "follow-default"); onTaskUpdated?.(updatedTask); addToast(t("taskReview.autoMergePreferenceUpdated", "Per-task auto-merge preference updated"), "success"); } catch (updateError) { setAutoMergePreference(previousPreference); addToast(t("taskReview.updateFailed", "Failed to update {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(updateError) }), "error"); } finally { setIsSavingAutoMergePreference(false); } }; const onAddressPrFeedback = async () => { try { setError(null); setAddressingPrFeedback(true); const result = await addressPrFeedback(task.id, projectId); onTaskUpdated?.(result.task); addToast(t("taskReview.addressPrFeedbackStarted", "Addressing PR feedback — AI session started"), "success"); } catch (addressError) { const message = addressError instanceof Error ? addressError.message : t("taskReview.addressPrFeedbackFailed", "Failed to start PR feedback session"); setError(message); addToast(message, "error"); } finally { setAddressingPrFeedback(false); } }; const onRevise = async () => { try { if (!review) return; setError(null); setRevising(true); const selectedItems: SelectedReviewItem[] = filteredDisplayItems .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; 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: item.item.htmlUrl ?? (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(t("taskReview.revisionStarted", "Same-task AI revision started from selected review feedback"), "success"); } catch (reviseError) { const message = reviseError instanceof Error ? reviseError.message : t("taskReview.revisionQueueFailed", "Failed to queue revision"); setError(message); addToast(message, "error"); } finally { setRevising(false); } }; const renderAuthorFilter = displayItems.length > 0 ? (
{AUTHOR_TYPE_FILTERS.map((mode) => ( ))}
) : null; const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled }); const effectiveAutoMergeLabel = effectiveAutoMerge ? t("taskReview.autoMergeOn", "Auto-merge on") : t("taskReview.autoMergeOff", "Auto-merge off"); return (

{summaryText}

{decisionLabel ? {decisionLabel} : null}
{task.column === "in-review" ? t("taskReview.effectiveFrozen", "Effective: {{label}} — frozen on entry to review", { label: effectiveAutoMergeLabel }) : t("taskReview.effective", "Effective: {{label}}", { label: effectiveAutoMergeLabel })}
{task.column === "in-review" && !task.prInfo && prAuthAvailable === true && !effectiveAutoMerge && typeof onRequestCreatePr === "function" ? ( ) : null} {canAddressPrFeedback ? ( <> {/* FNXC:TaskReviewPrFeedback 2026-06-28-00:00: The Review tab must expose the same gated Address PR feedback action as task cards when PR comments or CHANGES_REQUESTED make feedback actionable. Route the click through the lifecycle API so the ce-resolve-pr-feedback steering prompt is visible in Chat and can wake the assigned agent. */} ) : null}
{/* FNXC:TaskReviewTab 2026-06-27-23:38: PR-linked tasks need Review-tab context that is already present in the GitHub review payload: decision, reviewers, checks, blockers, and per-item author/state/GitHub links. Keep this branch gated to pull-request mode so reviewer-agent reviews retain their established direct-mode layout. */} {isPrMode ? (
{t("taskReview.reviewers", "Reviewers")}
{prSummary?.reviewers?.length ? (
    {prSummary.reviewers.map((reviewer) => (
  • {reviewer.login} {reviewer.state}
  • ))}
) : (
{t("taskReview.noReviewers", "No reviewers reported")}
)}
{t("taskReview.checks", "Checks")}
{summarizeChecks(prSummary?.checks, t)}
{prSummary?.checks?.length ? (
    {prSummary.checks.map((check) => (
  • {check.name} {check.state}
  • ))}
) : null}
{prSummary?.blockingReasons?.length ? (
{t("taskReview.blockingReasons", "Blocking reasons")}
    {prSummary.blockingReasons.map((reason) =>
  • {reason}
  • )}
) : null}
) : null} {loading ?
: null} {!loading && error ?
{error}
: null} {!loading && !error && !isPrMode && displayItems.length === 0 ?
{emptyMessage ?? t("taskReview.noFeedbackDirect", "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")}
: null} {!loading && !error && renderAuthorFilter} {!loading && !error && displayItems.length > 0 && filteredDisplayItems.length > 0 ? (
    {filteredDisplayItems.map((item) => { const checkboxId = `task-review-item-checkbox-${item.id}`; const authorInfo = resolveReviewCommentAuthor(item.author, { reviewSource: review?.source }); const authorType = authorInfo.authorIsBot ? "bot" : "human"; const avatarKey = `${item.id}:${authorInfo.author}`; const showAvatarImg = Boolean(authorInfo.authorAvatarUrl && !brokenAvatars.has(avatarKey)); const prAuthor = isPrMode ? item.item?.author?.login ?? item.addressing?.snapshot?.authorLogin : undefined; const prState = isPrMode ? item.item?.state : undefined; const prUrl = isPrMode ? item.item?.htmlUrl ?? item.addressing?.snapshot?.url : undefined; const summaryPrefix = item.path && !isPrMode ? `${item.path}: ` : ""; return (
  • {/* FNXC:TaskReview 2026-06-27-00:00: Every Review-tab item needs visible author provenance across PR live items, reviewer-agent items, and snapshot-only addressing records. Render a deterministic avatar image only for human GitHub logins; missing authors and bots use generic icons so there is never an empty or broken avatar shell. */}
    {authorInfo.author} {authorInfo.authorIsBot ?
    {isPrMode ? (
    {prAuthor ? {t("taskReview.itemAuthor", "Author: {{author}}", { author: prAuthor })} : null} {prState ? {prState} : null} {formatTimestamp(item.createdAt, t)} {item.path ? {item.path} : null} {prUrl ? ( {t("taskReview.viewOnGitHub", "View on GitHub")} ) : null}
    ) : (
    {formatTimestamp(item.createdAt, t)}
    )} {item.addressing ? (
    {t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })} {item.addressing.startedAt ? t("taskReview.startedAtSep", " · Started: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.startedAt, t) }) : ""} {item.addressing.completedAt ? t("taskReview.completedAtSep", " · Completed: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.completedAt, t) }) : ""} {item.addressing.error ? t("taskReview.errorSep", " · Error: {{message}}", { message: item.addressing.error }) : ""}
    ) : null}
    {renderMarkdown ? ( ) : (
    {linkifyFilePaths(stripHtmlComments(item.body))}
    )}
  • ); })}
) : null} {!loading && !error && displayItems.length > 0 && filteredDisplayItems.length === 0 ?
{t("taskReview.noItemsForFilter", "No review items match the filter.")}
: null} {isPrMode && !loading && !error && displayItems.length === 0 ?
{t("taskReview.noReviewItems", "No review items yet.")}
: null}
); }