diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8e01b94f70..6c6275ad37 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6435,24 +6435,47 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise ({ + id: item.itemId, + body: item.body, + author: { login: item.author }, + createdAt: item.createdAt ?? new Date(0).toISOString(), + updatedAt: item.updatedAt ?? undefined, + path: item.filePath, + threadId: item.threadId, + htmlUrl: item.url, + state: item.reviewState ?? undefined, + summary: item.title ?? undefined, + isResolved: item.isResolved, + ...(typeof item.line === "number" ? { line: item.line } : {}), + })); + return { reviewState: { source: data.mode, summary: data.summary ?? undefined, - items: data.items.map((item) => ({ - id: item.itemId, - body: item.body, - author: { login: item.author }, - createdAt: item.createdAt ?? new Date(0).toISOString(), - updatedAt: item.updatedAt ?? undefined, - path: item.filePath, - threadId: item.threadId, - htmlUrl: item.url, - state: item.reviewState ?? undefined, - isResolved: item.isResolved, - })), - addressing: [], - lastRefreshedAt: data.fetchedAt ?? undefined, + items: canonicalItems, + addressing: data.items + .filter((item) => item.progressStatus != null) + .map((item) => ({ + itemId: item.itemId, + status: item.progressStatus ?? "queued", + selectedAt: item.createdAt ?? fetchedAt ?? new Date(0).toISOString(), + snapshot: { + itemId: item.itemId, + sourceMode: item.sourceMode, + source: item.sourceMode === "pull-request" ? "pr-review" : "reviewer-agent", + summary: item.title || item.body.slice(0, 120), + body: item.body, + authorLogin: item.author, + filePath: item.filePath, + lineNumber: item.line, + threadId: item.threadId, + url: item.url, + }, + })), + lastRefreshedAt: fetchedAt, refreshStatus: "ready", refreshSource: "initial-load", }, diff --git a/packages/dashboard/app/components/TaskReviewTab.css b/packages/dashboard/app/components/TaskReviewTab.css index 41bb80dd47..71ed0b4e24 100644 --- a/packages/dashboard/app/components/TaskReviewTab.css +++ b/packages/dashboard/app/components/TaskReviewTab.css @@ -345,6 +345,92 @@ The Review tab mirrors the Import-from-GitHub comment provenance model: avatar, margin-top: var(--space-xs); } +/* +FNXC:TaskReviewTab 2026-06-27-23:38: +PR-linked tasks must expose decision-adjacent reviewers/checks/blockers plus item author/state/GitHub links without overflowing the task-detail modal. These scoped flex rows wrap using design tokens so desktop and mobile Review tabs preserve the existing hierarchy. +*/ +.task-review-tab__pr-summary { + display: flex; + flex-direction: column; + gap: var(--space-md); + min-width: 0; + padding: var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--surface) 72%, var(--card)); +} + +.task-review-tab__pr-summary-section { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.task-review-tab__pr-summary-label { + color: var(--text); + font-family: var(--font-primary); + font-size: 0.75rem; + font-weight: 700; + line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.task-review-tab__pill-list, +.task-review-tab__pr-item-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.task-review-tab__pill-list { + margin: 0; + padding: 0; + list-style: none; +} + +.task-review-tab__pill-list-item { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + max-width: 100%; +} + +.task-review-tab__reviewer-login, +.task-review-tab__check-name { + min-width: 0; + max-width: 100%; + color: var(--text); + font-family: var(--font-primary); + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.task-review-tab__github-link { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + color: var(--color-primary); + font-family: var(--font-primary); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.task-review-tab__github-link svg { + width: 1em; + height: 1em; + flex-shrink: 0; +} + .task-review-tab__empty { overflow-wrap: anywhere; } @@ -418,6 +504,21 @@ The Review tab mirrors the Import-from-GitHub comment provenance model: avatar, align-items: flex-start; } + .task-review-tab__pr-summary, + .task-review-tab__pr-item-meta { + align-items: stretch; + } + + .task-review-tab__pill-list, + .task-review-tab__pill-list-item, + .task-review-tab__pr-item-meta { + width: 100%; + } + + .task-review-tab__github-link { + max-width: 100%; + } + .task-review-tab__status, .task-review-tab__decision { max-width: 100%; diff --git a/packages/dashboard/app/components/TaskReviewTab.tsx b/packages/dashboard/app/components/TaskReviewTab.tsx index c3c8abd1b8..9de5b1f2e6 100644 --- a/packages/dashboard/app/components/TaskReviewTab.tsx +++ b/packages/dashboard/app/components/TaskReviewTab.tsx @@ -1,7 +1,7 @@ import "./TaskReviewTab.css"; -import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core"; +import { getErrorMessage, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { Bot, GitPullRequest, User } from "lucide-react"; +import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api"; @@ -84,6 +84,26 @@ function formatRefreshSource(source?: "manual" | "auto" | "initial-load", t?: (k 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) => { @@ -144,6 +164,7 @@ export function TaskReviewTab({ const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = 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; @@ -298,7 +319,7 @@ export function TaskReviewTab({ 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, + url: item.item.htmlUrl ?? (typeof itemRecord.url === "string" ? itemRecord.url : undefined), }; }); @@ -397,6 +418,51 @@ export function TaskReviewTab({ source: formatRefreshSource(review?.refreshSource, t), })} + {/* + 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} @@ -409,6 +475,10 @@ export function TaskReviewTab({ 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 (
  • @@ -417,7 +487,7 @@ export function TaskReviewTab({
    toggleSelected(item.id)} /> - {item.path ? `${item.path}: ` : ""}{item.summary} + {summaryPrefix}{item.summary}
    {item.status}
    @@ -449,6 +519,22 @@ export function TaskReviewTab({
    + {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) })} diff --git a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx index 3e4dddafa3..1a4d5de847 100644 --- a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx @@ -3,7 +3,7 @@ FNXC:DashboardTests 2026-06-14-08:31: FN-6441 rescued this orphaned component test after standalone dashboard-app execution passed without assertion, timeout, or source-code changes. Keep it registered through the app backfill lane so task-review UI regressions cannot silently fall out of quality coverage again. */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { act, render as rtlRender, screen, fireEvent, waitFor } from "@testing-library/react"; +import { act, render as rtlRender, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { TaskReviewTab } from "../TaskReviewTab"; import { makeTask } from "./TaskDetailModal.test-helpers"; import { loadAllAppCss } from "../../test/cssFixture"; @@ -47,6 +47,7 @@ describe("TaskReviewTab", () => { await renderWithAct(); expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeInTheDocument(); + expect(screen.queryByLabelText("Pull request review summary")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled(); }); @@ -67,7 +68,7 @@ describe("TaskReviewTab", () => { await renderWithAct(); fireEvent.click(await screen.findByRole("button", { name: "Refresh" })); expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined); - expect(await screen.findByText("APPROVED")).toBeInTheDocument(); + expect((await screen.findAllByText("APPROVED")).length).toBeGreaterThan(0); expect(screen.getAllByText("Looks good").length).toBeGreaterThan(0); expect(addToast).toHaveBeenCalledWith("Review refreshed", "success"); }); @@ -127,6 +128,139 @@ describe("TaskReviewTab", () => { await renderWithAct(); expect(await screen.findByText("No review items yet.")).toBeInTheDocument(); + expect(screen.getByText("No reviewers reported")).toBeInTheDocument(); + expect(screen.getByText("No checks reported")).toBeInTheDocument(); + }); + + it("renders PR decision, reviewers, checks, blockers, and per-item GitHub metadata", async () => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { + reviewDecision: "CHANGES_REQUESTED", + reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED", submittedAt: "2026-06-27T12:00:00.000Z" }], + blockingReasons: ["1 required review requesting changes"], + checks: [ + { name: "lint", required: true, state: "success" }, + { name: "test", required: true, state: "failure", detailsUrl: "https://github.example/checks/test" }, + ], + }, + items: [ + { + id: "review-1", + body: "Please fix the failing branch.", + author: { login: "octocat" }, + createdAt: "2026-06-27T12:00:00.000Z", + path: "src/parser.ts", + summary: "Review CHANGES_REQUESTED", + state: "CHANGES_REQUESTED", + htmlUrl: "https://github.example/reviews/1", + }, + { + id: "comment-1", + body: "Nit: rename this variable.", + author: { login: "hubot" }, + createdAt: "2026-06-27T12:05:00.000Z", + summary: "PR comment", + state: "COMMENTED", + htmlUrl: "https://github.example/comments/1", + }, + ], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + const { container } = await renderWithAct(); + + expect(await screen.findByText("CHANGES_REQUESTED · 2 review item(s)")).toBeInTheDocument(); + const summary = screen.getByLabelText("Pull request review summary"); + expect(within(summary).getByText("octocat")).toBeInTheDocument(); + expect(within(summary).getByText("1/2 checks passing · 2 required · 1 blocking")).toBeInTheDocument(); + expect(within(summary).getByText("lint")).toBeInTheDocument(); + expect(within(summary).getByText("test")).toBeInTheDocument(); + expect(within(summary).getByText("1 required review requesting changes")).toBeInTheDocument(); + expect(screen.getByText("Author: octocat")).toBeInTheDocument(); + expect(screen.getByText("Author: hubot")).toBeInTheDocument(); + expect(screen.getByText("src/parser.ts")).toBeInTheDocument(); + expect(screen.getAllByText("COMMENTED").length).toBeGreaterThan(0); + const githubLinks = screen.getAllByRole("link", { name: /View on GitHub/ }); + expect(githubLinks.map((link) => link.getAttribute("href"))).toEqual(["https://github.example/reviews/1", "https://github.example/comments/1"]); + expect(githubLinks[0]).toHaveAttribute("target", "_blank"); + expect(githubLinks[0]).toHaveAttribute("rel", "noopener noreferrer"); + expect(container.querySelectorAll(".task-review-tab__summary-group .task-review-tab__decision")).toHaveLength(1); + }); + + it.each([ + ["APPROVED", "APPROVED · 0 review item(s)", true], + ["CHANGES_REQUESTED", "CHANGES_REQUESTED · 0 review item(s)", true], + ["REVIEW_REQUIRED", "REVIEW_REQUIRED · 0 review item(s)", true], + [null, "REVIEW_REQUIRED · 0 review item(s)", false], + ] as const)("renders PR decision state %s without undefined badges", async (decision, summaryText, hasDecisionBadge) => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { reviewDecision: decision, reviewers: [], blockingReasons: [], checks: [] }, + items: [], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + const { container } = await renderWithAct(); + + expect(await screen.findByText(summaryText)).toBeInTheDocument(); + expect(screen.queryByText("undefined")).not.toBeInTheDocument(); + expect(container.querySelectorAll(".task-review-tab__summary-group .task-review-tab__decision")).toHaveLength(hasDecisionBadge ? 1 : 0); + }); + + it("renders PR items when optional path, state, URL, or body are missing", async () => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, + items: [ + { + id: "comment-with-empty-body", + body: "", + author: { login: "code-review-bot" }, + createdAt: "2026-06-27T12:10:00.000Z", + summary: "PR comment by code-review-bot", + }, + ], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + await renderWithAct(); + + expect(await screen.findByText("PR comment by code-review-bot")).toBeInTheDocument(); + expect(screen.getByText("Author: code-review-bot")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /View on GitHub/ })).not.toBeInTheDocument(); + expect(screen.queryByText("undefined")).not.toBeInTheDocument(); + }); + + it("toggles markdown/plain rendering for PR items without affecting selection", async () => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { reviewDecision: "APPROVED", reviewers: [], blockingReasons: [], checks: [] }, + items: [{ id: "markdown-pr-item", body: "**bold PR body**", author: { login: "octocat" }, createdAt: new Date().toISOString(), summary: "PR markdown item", state: "APPROVED" }], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + const { container } = await renderWithAct(); + + await screen.findByText("PR markdown item"); + expect(container.querySelector("strong")?.textContent).toBe("bold PR body"); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(screen.getByTestId("task-review-markdown-toggle")); + await waitFor(() => expect(container.querySelector("pre.task-review-tab__body")?.textContent).toContain("**bold PR body**")); + fireEvent.click(container.querySelector("pre.task-review-tab__body") as HTMLElement); + expect(checkbox).not.toBeChecked(); }); it("shows load error when initial review fetch fails", async () => { @@ -475,7 +609,7 @@ describe("TaskReviewTab", () => { summary: "Parser guard is missing", threadId: "thread-1", line: 42, - url: "https://example.test/thread/1", + htmlUrl: "https://example.test/thread/1", }, ], addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }], @@ -689,6 +823,9 @@ describe("TaskReviewTab", () => { expect(baseBodyRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/); expect(baseBodyRule).toMatch(/overflow-x\s*:\s*auto\s*;/); expect(taskReviewCss).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/); + expect(taskReviewCss).toMatch(/\.task-review-tab__pr-summary\s*\{[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*\}/); + expect(taskReviewCss).toMatch(/\.task-review-tab__pr-item-meta\s*\{[^}]*flex-wrap\s*:\s*wrap\s*;[^}]*\}/); + expect(mobileCss).toMatch(/\.task-review-tab__pill-list,\s*\.task-review-tab__pill-list-item,\s*\.task-review-tab__pr-item-meta\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/); }); it("preserves review header structure across sources and empty or populated states", async () => {