From fc958bc54b06598eabf3c3a51581b7ce81170d97 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 23:53:51 -0700 Subject: [PATCH] FN-7178: improve review comment rendering Render task Review tab comments with cleaner content, author context, and filter controls. - Reuse the sanitized mailbox markdown renderer and strip GitHub HTML comments in plain-text mode. - Add human/bot author derivation, avatars, badges, and All/Human/Bot filtering for review items. - Cover rendering, filtering, selection pruning, and author classification with dashboard tests. - Document the Review tab behavior and add the release changeset. Files changed: .../fn-7178-task-review-comment-rendering.md | 7 + docs/dashboard-guide.md | 1 + .../dashboard/app/components/TaskReviewTab.css | 117 ++++++++++++++++ .../dashboard/app/components/TaskReviewTab.tsx | 144 ++++++++++++------- .../components/__tests__/TaskReviewTab.test.tsx | 153 +++++++++++++++++++++ .../utils/__tests__/githubCommentAuthor.test.ts | 46 +++++++ .../dashboard/app/utils/githubCommentAuthor.ts | 33 +++++ packages/dashboard/vitest.config.ts | 1 + 8 files changed, 455 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-7178 Fusion-Task-Lineage: 98269a09-3c57-492d-b433-d6ccb81d5b17 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7178-task-review-comment-rendering.md | 7 + docs/dashboard-guide.md | 1 + .../app/components/TaskReviewTab.css | 117 ++++++++++++++ .../app/components/TaskReviewTab.tsx | 144 +++++++++++------ .../__tests__/TaskReviewTab.test.tsx | 153 ++++++++++++++++++ .../__tests__/githubCommentAuthor.test.ts | 46 ++++++ .../app/utils/githubCommentAuthor.ts | 33 ++++ packages/dashboard/vitest.config.ts | 1 + 8 files changed, 455 insertions(+), 47 deletions(-) create mode 100644 .changeset/fn-7178-task-review-comment-rendering.md create mode 100644 packages/dashboard/app/utils/__tests__/githubCommentAuthor.test.ts create mode 100644 packages/dashboard/app/utils/githubCommentAuthor.ts diff --git a/.changeset/fn-7178-task-review-comment-rendering.md b/.changeset/fn-7178-task-review-comment-rendering.md new file mode 100644 index 0000000000..b5d8e0a472 --- /dev/null +++ b/.changeset/fn-7178-task-review-comment-rendering.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Task detail Review tab now hides HTML comments and shows comment avatars, human/bot badges, and author-type filtering. +category: feature +dev: TaskReviewTab renders bodies via the shared sanitized MailboxMessageContent and a new app/utils/githubCommentAuthor helper for bot/avatar derivation. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 01e548be4d..3b52ac2a84 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1006,6 +1006,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. - The **Artifacts** tab combines task documents written by agents or users with task-scoped registered media artifacts. The gallery uses thumbnail-first image/video cards, image and video previews can expand into a dismissible full-size lightbox, video and audio use native controls, document artifacts show text previews, and generic artifacts open through their media URL. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. +- Review comments hide GitHub template HTML comments in both Markdown and Plain modes, show author avatars or User/Bot fallbacks, label Human vs Bot/agent authors, and include All/Human/Bot filtering. - **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. - Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call). - For shared `branch_groups` (tasks with `branchContext.groupId`), PR merge mode opens and tracks one group-level PR from the group integration branch to the project default branch; member tasks share that PR state. diff --git a/packages/dashboard/app/components/TaskReviewTab.css b/packages/dashboard/app/components/TaskReviewTab.css index 5a90a11a4a..41bb80dd47 100644 --- a/packages/dashboard/app/components/TaskReviewTab.css +++ b/packages/dashboard/app/components/TaskReviewTab.css @@ -102,6 +102,42 @@ background: color-mix(in srgb, var(--surface) 72%, var(--card)); } +/* +FNXC:TaskReview 2026-06-27-00:00: +Review comments need the same All/Human/Bot narrowing affordance as the GitHub import preview. Chips wrap instead of scrolling so mobile task detail users can filter reviewer-agent noise without losing access to revision controls. +*/ +.task-review-tab__comments-filter { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); + min-width: 0; +} + +.task-review-tab__comments-filter-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: calc(var(--space-lg) + var(--space-xs)); + padding: 0 var(--space-sm); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface); + color: var(--text-muted); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; +} + +.task-review-tab__comments-filter-chip:hover, +.task-review-tab__comments-filter-chip:focus-visible, +.task-review-tab__comments-filter-chip.active { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); +} + .task-review-tab__list { display: flex; flex-direction: column; @@ -167,6 +203,79 @@ gap: var(--space-xs); } +/* +FNXC:TaskReview 2026-06-27-00:00: +The Review tab mirrors the Import-from-GitHub comment provenance model: avatar, author, Human/Bot badge, and timestamp sit in a wrapping meta row so desktop task detail and the mobile detail pane both expose comment identity without horizontal overflow. +*/ +.task-review-tab__comment-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + min-width: 0; + color: var(--text-muted); + font-family: var(--font-primary); + font-size: 0.75rem; + line-height: 1.5; +} + +.task-review-tab__comment-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: calc(var(--space-lg) + var(--space-xs)); + height: calc(var(--space-lg) + var(--space-xs)); + border-radius: var(--radius-pill); + border: var(--btn-border-width) solid var(--border); + background: var(--surface); + color: var(--text-muted); + overflow: hidden; +} + +.task-review-tab__comment-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.task-review-tab__comment-author { + color: var(--text); + font-weight: 600; + overflow-wrap: anywhere; +} + +.task-review-tab__comment-type-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + padding: 0 var(--space-xs); + min-height: var(--space-lg); + border-radius: var(--radius-pill); + border: var(--btn-border-width) solid var(--border); + font-size: 0.6875rem; + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.task-review-tab__comment-type-badge--human { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); + border-color: color-mix(in srgb, var(--color-success) 28%, transparent); +} + +.task-review-tab__comment-type-badge--bot { + color: var(--color-info); + background: color-mix(in srgb, var(--color-info) 12%, transparent); + border-color: color-mix(in srgb, var(--color-info) 28%, transparent); +} + +.task-review-tab__comment-time { + color: var(--text-muted); +} + .task-review-tab__body { margin: 0; min-width: 0; @@ -288,6 +397,10 @@ justify-content: center; } + .task-review-tab__comments-filter-chip { + flex: 1 1 0; + } + .task-review-tab__item, .task-review-tab__body, .task-review-tab__refresh-meta, @@ -301,6 +414,10 @@ align-items: stretch; } + .task-review-tab__comment-meta { + align-items: flex-start; + } + .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 6affdbbf83..c3c8abd1b8 100644 --- a/packages/dashboard/app/components/TaskReviewTab.tsx +++ b/packages/dashboard/app/components/TaskReviewTab.tsx @@ -1,17 +1,16 @@ import "./TaskReviewTab.css"; import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { GitPullRequest } from "lucide-react"; +import { Bot, GitPullRequest, User } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { Components } from "react-markdown"; import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api"; import type { SelectedReviewItem } from "../api"; import type { ToastType } from "../hooks/useToast"; -import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; +import { linkifyFilePaths } from "../utils/filePathLinkify"; +import { resolveReviewCommentAuthor } from "../utils/githubCommentAuthor"; import { LoadingSpinner } from "./LoadingSpinner"; +import { MailboxMessageContent } from "./MailboxMessageContent"; interface Props { task: Task | TaskDetail; @@ -24,6 +23,9 @@ interface Props { } 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]; @@ -33,6 +35,7 @@ type DisplayReviewItem = { id: string; summary: string; body: string; + author?: string; path?: string; createdAt?: string; status: "queued" | "in-progress" | "addressed" | "failed"; @@ -60,36 +63,15 @@ function writeBooleanPref(key: string, value: boolean): void { } } -const markdownComponents: Components = { - p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, - li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , - code: ({ children, ...props }) => {linkifyReactChildren(children)}, - pre: ({ children, ...props }) => ( -
    -      {linkifyReactChildren(children)}
    -    
    - ), - table: ({ children, ...props }) => ( - - {children} -
    - ), -}; +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"; @@ -110,6 +92,7 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] { 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", @@ -125,6 +108,7 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] { 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, @@ -152,19 +136,37 @@ export function TaskReviewTab({ 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 canRevise = selected.length > 0 && !revising; const isPrMode = review?.source === "pull-request"; 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; 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]); @@ -269,7 +271,7 @@ export function TaskReviewTab({ if (!review) return; setError(null); setRevising(true); - const selectedItems: SelectedReviewItem[] = displayItems + const selectedItems: SelectedReviewItem[] = filteredDisplayItems .filter((item) => selected.includes(item.id)) .map((item) => { if (!item.item) { @@ -314,6 +316,27 @@ export function TaskReviewTab({ } }; + 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"); @@ -377,13 +400,18 @@ export function TaskReviewTab({ {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 && displayItems.length > 0 ? ( + {!loading && !error && renderAuthorFilter} + {!loading && !error && displayItems.length > 0 && filteredDisplayItems.length > 0 ? (
      - {displayItems.map((item) => { + {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)); 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 ? + +
      -
      {formatTimestamp(item.createdAt, t)}
      {item.addressing ? (
      {t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })} @@ -406,13 +459,9 @@ export function TaskReviewTab({ ) : null}
      {renderMarkdown ? ( -
      - - {item.body} - -
      + ) : ( -
      {linkifyFilePaths(item.body)}
      +
      {linkifyFilePaths(stripHtmlComments(item.body))}
      )}
    • @@ -420,6 +469,7 @@ export function TaskReviewTab({ })}
    ) : 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} ); diff --git a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx index 748ed9d98b..3e4dddafa3 100644 --- a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx @@ -268,6 +268,159 @@ describe("TaskReviewTab", () => { expect(body?.closest("label")).toBeNull(); }); + it("hides GitHub HTML comments and renders PR author avatars, badges, and filters", async () => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] }, + items: [ + { + id: "human-comment", + body: "Real human feedback\n", + author: { login: "octocat" }, + createdAt: "2026-06-27T00:00:00.000Z", + summary: "Human feedback", + }, + { + id: "bot-comment", + body: "Automated feedback\n", + author: { login: "coderabbitai[bot]" }, + createdAt: "2026-06-27T00:01:00.000Z", + summary: "Bot feedback", + }, + ], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + const { container } = await renderWithAct(); + + expect(await screen.findByText("Real human feedback")).toBeInTheDocument(); + expect(screen.queryByText(/hidden template/)).not.toBeInTheDocument(); + expect(screen.getByAltText("octocat avatar")).toHaveAttribute("src", "https://github.com/octocat.png?size=40"); + expect(screen.getByText("octocat")).toBeInTheDocument(); + expect(screen.getByText("coderabbitai[bot]")).toBeInTheDocument(); + expect(container.querySelectorAll('[data-review-comment-author-type="human"]')).toHaveLength(2); + expect(container.querySelectorAll('[data-review-comment-author-type="bot"]')).toHaveLength(2); + expect(container.querySelectorAll(".task-review-tab__comment-avatar-img")).toHaveLength(1); + expect(container.querySelectorAll(".task-review-tab__comment-avatar svg")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Bot" })); + expect(screen.queryByText("Human feedback")).not.toBeInTheDocument(); + expect(screen.getByText("Bot feedback")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + fireEvent.click(screen.getByTestId("task-review-markdown-toggle")); + expect(await screen.findByText(/Real human feedback/)).toBeInTheDocument(); + expect(screen.queryByText(/hidden template/)).not.toBeInTheDocument(); + }); + + it("surfaces reviewer-agent live and snapshot authors with safe fallback avatars", async () => { + const task = makeTask({ + reviewState: { + source: "reviewer-agent", + summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" }, + items: [ + { + id: "agent-missing-author", + body: "Agent feedback without login\n", + author: undefined, + createdAt: "2026-06-27T00:02:00.000Z", + summary: "Missing author feedback", + } as never, + { + id: "agent-login-author", + body: "Agent feedback with reviewer login", + author: { login: "reviewer-agent" }, + createdAt: "2026-06-27T00:02:30.000Z", + summary: "Reviewer-agent login feedback", + }, + ], + addressing: [ + { + itemId: "snapshot-human", + status: "queued", + selectedAt: "2026-06-27T00:03:00.000Z", + snapshot: { + itemId: "snapshot-human", + sourceMode: "reviewer-agent", + source: "reviewer-agent", + authorLogin: "snapshot-user", + summary: "Snapshot human feedback", + body: "Snapshot body", + }, + }, + { + itemId: "snapshot-bot", + status: "queued", + selectedAt: "2026-06-27T00:04:00.000Z", + snapshot: { + itemId: "snapshot-bot", + sourceMode: "reviewer-agent", + source: "reviewer-agent", + authorLogin: "reviewer-agent[bot]", + summary: "Snapshot bot feedback", + body: "Snapshot bot body", + }, + }, + ], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + const { container } = await renderWithAct(); + + expect(await screen.findByText("Missing author feedback")).toBeInTheDocument(); + expect(screen.getByText("unknown")).toBeInTheDocument(); + expect(screen.getByText("reviewer-agent")).toBeInTheDocument(); + expect(screen.getByText("snapshot-user")).toBeInTheDocument(); + expect(screen.getByText("reviewer-agent[bot]")).toBeInTheDocument(); + expect(screen.getByAltText("snapshot-user avatar")).toHaveAttribute("src", "https://github.com/snapshot-user.png?size=40"); + expect(container.querySelectorAll(".task-review-tab__comment-avatar-img")).toHaveLength(1); + expect(container.querySelectorAll(".task-review-tab__comment-avatar svg")).toHaveLength(3); + + fireEvent.click(screen.getByRole("button", { name: "Human" })); + expect(screen.queryByText("Missing author feedback")).not.toBeInTheDocument(); + expect(screen.queryByText("Reviewer-agent login feedback")).not.toBeInTheDocument(); + expect(screen.getByText("Snapshot human feedback")).toBeInTheDocument(); + expect(screen.queryByText("Snapshot bot feedback")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Bot" })); + expect(screen.getByText("Missing author feedback")).toBeInTheDocument(); + expect(screen.getByText("Reviewer-agent login feedback")).toBeInTheDocument(); + expect(screen.getByText("Snapshot bot feedback")).toBeInTheDocument(); + expect(screen.queryByText("Snapshot human feedback")).not.toBeInTheDocument(); + }); + + it("prunes hidden selections when filtering before requesting revision", async () => { + const task = makeTask({ + reviewState: { + source: "pull-request", + summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [], blockingReasons: [], checks: [] }, + items: [ + { id: "human-selected", body: "Human body", author: { login: "octocat" }, createdAt: new Date().toISOString(), summary: "Human selected" }, + { id: "bot-selected", body: "Bot body", author: { login: "renovate[bot]" }, createdAt: new Date().toISOString(), summary: "Bot selected" }, + ], + addressing: [], + }, + }); + + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); + apiMocks.reviseTaskReviewItems.mockResolvedValue({ task, reviewState: task.reviewState }); + await renderWithAct(); + + const checkboxes = await screen.findAllByRole("checkbox"); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + fireEvent.click(screen.getByRole("button", { name: "Bot" })); + await waitFor(() => expect(screen.queryByText("Human selected")).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Request revision" })); + await waitFor(() => expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalled()); + expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, [expect.objectContaining({ id: "bot-selected" })], undefined); + }); + it("renders markdown by default and persists plain-text toggle preference", async () => { const task = makeTask({ reviewState: { diff --git a/packages/dashboard/app/utils/__tests__/githubCommentAuthor.test.ts b/packages/dashboard/app/utils/__tests__/githubCommentAuthor.test.ts new file mode 100644 index 0000000000..c091488372 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/githubCommentAuthor.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveReviewCommentAuthor } from "../githubCommentAuthor"; + +describe("resolveReviewCommentAuthor", () => { + it("classifies a human login and derives a GitHub avatar URL", () => { + expect(resolveReviewCommentAuthor("octocat")).toEqual({ + author: "octocat", + authorIsBot: false, + authorAvatarUrl: "https://github.com/octocat.png?size=40", + }); + }); + + it("classifies bracket-suffixed bot logins without deriving an avatar", () => { + expect(resolveReviewCommentAuthor("coderabbitai[bot]")).toEqual({ + author: "coderabbitai[bot]", + authorIsBot: true, + authorAvatarUrl: undefined, + }); + }); + + it("treats missing or empty pull-request logins as unknown without an avatar", () => { + expect(resolveReviewCommentAuthor()).toEqual({ + author: "unknown", + authorIsBot: false, + authorAvatarUrl: undefined, + }); + expect(resolveReviewCommentAuthor(" ")).toEqual({ + author: "unknown", + authorIsBot: false, + authorAvatarUrl: undefined, + }); + }); + + it("classifies reviewer-agent identities and missing direct-mode authors as agents", () => { + expect(resolveReviewCommentAuthor("reviewer-agent")).toEqual({ + author: "reviewer-agent", + authorIsBot: true, + authorAvatarUrl: undefined, + }); + expect(resolveReviewCommentAuthor(undefined, { reviewSource: "reviewer-agent" })).toEqual({ + author: "unknown", + authorIsBot: true, + authorAvatarUrl: undefined, + }); + }); +}); diff --git a/packages/dashboard/app/utils/githubCommentAuthor.ts b/packages/dashboard/app/utils/githubCommentAuthor.ts new file mode 100644 index 0000000000..debd3b6a8e --- /dev/null +++ b/packages/dashboard/app/utils/githubCommentAuthor.ts @@ -0,0 +1,33 @@ +export interface ReviewCommentAuthorResolution { + author: string; + authorIsBot: boolean; + authorAvatarUrl?: string; +} + +export interface ResolveReviewCommentAuthorOptions { + reviewSource?: "pull-request" | "reviewer-agent"; +} + +const KNOWN_AGENT_LOGINS = new Set(["agent", "reviewer-agent", "fusion-agent", "fusion-reviewer", "executor-agent", "triage-agent", "merger-agent"]); + +/* +FNXC:TaskReview 2026-06-27-00:00: +Task-detail Review comments only receive a GitHub login from the review backend, so the UI derives the same author shape as the GitHub import preview: `[bot]` suffixes are agents, missing logins render as `unknown`, and human logins get GitHub's deterministic PNG avatar URL. +Bot avatars are intentionally suppressed because synthetic `[bot]` logins often do not resolve to a real avatar; the Review tab renders a generic Bot icon instead of a broken image. + +FNXC:TaskReview 2026-06-27-00:00: +Direct reviewer-agent feedback can arrive as `author.login: "reviewer-agent"` or without a login at all. Treat those known reviewer identities as agents so badges, fallback avatars, and Human/Bot filtering do not mislabel AI reviewer feedback as a human GitHub author. +*/ +export function resolveReviewCommentAuthor(login?: string | null, options: ResolveReviewCommentAuthorOptions = {}): ReviewCommentAuthorResolution { + const trimmedLogin = login?.trim() ?? ""; + const author = trimmedLogin || "unknown"; + const normalizedAuthor = author.toLowerCase(); + const authorIsBot = /\[bot\]$/i.test(author) + || KNOWN_AGENT_LOGINS.has(normalizedAuthor) + || (options.reviewSource === "reviewer-agent" && author === "unknown"); + const authorAvatarUrl = !authorIsBot && author !== "unknown" + ? `https://github.com/${encodeURIComponent(author)}.png?size=40` + : undefined; + + return { author, authorIsBot, authorAvatarUrl }; +} diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 8fd70269c4..515097d7a2 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -195,6 +195,7 @@ const qualityAppComponentTests = [ "TaskDetailModal.github-tracking-stale", "TaskDocumentsTab", "TaskFieldsSection", + "TaskReviewTab", "TaskForm", "TaskIdIntegrityBanner", "TrackingRepoSelect",