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:
7
.changeset/fn-7178-task-review-comment-rendering.md
Normal file
7
.changeset/fn-7178-task-review-comment-rendering.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<TaskDetail["reviewState"]>;
|
||||
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 }) => <p {...props}>{linkifyReactChildren(children)}</p>,
|
||||
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
|
||||
code: ({ children, ...props }) => <code {...props}>{linkifyReactChildren(children)}</code>,
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
style={{
|
||||
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>
|
||||
),
|
||||
};
|
||||
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/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<string | null>(null);
|
||||
const [review, setReview] = useState(task.reviewState ?? null);
|
||||
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">(
|
||||
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 ? (
|
||||
<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 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 && 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 && displayItems.length > 0 ? (
|
||||
{!loading && !error && renderAuthorFilter}
|
||||
{!loading && !error && displayItems.length > 0 && filteredDisplayItems.length > 0 ? (
|
||||
<ul className="task-review-tab__list">
|
||||
{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 (
|
||||
<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">
|
||||
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
|
||||
<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>
|
||||
</div>
|
||||
</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__meta">{formatTimestamp(item.createdAt, t)}</div>
|
||||
{item.addressing ? (
|
||||
<div className="task-review-tab__meta">
|
||||
{t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })}
|
||||
@@ -406,13 +459,9 @@ export function TaskReviewTab({
|
||||
) : null}
|
||||
</div>
|
||||
{renderMarkdown ? (
|
||||
<div className="task-review-tab__body markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{item.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<MailboxMessageContent className="task-review-tab__body markdown-body" content={item.body} testId="task-review-comment-body" />
|
||||
) : (
|
||||
<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>
|
||||
</li>
|
||||
@@ -420,6 +469,7 @@ export function TaskReviewTab({
|
||||
})}
|
||||
</ul>
|
||||
) : 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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<!-- 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 () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
33
packages/dashboard/app/utils/githubCommentAuthor.ts
Normal file
33
packages/dashboard/app/utils/githubCommentAuthor.ts
Normal 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 };
|
||||
}
|
||||
@@ -195,6 +195,7 @@ const qualityAppComponentTests = [
|
||||
"TaskDetailModal.github-tracking-stale",
|
||||
"TaskDocumentsTab",
|
||||
"TaskFieldsSection",
|
||||
"TaskReviewTab",
|
||||
"TaskForm",
|
||||
"TaskIdIntegrityBanner",
|
||||
"TrackingRepoSelect",
|
||||
|
||||
Reference in New Issue
Block a user