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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 23:53:51 -07:00
parent 5b71bdb284
commit fc958bc54b
8 changed files with 455 additions and 47 deletions

View File

@@ -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.

View File

@@ -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. - 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 **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. - 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. - **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). - 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. - 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.

View File

@@ -102,6 +102,42 @@
background: color-mix(in srgb, var(--surface) 72%, var(--card)); 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 { .task-review-tab__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -167,6 +203,79 @@
gap: var(--space-xs); 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 { .task-review-tab__body {
margin: 0; margin: 0;
min-width: 0; min-width: 0;
@@ -288,6 +397,10 @@
justify-content: center; justify-content: center;
} }
.task-review-tab__comments-filter-chip {
flex: 1 1 0;
}
.task-review-tab__item, .task-review-tab__item,
.task-review-tab__body, .task-review-tab__body,
.task-review-tab__refresh-meta, .task-review-tab__refresh-meta,
@@ -301,6 +414,10 @@
align-items: stretch; align-items: stretch;
} }
.task-review-tab__comment-meta {
align-items: flex-start;
}
.task-review-tab__status, .task-review-tab__status,
.task-review-tab__decision { .task-review-tab__decision {
max-width: 100%; max-width: 100%;

View File

@@ -1,17 +1,16 @@
import "./TaskReviewTab.css"; import "./TaskReviewTab.css";
import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core"; import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; 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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api";
import type { SelectedReviewItem } from "../api"; import type { SelectedReviewItem } from "../api";
import type { ToastType } from "../hooks/useToast"; 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 { LoadingSpinner } from "./LoadingSpinner";
import { MailboxMessageContent } from "./MailboxMessageContent";
interface Props { interface Props {
task: Task | TaskDetail; task: Task | TaskDetail;
@@ -24,6 +23,9 @@ interface Props {
} }
const REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY = "fn-task-review-markdown"; 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<TaskDetail["reviewState"]>; type ReviewState = NonNullable<TaskDetail["reviewState"]>;
type ReviewItem = ReviewState["items"][number]; type ReviewItem = ReviewState["items"][number];
@@ -33,6 +35,7 @@ type DisplayReviewItem = {
id: string; id: string;
summary: string; summary: string;
body: string; body: string;
author?: string;
path?: string; path?: string;
createdAt?: string; createdAt?: string;
status: "queued" | "in-progress" | "addressed" | "failed"; status: "queued" | "in-progress" | "addressed" | "failed";
@@ -60,36 +63,15 @@ function writeBooleanPref(key: string, value: boolean): void {
} }
} }
const markdownComponents: Components = { const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>, /*
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>, FNXC:TaskReview 2026-06-27-00:00:
pre: ({ children, ...props }) => ( 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.
<pre */
{...props} function stripHtmlComments(value: string): string {
style={{ return value.replace(HTML_COMMENT_PATTERN, "").trim();
overflowX: "auto", }
maxWidth: "100%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{linkifyReactChildren(children)}
</pre>
),
table: ({ children, ...props }) => (
<table
{...props}
style={{
display: "block",
overflowX: "auto",
maxWidth: "100%",
}}
>
{children}
</table>
),
};
function formatTimestamp(value?: string, t?: (key: string, defaultValue: string) => string): string { function formatTimestamp(value?: string, t?: (key: string, defaultValue: string) => string): string {
if (!value) return t?.("taskReview.never", "Never") ?? "Never"; if (!value) return t?.("taskReview.never", "Never") ?? "Never";
@@ -110,6 +92,7 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
id: item.id, id: item.id,
summary: item.summary ?? item.body.slice(0, 120), summary: item.summary ?? item.body.slice(0, 120),
body: item.body, body: item.body,
author: item.author?.login,
path: item.path, path: item.path,
createdAt: item.createdAt, createdAt: item.createdAt,
status: addressing?.status ?? "queued", status: addressing?.status ?? "queued",
@@ -125,6 +108,7 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
id: record.itemId, id: record.itemId,
summary: record.snapshot?.summary ?? record.itemId, summary: record.snapshot?.summary ?? record.itemId,
body: record.snapshot?.body ?? record.snapshot?.summary ?? record.itemId, body: record.snapshot?.body ?? record.snapshot?.summary ?? record.itemId,
author: record.snapshot?.authorLogin,
path: record.snapshot?.filePath, path: record.snapshot?.filePath,
createdAt: record.selectedAt, createdAt: record.selectedAt,
status: record.status, status: record.status,
@@ -152,19 +136,37 @@ export function TaskReviewTab({
const [emptyMessage, setEmptyMessage] = useState<string | null>(null); const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
const [review, setReview] = useState(task.reviewState ?? null); const [review, setReview] = useState(task.reviewState ?? null);
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(() => readBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, true)); const [renderMarkdown, setRenderMarkdown] = useState<boolean>(() => readBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, true));
const [authorTypeFilter, setAuthorTypeFilter] = useState<AuthorTypeFilter>("all");
const [brokenAvatars, setBrokenAvatars] = useState<Set<string>>(new Set());
const [autoMergePreference, setAutoMergePreference] = useState<"follow-default" | "on" | "off">( const [autoMergePreference, setAutoMergePreference] = useState<"follow-default" | "on" | "off">(
task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default", task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default",
); );
const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false); const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false);
const canRevise = selected.length > 0 && !revising;
const isPrMode = review?.source === "pull-request"; const isPrMode = review?.source === "pull-request";
const displayItems = useMemo(() => (review ? getDisplayReviewItems(review) : []), [review]); 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(() => { useEffect(() => {
writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
}, [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(() => { useEffect(() => {
setAutoMergePreference(task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default"); setAutoMergePreference(task.autoMerge === true ? "on" : task.autoMerge === false ? "off" : "follow-default");
}, [task.autoMerge]); }, [task.autoMerge]);
@@ -269,7 +271,7 @@ export function TaskReviewTab({
if (!review) return; if (!review) return;
setError(null); setError(null);
setRevising(true); setRevising(true);
const selectedItems: SelectedReviewItem[] = displayItems const selectedItems: SelectedReviewItem[] = filteredDisplayItems
.filter((item) => selected.includes(item.id)) .filter((item) => selected.includes(item.id))
.map((item) => { .map((item) => {
if (!item.item) { if (!item.item) {
@@ -314,6 +316,27 @@ export function TaskReviewTab({
} }
}; };
const renderAuthorFilter = displayItems.length > 0 ? (
<div className="task-review-tab__comments-filter" data-testid="task-review-comments-filter" role="group" aria-label={t("taskReview.filterCommentsAriaLabel", "Filter review comments by author type")}>
{AUTHOR_TYPE_FILTERS.map((mode) => (
<button
key={mode}
type="button"
className={`task-review-tab__comments-filter-chip ${authorTypeFilter === mode ? "active" : ""}`}
aria-pressed={authorTypeFilter === mode}
data-filter={mode}
onClick={() => setAuthorTypeFilter(mode)}
>
{mode === "all"
? t("taskReview.filterAll", "All")
: mode === "human"
? t("taskReview.filterHuman", "Human")
: t("taskReview.filterBot", "Bot")}
</button>
))}
</div>
) : null;
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled }); const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
const effectiveAutoMergeLabel = effectiveAutoMerge ? t("taskReview.autoMergeOn", "Auto-merge on") : t("taskReview.autoMergeOff", "Auto-merge off"); const effectiveAutoMergeLabel = effectiveAutoMerge ? t("taskReview.autoMergeOn", "Auto-merge on") : t("taskReview.autoMergeOff", "Auto-merge off");
@@ -377,13 +400,18 @@ export function TaskReviewTab({
{loading ? <div className="task-review-tab__meta"><LoadingSpinner label={t("taskReview.loadingData", "Loading review data…")} /></div> : null} {loading ? <div className="task-review-tab__meta"><LoadingSpinner label={t("taskReview.loadingData", "Loading review data…")} /></div> : null}
{!loading && error ? <div className="task-review-tab__error">{error}</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 ?? t("taskReview.noFeedbackDirect", "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")}</div> : null} {!loading && !error && !isPrMode && displayItems.length === 0 ? <div className="task-review-tab__empty">{emptyMessage ?? t("taskReview.noFeedbackDirect", "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")}</div> : null}
{!loading && !error && displayItems.length > 0 ? ( {!loading && !error && renderAuthorFilter}
{!loading && !error && displayItems.length > 0 && filteredDisplayItems.length > 0 ? (
<ul className="task-review-tab__list"> <ul className="task-review-tab__list">
{displayItems.map((item) => { {filteredDisplayItems.map((item) => {
const checkboxId = `task-review-item-checkbox-${item.id}`; 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 ( return (
<li key={item.id} className="task-review-tab__item card"> <li key={item.id} className="task-review-tab__item card" data-review-comment-author-type={authorType}>
<div className="task-review-tab__item-inner"> <div className="task-review-tab__item-inner">
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable"> <label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
<div className="task-review-tab__item-header"> <div className="task-review-tab__item-header">
@@ -394,8 +422,33 @@ export function TaskReviewTab({
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span> <span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
</div> </div>
</label> </label>
{/*
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.
*/}
<div className="task-review-tab__comment-meta">
<span className="task-review-tab__comment-avatar" aria-hidden="true" data-testid="task-review-comment-avatar">
{showAvatarImg ? (
<img
src={authorInfo.authorAvatarUrl}
alt={t("taskReview.avatarAlt", "{{author}} avatar", { author: authorInfo.author })}
className="task-review-tab__comment-avatar-img"
onError={() => setBrokenAvatars((prev) => new Set(prev).add(avatarKey))}
/>
) : authorInfo.authorIsBot ? (
<Bot size={16} aria-hidden="true" />
) : (
<User size={16} aria-hidden="true" />
)}
</span>
<span className="task-review-tab__comment-author">{authorInfo.author}</span>
<span className={`task-review-tab__comment-type-badge task-review-tab__comment-type-badge--${authorType}`} data-review-comment-author-type={authorType}>
{authorInfo.authorIsBot ? <Bot size={11} aria-hidden="true" /> : <User size={11} aria-hidden="true" />}
<span>{authorInfo.authorIsBot ? t("taskReview.bot", "Bot") : t("taskReview.human", "Human")}</span>
</span>
<time className="task-review-tab__comment-time" dateTime={item.createdAt} title={item.createdAt}>{formatTimestamp(item.createdAt, t)}</time>
</div>
<div className="task-review-tab__item-meta-list"> <div className="task-review-tab__item-meta-list">
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt, t)}</div>
{item.addressing ? ( {item.addressing ? (
<div className="task-review-tab__meta"> <div className="task-review-tab__meta">
{t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })} {t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })}
@@ -406,13 +459,9 @@ export function TaskReviewTab({
) : null} ) : null}
</div> </div>
{renderMarkdown ? ( {renderMarkdown ? (
<div className="task-review-tab__body markdown-body"> <MailboxMessageContent className="task-review-tab__body markdown-body" content={item.body} testId="task-review-comment-body" />
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{item.body}
</ReactMarkdown>
</div>
) : ( ) : (
<pre className="task-review-tab__body">{linkifyFilePaths(item.body)}</pre> <pre className="task-review-tab__body" data-testid="task-review-comment-body">{linkifyFilePaths(stripHtmlComments(item.body))}</pre>
)} )}
</div> </div>
</li> </li>
@@ -420,6 +469,7 @@ export function TaskReviewTab({
})} })}
</ul> </ul>
) : null} ) : null}
{!loading && !error && displayItems.length > 0 && filteredDisplayItems.length === 0 ? <div className="task-review-tab__empty">{t("taskReview.noItemsForFilter", "No review items match the filter.")}</div> : null}
{isPrMode && !loading && !error && displayItems.length === 0 ? <div className="task-review-tab__empty">{t("taskReview.noReviewItems", "No review items yet.")}</div> : null} {isPrMode && !loading && !error && displayItems.length === 0 ? <div className="task-review-tab__empty">{t("taskReview.noReviewItems", "No review items yet.")}</div> : null}
</div> </div>
); );

View File

@@ -268,6 +268,159 @@ describe("TaskReviewTab", () => {
expect(body?.closest("label")).toBeNull(); 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<!-- hidden template -->",
author: { login: "octocat" },
createdAt: "2026-06-27T00:00:00.000Z",
summary: "Human feedback",
},
{
id: "bot-comment",
body: "Automated feedback\n<!-- bot hidden template -->",
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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<!-- agent template -->",
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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 () => { it("renders markdown by default and persists plain-text toggle preference", async () => {
const task = makeTask({ const task = makeTask({
reviewState: { reviewState: {

View File

@@ -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,
});
});
});

View File

@@ -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 };
}

View File

@@ -195,6 +195,7 @@ const qualityAppComponentTests = [
"TaskDetailModal.github-tracking-stale", "TaskDetailModal.github-tracking-stale",
"TaskDocumentsTab", "TaskDocumentsTab",
"TaskFieldsSection", "TaskFieldsSection",
"TaskReviewTab",
"TaskForm", "TaskForm",
"TaskIdIntegrityBanner", "TaskIdIntegrityBanner",
"TrackingRepoSelect", "TrackingRepoSelect",