200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import ReactMarkdown from "react-markdown";
|
|
import { IconArrowBackUp, IconCheck, IconEyeOff, IconMessagePlus } from "@tabler/icons-react";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
markdownComponents,
|
|
normalizeMarkdown,
|
|
rehypePlugins,
|
|
remarkPlugins,
|
|
} from "@/components/shared/markdown-components";
|
|
import { findingLocation } from "@/lib/review/format";
|
|
import type { TaskReviewFinding } from "@/lib/types/review";
|
|
import { ReviewFindingSeverityBadge } from "./review-finding-severity";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export type ReviewFindingCardProps = {
|
|
finding: TaskReviewFinding;
|
|
onResolve?: (finding: TaskReviewFinding) => void;
|
|
onDismiss?: (finding: TaskReviewFinding) => void;
|
|
onReopen?: (finding: TaskReviewFinding) => void;
|
|
onSendToAgent?: (finding: TaskReviewFinding) => void;
|
|
/** Shown when the finding could not be anchored to a current line. */
|
|
staleReason?: string;
|
|
/** Renders the location line; off inside a diff where the line is implicit. */
|
|
showLocation?: boolean;
|
|
};
|
|
|
|
function FindingActions({
|
|
finding,
|
|
onResolve,
|
|
onDismiss,
|
|
onReopen,
|
|
onSendToAgent,
|
|
}: Omit<ReviewFindingCardProps, "staleReason" | "showLocation">) {
|
|
const { t } = useTranslation();
|
|
const isOpen = finding.status === "open";
|
|
return (
|
|
<div className="mt-2 flex flex-wrap items-center gap-1">
|
|
{onSendToAgent && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 cursor-pointer gap-1 px-1.5 text-xs"
|
|
onClick={() => onSendToAgent(finding)}
|
|
data-testid="review-finding-send-to-agent"
|
|
>
|
|
<IconMessagePlus className="h-3.5 w-3.5" />
|
|
{t("diff:sendToAgent")}
|
|
</Button>
|
|
)}
|
|
{isOpen && onResolve && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 cursor-pointer gap-1 px-1.5 text-xs text-emerald-600 dark:text-emerald-400"
|
|
onClick={() => onResolve(finding)}
|
|
data-testid="review-finding-resolve"
|
|
>
|
|
<IconCheck className="h-3.5 w-3.5" />
|
|
{t("diff:resolve")}
|
|
</Button>
|
|
)}
|
|
{isOpen && onDismiss && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 cursor-pointer gap-1 px-1.5 text-xs text-muted-foreground"
|
|
onClick={() => onDismiss(finding)}
|
|
data-testid="review-finding-dismiss"
|
|
>
|
|
<IconEyeOff className="h-3.5 w-3.5" />
|
|
{t("diff:dismiss")}
|
|
</Button>
|
|
)}
|
|
{!isOpen && onReopen && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 cursor-pointer gap-1 px-1.5 text-xs"
|
|
onClick={() => onReopen(finding)}
|
|
data-testid="review-finding-reopen"
|
|
>
|
|
<IconArrowBackUp className="h-3.5 w-3.5" />
|
|
{t("diff:reopen")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A suggested change is display-only in this iteration: it is never applied,
|
|
* staged, or committed, so the card says so next to the code rather than
|
|
* offering an action the feature does not have.
|
|
*/
|
|
function FindingSuggestion({ suggestion }: { suggestion: string }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="mt-2">
|
|
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
{t("diff:suggestedChangeNotAppliedAutomatically")}
|
|
</p>
|
|
<pre className="overflow-x-auto rounded bg-muted p-1.5 text-[11px] leading-tight">
|
|
<code>{suggestion}</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One anchored, advisory review finding.
|
|
*
|
|
* Resolved and dismissed findings collapse to a single muted line with an Undo,
|
|
* so a reviewed diff does not stay cluttered with issues the human has already
|
|
* dealt with while still letting them change their mind.
|
|
*/
|
|
export function ReviewFindingCard(props: ReviewFindingCardProps) {
|
|
const { t } = useTranslation();
|
|
const { finding, staleReason, showLocation = false } = props;
|
|
const isOpen = finding.status === "open";
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
|
|
data-testid="review-finding-card"
|
|
data-finding-status={finding.status}
|
|
>
|
|
<IconCheck className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate line-through">{finding.title}</span>
|
|
<span className="shrink-0 capitalize">{finding.status}</span>
|
|
{props.onReopen && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-5 cursor-pointer px-1 text-xs"
|
|
onClick={() => props.onReopen?.(finding)}
|
|
data-testid="review-finding-reopen"
|
|
>
|
|
{t("diff:undo")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="rounded-md border border-border bg-card p-2 shadow-sm"
|
|
data-testid="review-finding-card"
|
|
data-finding-status={finding.status}
|
|
data-finding-severity={finding.severity}
|
|
>
|
|
<div className="mb-1 flex flex-wrap items-center gap-1.5">
|
|
<ReviewFindingSeverityBadge severity={finding.severity} />
|
|
{finding.category && (
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px] text-muted-foreground">
|
|
{finding.category}
|
|
</Badge>
|
|
)}
|
|
{staleReason && (
|
|
<Badge
|
|
variant="outline"
|
|
className="px-1.5 py-0 text-[10px] text-muted-foreground"
|
|
title={staleReason}
|
|
data-testid="review-finding-stale"
|
|
>
|
|
{t("diff:stale")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{showLocation && (
|
|
<p className="mb-1 font-mono text-[11px] text-muted-foreground">
|
|
{findingLocation(finding)}
|
|
</p>
|
|
)}
|
|
<p className="text-xs font-semibold leading-snug" data-testid="review-finding-title">
|
|
{finding.title}
|
|
</p>
|
|
{staleReason && <p className="mt-1 text-[11px] text-muted-foreground">{staleReason}</p>}
|
|
<div
|
|
className="prose prose-sm dark:prose-invert mt-1 max-w-none text-xs leading-relaxed [overflow-wrap:anywhere] [&_p]:my-1"
|
|
data-testid="review-finding-body"
|
|
>
|
|
<ReactMarkdown
|
|
remarkPlugins={remarkPlugins}
|
|
rehypePlugins={rehypePlugins}
|
|
components={markdownComponents}
|
|
>
|
|
{normalizeMarkdown(finding.body)}
|
|
</ReactMarkdown>
|
|
</div>
|
|
{finding.suggestion && <FindingSuggestion suggestion={finding.suggestion} />}
|
|
<FindingActions {...props} />
|
|
</div>
|
|
);
|
|
}
|