mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
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:
1 parent
efe83dc935
commit
8d7fb594f2
6 files changed
+667
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in new issue
Block a user