From 8d7fb594f2452882eb876f8dcc3a023608e195c1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 9 Jul 2026 16:10:41 +0000 Subject: [PATCH] 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 --- ui/src/api/issues.ts | 6 + .../AttentionInteractionResolver.tsx | 13 + ui/src/components/IssueChatThread.tsx | 16 + .../components/IssueThreadInteractionCard.tsx | 508 +++++++++++++++++- ui/src/lib/issue-thread-interactions.ts | 78 +++ ui/src/pages/IssueDetail.tsx | 47 ++ 6 files changed, 667 insertions(+), 1 deletion(-) diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 2fd1d1cebb1..73e6798b890 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -252,6 +252,12 @@ export const issuesApi = { data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null }, ) => api.post(`/issues/${id}/interactions/${interactionId}/respond`, data), + submitInteractionVerdicts: ( + id: string, + interactionId: string, + verdicts: { id: string; verdict: "approve" | "reject" | "defer"; reason?: string | null }[], + ) => + api.post(`/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }), getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), diff --git a/ui/src/components/AttentionInteractionResolver.tsx b/ui/src/components/AttentionInteractionResolver.tsx index bea029b2c92..8176f534d8b 100644 --- a/ui/src/components/AttentionInteractionResolver.tsx +++ b/ui/src/components/AttentionInteractionResolver.tsx @@ -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 (
@@ -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) + } /> ); } diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index d01a5762dd0..c7a954d41c6 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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; + onSubmitInteractionVerdicts?: ( + interaction: RequestItemVerdictsInteraction, + verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], + ) => Promise | void; onUploadImage?: (file: File) => Promise; issueStatus?: string; successfulRunHandoff?: SuccessfulRunHandoffState | null; @@ -517,6 +523,10 @@ interface IssueChatThreadProps { onCancelInteraction?: ( interaction: AskUserQuestionsInteraction, ) => Promise | void; + onSubmitInteractionVerdicts?: ( + interaction: RequestItemVerdictsInteraction, + verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], + ) => Promise | void; composerRef?: Ref; 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( @@ -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, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index a66b1e08ca5..bcfb2f998e5 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -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; + onSubmitInteractionVerdicts?: ( + interaction: RequestItemVerdictsInteraction, + verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], + ) => Promise | void; onUploadImage?: (file: File) => Promise; 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 = { + approve: "Approve", + reject: "Reject", + defer: "Defer", +}; + +/** Present-tense past-participle label for a resolved verdict chip. */ +const VERDICT_RESOLVED_LABEL: Record = { + 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 ( + + + {VERDICT_RESOLVED_LABEL[verdict]} + + ); +} + +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 ? : } + + ); + if (isInternal) { + return ( + + {label} + + ); + } + return ( + + {label} + + ); +} + +function ItemVerdictSegmentedControl({ + itemId, + verdicts, + value, + disabled, + onSelect, +}: { + itemId: string; + verdicts: RequestItemVerdictValue[]; + value: RequestItemVerdictValue | null; + disabled: boolean; + onSelect: (verdict: RequestItemVerdictValue) => void; +}) { + return ( +
+ {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 ( + + ); + })} +
+ ); +} + +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; + externalReferences?: MarkdownExternalReferenceMap; +}) { + const payload = interaction.payload; + const items = payload.items; + const enabledVerdicts = useMemo( + () => payload.verdicts ?? ["approve", "reject"], + [payload.verdicts], + ); + const requireReasonOn = useMemo( + () => new Set(payload.requireReasonOn ?? ["reject"]), + [payload.requireReasonOn], + ); + const allowBulkApprove = payload.allowBulkApprove !== false && enabledVerdicts.includes("approve"); + const reasonLabel = payload.reasonLabel ?? "Reason"; + + const resolvedById = useMemo( + () => new Map((interaction.result?.items ?? []).map((item) => [item.id, item])), + [interaction.result], + ); + + const [drafts, setDrafts] = useState>(new Map()); + const [applyingItemIds, setApplyingItemIds] = useState>(new Set()); + const [working, setWorking] = useState(false); + const [attempted, setAttempted] = useState(false); + const [actionError, setActionError] = useState(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 ( +
+ {/* Prompt + details (S1) */} +
+
+
{payload.prompt}
+ 0} /> +
+ {payload.detailsMarkdown ? ( +
+ {payload.detailsMarkdown} +
+ ) : null} + {interaction.payload.target ? ( + + ) : null} +
+ + {/* Stale / superseded notice (S6) */} + {isExpired ? ( +
+
+ + {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."} +
+ {progress.decided > 0 ? ( +

+ {progress.decided === 1 ? "1 item was" : `${progress.decided} items were`} already applied and cannot be + reverted. Remaining items were cancelled. +

+ ) : null} +
+ ) : null} + + {/* Item list (S1/S2/S3/S4) */} +
    + {items.map((item) => { + const resolved = resolvedById.get(item.id); + const applying = applyingItemIds.has(item.id); + const draft = drafts.get(item.id); + return ( +
  • +
    +
    +
    + {item.label} + +
    + {item.description ? ( +

    {item.description}

    + ) : null} + {item.previewMarkdown ? ( +
    + {item.previewMarkdown} +
    + ) : null} + {resolved?.reason ? ( +

    + {reasonLabel}: + {resolved.reason} +

    + ) : null} +
    + +
    + {resolved ? ( + + ) : applying ? ( + + + Applying… + + ) : isTerminal ? ( + + + Not decided + + ) : ( + toggleDraft(item.id, verdict)} + /> + )} +
    +
    + + {/* Draft reason field (S2) — reveals when the draft verdict needs a reason */} + {!resolved && !applying && draft && requireReasonOn.has(draft.verdict) ? ( +
    + +