Add C3 per-item verdict card (states S0-S7) wired to /verdicts

Implements the UI half of the ratified C3 per-item fan-out verdicts spec
(PAP-13207), wiring against the finalized request_item_verdicts interaction
kind + /verdicts partial-wake route from PAP-13247.

- RequestItemVerdictsCard in IssueThreadInteractionCard: prompt+details, per-item
  rows (label/description/previewMarkdown/deep-link) with a segmented
  Approve/Reject(/Defer) control (real >=44px keyboard buttons), inline required
  reject reason, goal-gradient 'M of N decided' progress, pinned batch bar
  (Approve all + Apply N decisions), applying state, terminal consequence chips
  (icon+text), complete summary chip, and stale/superseded 'cannot revert' notice.
- Draft-then-Apply: local optimistic drafts, one /verdicts request per Apply,
  partial progress keeps the card alive until complete.
- Reuse-only: A2 card chrome, Button default/destructive, textarea+inline
  validation, status-chip color tokens, motion tokens (reduced-motion honored).
- Threaded onSubmitInteractionVerdicts through IssueChatThread context,
  IssueDetail mutation, and the What-needs-me AttentionInteractionResolver.
- api: issuesApi.submitInteractionVerdicts; lib: getItemVerdictProgress +
  buildItemVerdictsSummary + type guard/summary for the new kind.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:07:56 +00:00
1 parent efe83dc935
commit 8d7fb594f2
6 files changed
+667 -1

No files matched your search

+6
View File
@@ -252,6 +252,12 @@ export const issuesApi = {
data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null },
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/respond`, data),
submitInteractionVerdicts: (
id: string,
interactionId: string,
verdicts: { id: string; verdict: "approve" | "reject" | "defer"; reason?: string | null }[],
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }),
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
@@ -11,6 +11,8 @@ import {
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestItemVerdictsInteraction,
type RequestItemVerdictValue,
type SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
@@ -91,6 +93,14 @@ export function AttentionInteractionResolver({
onSuccess: invalidate,
});
const verdictsMutation = useMutation({
mutationFn: (input: {
interactionId: string;
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[];
}) => issuesApi.submitInteractionVerdicts(issueId, input.interactionId, input.verdicts),
onSuccess: invalidate,
});
if (isLoading) {
return (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
@@ -125,6 +135,9 @@ export function AttentionInteractionResolver({
onCancelInteraction={(target: AskUserQuestionsInteraction) =>
cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined)
}
onSubmitInteractionVerdicts={(target: RequestItemVerdictsInteraction, verdicts) =>
verdictsMutation.mutateAsync({ interactionId: target.id, verdicts }).then(() => undefined)
}
/>
);
}
+16
View File
@@ -65,6 +65,8 @@ import type {
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
RequestItemVerdictsInteraction,
RequestItemVerdictValue,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
@@ -221,6 +223,10 @@ interface IssueChatMessageContext {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
onSubmitInteractionVerdicts?: (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void> | void;
onUploadImage?: (file: File) => Promise<string>;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
@@ -517,6 +523,10 @@ interface IssueChatThreadProps {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
onSubmitInteractionVerdicts?: (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
issueWorkMode?: IssueWorkMode;
/**
@@ -2724,6 +2734,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
onUploadImage,
externalReferences,
} = useContext(IssueChatCtx);
@@ -2782,6 +2793,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
onUploadImage={onUploadImage}
externalReferences={externalReferences}
/>
@@ -4236,6 +4248,7 @@ export function IssueChatThread({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
composerRef,
issueWorkMode,
onWorkModeChange,
@@ -4768,6 +4781,7 @@ export function IssueChatThread({
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
const stableOnSubmitInteractionVerdicts = useStableEvent(onSubmitInteractionVerdicts);
const stableOnUploadImage = useStableEvent(imageUploadHandler);
const chatCtx = useMemo<IssueChatMessageContext>(
@@ -4792,6 +4806,7 @@ export function IssueChatThread({
onRejectInteraction: stableOnRejectInteraction,
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
onCancelInteraction: stableOnCancelInteraction,
onSubmitInteractionVerdicts: stableOnSubmitInteractionVerdicts,
onUploadImage: stableOnUploadImage,
issueStatus,
successfulRunHandoff,
@@ -4819,6 +4834,7 @@ export function IssueChatThread({
stableOnRejectInteraction,
stableOnSubmitInteractionAnswers,
stableOnCancelInteraction,
stableOnSubmitInteractionVerdicts,
stableOnUploadImage,
issueStatus,
successfulRunHandoff,
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, X, XCircle } from "lucide-react";
import { AlertTriangle, ArrowUpRight, Check, CheckCircle2, ChevronRight, CircleDashed, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ThumbsUp, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
@@ -8,13 +8,19 @@ import {
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getItemVerdictProgress,
getQuestionAnswerLabels,
normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestConfirmationTarget,
type RequestItemVerdictsInteraction,
type RequestItemVerdictsItem,
type RequestItemVerdictsResultItem,
type RequestItemVerdictValue,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
type SuggestedTaskDraft,
@@ -58,6 +64,10 @@ interface IssueThreadInteractionCardProps {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
onSubmitInteractionVerdicts?: (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void> | void;
onUploadImage?: (file: File) => Promise<string>;
externalReferences?: MarkdownExternalReferenceMap;
}
@@ -110,6 +120,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Confirmation";
case "request_checkbox_confirmation":
return "Checkbox confirmation";
case "request_item_verdicts":
return "Item verdicts";
default:
return kind;
}
@@ -1939,6 +1951,491 @@ function RequestCheckboxConfirmationCard({
);
}
// --- Per-item verdicts (C3) ---------------------------------------------
const VERDICT_LABEL: Record<RequestItemVerdictValue, string> = {
approve: "Approve",
reject: "Reject",
defer: "Defer",
};
/** Present-tense past-participle label for a resolved verdict chip. */
const VERDICT_RESOLVED_LABEL: Record<RequestItemVerdictValue, string> = {
approve: "Approved",
reject: "Rejected",
defer: "Deferred",
};
function verdictChipClasses(verdict: RequestItemVerdictValue) {
switch (verdict) {
case "approve":
return "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100";
case "reject":
return "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100";
default:
return "border-border/70 bg-muted/40 text-muted-foreground";
}
}
function VerdictConsequenceChip({ verdict }: { verdict: RequestItemVerdictValue }) {
const Icon = verdict === "approve" ? CheckCircle2 : verdict === "reject" ? XCircle : MinusCircle;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)",
verdictChipClasses(verdict),
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
{VERDICT_RESOLVED_LABEL[verdict]}
</span>
);
}
function ItemVerdictDeepLink({ item }: { item: RequestItemVerdictsItem }) {
const href = item.href ? normalizeRequestConfirmationTargetHref(item.href) : null;
if (!href) return null;
const isInternal = href.startsWith("/") || href.startsWith("#");
const className =
"inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1";
const label = (
<>
Open
{isInternal ? <ArrowUpRight className="h-3 w-3" aria-hidden /> : <ExternalLink className="h-3 w-3" aria-hidden />}
</>
);
if (isInternal) {
return (
<Link to={href} className={className}>
{label}
</Link>
);
}
return (
<a href={href} target="_blank" rel="noreferrer" className={className}>
{label}
</a>
);
}
function ItemVerdictSegmentedControl({
itemId,
verdicts,
value,
disabled,
onSelect,
}: {
itemId: string;
verdicts: RequestItemVerdictValue[];
value: RequestItemVerdictValue | null;
disabled: boolean;
onSelect: (verdict: RequestItemVerdictValue) => void;
}) {
return (
<div
role="group"
aria-label="Choose a verdict"
className="flex shrink-0 flex-wrap items-center gap-2"
>
{verdicts.map((verdict) => {
const active = value === verdict;
const variant = verdict === "reject"
? (active ? "destructive" : "outline")
: verdict === "approve"
? (active ? "default" : "outline")
: (active ? "secondary" : "outline");
const Icon = verdict === "approve" ? Check : verdict === "reject" ? X : MinusCircle;
return (
<Button
key={verdict}
type="button"
size="sm"
variant={variant}
disabled={disabled}
aria-pressed={active}
aria-label={`${VERDICT_LABEL[verdict]} this item`}
className="min-h-11 min-w-24"
onClick={() => onSelect(verdict)}
data-verdict={verdict}
data-item-id={itemId}
data-active={active}
>
<Icon className="h-4 w-4" aria-hidden />
{VERDICT_LABEL[verdict]}
</Button>
);
})}
</div>
);
}
interface VerdictDraft {
verdict: RequestItemVerdictValue;
reason: string;
}
function RequestItemVerdictsCard({
interaction,
onSubmitInteractionVerdicts,
externalReferences,
}: {
interaction: RequestItemVerdictsInteraction;
onSubmitInteractionVerdicts?: (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void> | void;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const payload = interaction.payload;
const items = payload.items;
const enabledVerdicts = useMemo<RequestItemVerdictValue[]>(
() => payload.verdicts ?? ["approve", "reject"],
[payload.verdicts],
);
const requireReasonOn = useMemo(
() => new Set<RequestItemVerdictValue>(payload.requireReasonOn ?? ["reject"]),
[payload.requireReasonOn],
);
const allowBulkApprove = payload.allowBulkApprove !== false && enabledVerdicts.includes("approve");
const reasonLabel = payload.reasonLabel ?? "Reason";
const resolvedById = useMemo(
() => new Map<string, RequestItemVerdictsResultItem>((interaction.result?.items ?? []).map((item) => [item.id, item])),
[interaction.result],
);
const [drafts, setDrafts] = useState<Map<string, VerdictDraft>>(new Map());
const [applyingItemIds, setApplyingItemIds] = useState<Set<string>>(new Set());
const [working, setWorking] = useState(false);
const [attempted, setAttempted] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// When the server merges newly-resolved items, drop their local drafts and
// clear the applying/working state so the terminal chips take over (S3 → S4).
useEffect(() => {
setDrafts((current) => {
let changed = false;
const next = new Map(current);
for (const id of [...next.keys()]) {
if (resolvedById.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : current;
});
setApplyingItemIds(new Set());
setWorking(false);
setActionError(null);
}, [resolvedById]);
const progress = getItemVerdictProgress({ payload, result: interaction.result });
const isTerminal = interaction.status !== "pending";
const isExpired = interaction.status === "expired";
const isComplete = interaction.status === "answered" || progress.decided === progress.total;
const draftEntries = [...drafts.entries()];
const draftCount = draftEntries.length;
const invalidDraftIds = new Set(
draftEntries
.filter(([, draft]) => requireReasonOn.has(draft.verdict) && draft.reason.trim().length === 0)
.map(([id]) => id),
);
const canApply = draftCount > 0 && invalidDraftIds.size === 0 && !working && Boolean(onSubmitInteractionVerdicts);
function toggleDraft(itemId: string, verdict: RequestItemVerdictValue) {
setDrafts((current) => {
const next = new Map(current);
const existing = next.get(itemId);
if (existing?.verdict === verdict) {
next.delete(itemId); // per-item undo
} else {
next.set(itemId, { verdict, reason: existing?.reason ?? "" });
}
return next;
});
}
function setDraftReason(itemId: string, reason: string) {
setDrafts((current) => {
const existing = current.get(itemId);
if (!existing) return current;
const next = new Map(current);
next.set(itemId, { ...existing, reason });
return next;
});
}
function handleApproveAll() {
if (!allowBulkApprove) return;
setDrafts((current) => {
const next = new Map(current);
for (const id of progress.pendingItemIds) {
const existing = next.get(id);
next.set(id, { verdict: "approve", reason: existing?.reason ?? "" });
}
return next;
});
}
async function handleApply() {
setAttempted(true);
if (!onSubmitInteractionVerdicts || draftCount === 0 || invalidDraftIds.size > 0) return;
const verdicts = draftEntries.map(([id, draft]) => ({
id,
verdict: draft.verdict,
reason: draft.reason.trim() ? draft.reason.trim() : undefined,
}));
setWorking(true);
setApplyingItemIds(new Set(verdicts.map((entry) => entry.id)));
setActionError(null);
try {
await onSubmitInteractionVerdicts(interaction, verdicts);
// Success: the parent refetch updates `interaction.result`, the effect
// above clears drafts + applying state, and terminal chips render.
} catch {
setActionError("Try again");
setApplyingItemIds(new Set());
setWorking(false);
}
}
const applyLabel = draftCount === 0
? "Apply 0 decisions"
: `Apply ${draftCount} decision${draftCount === 1 ? "" : "s"}`;
return (
<div className="space-y-4">
{/* Prompt + details (S1) */}
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm leading-6 text-foreground">{payload.prompt}</div>
<VerdictProgressBadge progress={progress} pendingReason={invalidDraftIds.size > 0} />
</div>
{payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody externalReferences={externalReferences}>{payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
{interaction.payload.target ? (
<RequestConfirmationTargetChip interaction={interaction} target={interaction.payload.target} />
) : null}
</div>
{/* Stale / superseded notice (S6) */}
{isExpired ? (
<div className="rounded-sm border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">
<div className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4" aria-hidden />
{interaction.result?.outcome === "superseded_by_comment"
? "This review expired after a later comment."
: interaction.result?.outcome === "stale_target"
? "This review expired after the target changed."
: "This review expired."}
</div>
{progress.decided > 0 ? (
<p className="mt-1 text-xs leading-5">
{progress.decided === 1 ? "1 item was" : `${progress.decided} items were`} already applied and cannot be
reverted. Remaining items were cancelled.
</p>
) : null}
</div>
) : null}
{/* Item list (S1/S2/S3/S4) */}
<ul className="space-y-2" aria-label="Items to review">
{items.map((item) => {
const resolved = resolvedById.get(item.id);
const applying = applyingItemIds.has(item.id);
const draft = drafts.get(item.id);
return (
<li
key={item.id}
className={cn(
"rounded-sm border border-border/70 bg-background/60 p-3",
draft && !resolved && "border-border",
)}
data-item-id={item.id}
data-item-state={resolved ? "resolved" : applying ? "applying" : draft ? "draft" : "pending"}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 basis-64">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium leading-5 text-foreground">{item.label}</span>
<ItemVerdictDeepLink item={item} />
</div>
{item.description ? (
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{item.description}</p>
) : null}
{item.previewMarkdown ? (
<div className="mt-2 rounded-sm border border-border/50 bg-muted/20 px-2.5 py-2 text-xs">
<MarkdownBody externalReferences={externalReferences}>{item.previewMarkdown}</MarkdownBody>
</div>
) : null}
{resolved?.reason ? (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
<span className="font-medium text-foreground">{reasonLabel}: </span>
{resolved.reason}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{resolved ? (
<VerdictConsequenceChip verdict={resolved.verdict} />
) : applying ? (
<span className="inline-flex items-center gap-1.5 rounded-sm border border-border/70 bg-muted/40 px-2 py-0.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 motion-safe:animate-spin" aria-hidden />
Applying…
</span>
) : isTerminal ? (
<span className="inline-flex items-center gap-1 rounded-sm border border-border/70 bg-muted/30 px-2 py-0.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5" aria-hidden />
Not decided
</span>
) : (
<ItemVerdictSegmentedControl
itemId={item.id}
verdicts={enabledVerdicts}
value={draft?.verdict ?? null}
disabled={working}
onSelect={(verdict) => toggleDraft(item.id, verdict)}
/>
)}
</div>
</div>
{/* Draft reason field (S2) — reveals when the draft verdict needs a reason */}
{!resolved && !applying && draft && requireReasonOn.has(draft.verdict) ? (
<div className="mt-3 space-y-1.5">
<label
htmlFor={`${interaction.id}-${item.id}-reason`}
className="text-xs font-medium text-foreground"
>
{reasonLabel}
</label>
<Textarea
id={`${interaction.id}-${item.id}-reason`}
value={draft.reason}
onChange={(event) => setDraftReason(item.id, event.target.value)}
placeholder="Give the agent a reason so it can act on this item."
aria-invalid={attempted && invalidDraftIds.has(item.id)}
className={cn(
"min-h-16 bg-background text-sm",
attempted && invalidDraftIds.has(item.id) && "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{attempted && invalidDraftIds.has(item.id) ? (
<p className="text-xs text-destructive">A reason is required to {VERDICT_LABEL[draft.verdict].toLowerCase()} this item.</p>
) : null}
</div>
) : null}
</li>
);
})}
</ul>
{/* Complete summary (S5) */}
{isComplete && !isExpired ? (
<div className="flex flex-wrap items-center gap-2 rounded-sm border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-900 dark:text-emerald-100">
<CheckCircle2 className="h-4 w-4" aria-hidden />
<span className="font-medium">
{progress.decided} decided · {progress.approved} approved · {progress.rejected} rejected
{progress.deferred > 0 ? ` · ${progress.deferred} deferred` : ""}
</span>
</div>
) : null}
{/* Pinned batch bar (S1/S2) — only while items remain actionable */}
{!isTerminal && progress.pendingItemIds.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3">
<div className="text-xs text-muted-foreground">
{draftCount > 0
? `${draftCount} draft verdict${draftCount === 1 ? "" : "s"} ready to apply`
: "Mark verdicts, then apply them in one pass."}
</div>
<div className="flex flex-wrap items-center gap-2">
{allowBulkApprove ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={working || progress.pendingItemIds.length === 0}
onClick={handleApproveAll}
>
<ThumbsUp className="h-4 w-4" aria-hidden />
Approve all
</Button>
) : null}
<Button
type="button"
size="sm"
variant="default"
aria-disabled={!canApply}
disabled={!canApply}
onClick={() => void handleApply()}
>
{working ? (
<>
<Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden />
Applying…
</>
) : (
applyLabel
)}
</Button>
</div>
</div>
) : null}
{actionError ? (
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
</div>
);
}
function VerdictProgressBadge({
progress,
pendingReason,
}: {
progress: ReturnType<typeof getItemVerdictProgress>;
pendingReason: boolean;
}) {
const pct = progress.total > 0 ? Math.round((progress.decided / progress.total) * 100) : 0;
return (
<div className="flex items-center gap-2">
{/* Von Restorff accent when a draft reject is missing its reason */}
{pendingReason ? (
<span className="inline-flex items-center gap-1 rounded-sm border border-amber-500/60 bg-amber-500/10 px-1.5 py-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-amber-900 dark:text-amber-100">
<AlertTriangle className="h-3 w-3" aria-hidden />
Reason needed
</span>
) : null}
<div
className="flex items-center gap-2"
role="progressbar"
aria-valuemin={0}
aria-valuemax={progress.total}
aria-valuenow={progress.decided}
aria-label={`${progress.decided} of ${progress.total} decided`}
>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] motion-reduce:transition-none"
style={{ width: `${pct}%` }}
/>
</div>
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
{progress.decided} of {progress.total} decided
</span>
</div>
</div>
);
}
export function IssueThreadInteractionCard({
interaction,
agentMap,
@@ -1948,6 +2445,7 @@ export function IssueThreadInteractionCard({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
onUploadImage,
externalReferences,
}: IssueThreadInteractionCardProps) {
@@ -1995,6 +2493,8 @@ export function IssueThreadInteractionCard({
? interaction.payload.title ?? "Questions for the operator"
: interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
: interaction.kind === "request_item_verdicts"
? "Review these items"
: isPlan
? "Plan review"
: "Confirmation requested")}
@@ -2043,6 +2543,12 @@ export function IssueThreadInteractionCard({
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
) : interaction.kind === "request_item_verdicts" ? (
<RequestItemVerdictsCard
interaction={interaction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
externalReferences={externalReferences}
/>
) : (
<RequestConfirmationCard
interaction={interaction}
+78
View File
@@ -19,6 +19,13 @@ export type {
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationTarget,
RequestItemVerdictsInteraction,
RequestItemVerdictsItem,
RequestItemVerdictsPayload,
RequestItemVerdictsResult,
RequestItemVerdictsResultItem,
RequestItemVerdictValue,
SubmitIssueThreadInteractionVerdicts,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksPayload,
@@ -34,6 +41,10 @@ import type {
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationTarget,
RequestItemVerdictsInteraction,
RequestItemVerdictsPayload,
RequestItemVerdictsResult,
RequestItemVerdictValue,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
@@ -57,9 +68,72 @@ export function isIssueThreadInteraction(
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
|| candidate.kind === "request_item_verdicts"
);
}
export interface ItemVerdictProgress {
total: number;
decided: number;
approved: number;
rejected: number;
deferred: number;
/** ids in payload order that still have no verdict. */
pendingItemIds: string[];
}
/**
* Derive the `M of N decided` progress for a per-item verdict interaction from
* its payload (the full item roster) and result (verdicts accumulated so far).
* Present-tense verdict values (`approve`/`reject`/`defer`) are what the server
* stores in `result.items[].verdict` (see PAP-13247).
*/
export function getItemVerdictProgress(args: {
payload: RequestItemVerdictsPayload;
result?: RequestItemVerdictsResult | null;
}): ItemVerdictProgress {
const { payload, result } = args;
const resolvedById = new Map<string, RequestItemVerdictValue>(
(result?.items ?? []).map((item) => [item.id, item.verdict] as const),
);
let approved = 0;
let rejected = 0;
let deferred = 0;
const pendingItemIds: string[] = [];
for (const item of payload.items) {
const verdict = resolvedById.get(item.id);
if (verdict === "approve") approved += 1;
else if (verdict === "reject") rejected += 1;
else if (verdict === "defer") deferred += 1;
else pendingItemIds.push(item.id);
}
const decided = approved + rejected + deferred;
return { total: payload.items.length, decided, approved, rejected, deferred, pendingItemIds };
}
export function buildItemVerdictsSummary(
interaction: RequestItemVerdictsInteraction,
): string {
const progress = getItemVerdictProgress({
payload: interaction.payload,
result: interaction.result,
});
if (interaction.status === "answered") {
const parts = [`${progress.decided} decided`];
if (progress.approved > 0) parts.push(`${progress.approved} approved`);
if (progress.rejected > 0) parts.push(`${progress.rejected} rejected`);
if (progress.deferred > 0) parts.push(`${progress.deferred} deferred`);
return parts.join(" · ");
}
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Verdicts expired after comment";
if (outcome === "stale_target") return "Verdicts expired after target changed";
return "Verdicts expired";
}
return `${progress.decided} of ${progress.total} decided`;
}
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
@@ -151,6 +225,10 @@ export function buildIssueThreadInteractionSummary(
: `Requested a selection from ${optionCount} options`;
}
if (interaction.kind === "request_item_verdicts") {
return buildItemVerdictsSummary(interaction);
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+47
View File
@@ -198,6 +198,8 @@ import {
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestItemVerdictsInteraction,
type RequestItemVerdictValue,
type SuggestTasksInteraction,
type IssueTreeControlMode,
type WorkspaceFileRef,
@@ -917,6 +919,10 @@ type IssueDetailChatTabProps = {
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
onSubmitInteractionVerdicts: (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void>;
assigneeUserId: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
@@ -989,6 +995,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
assigneeUserId,
onResumeFromBacklog,
resumeFromBacklogPending,
@@ -1210,6 +1217,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onSubmitInteractionAnswers(interaction, answers)
}
onCancelInteraction={onCancelInteraction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
issueWorkMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
onCancelRun={runningIssueRun && onPauseWorkRun
@@ -2588,6 +2596,38 @@ export function IssueDetail() {
},
});
const submitInteractionVerdicts = useMutation({
mutationFn: ({
interaction,
verdicts,
}: {
interaction: RequestItemVerdictsInteraction;
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[];
}) => issuesApi.submitInteractionVerdicts(issueId!, interaction.id, verdicts),
onSuccess: (interaction, variables) => {
upsertInteractionInCache(interaction);
invalidateIssueDetail();
invalidateIssueCollections();
const applied = variables.verdicts.length;
const complete = interaction.kind === "request_item_verdicts"
? interaction.result?.complete ?? false
: false;
pushToast({
title: complete
? "All verdicts applied"
: `Applied ${applied} decision${applied === 1 ? "" : "s"}`,
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Apply failed",
body: err instanceof Error ? err.message : "Unable to apply the verdicts",
tone: "error",
});
},
});
const cancelInteraction = useMutation({
mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) =>
issuesApi.cancelInteraction(issueId!, interaction.id),
@@ -3589,6 +3629,12 @@ export function IssueDetail() {
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
await cancelInteraction.mutateAsync({ interaction });
}, [cancelInteraction]);
const handleSubmitInteractionVerdicts = useCallback(async (
interaction: RequestItemVerdictsInteraction,
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => {
await submitInteractionVerdicts.mutateAsync({ interaction, verdicts });
}, [submitInteractionVerdicts]);
const canResumeFromBacklog = issue?.status === "backlog" && Boolean(issue.assigneeAgentId || issue.assigneeUserId);
const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" });
@@ -4742,6 +4788,7 @@ export function IssueDetail() {
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
onSubmitInteractionVerdicts={handleSubmitInteractionVerdicts}
assigneeUserId={issue.assigneeUserId ?? null}
onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined}
resumeFromBacklogPending={