From 7e9d93f1d218791a7a64638181e2be4df1f0fc08 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 21:15:01 -0500 Subject: [PATCH] feat(ui): complete task protocol interactions --- .../TaskChatCompactInteractionCard.tsx | 927 ++++++++++++++++++ .../TaskChatInteractionCard.test.tsx | 87 +- .../task-chat/TaskChatInteractionCard.tsx | 24 +- .../task-chat/TaskChatProtocolCard.test.tsx | 28 + .../task-chat/TaskChatProtocolCard.tsx | 7 +- .../task-chat/TaskChatRunnerTurn.test.tsx | 38 +- .../task-chat/TaskChatRunnerTurn.tsx | 29 +- .../stories/task-runner-protocol.stories.tsx | 38 +- 8 files changed, 1142 insertions(+), 36 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx new file mode 100644 index 00000000000..20a24cdd3a0 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -0,0 +1,927 @@ +import { + useEffect, + useMemo, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import { + Check, + CheckCircle2, + ChevronDown, + ChevronLeft, + ChevronRight, + Circle, + CircleHelp, + GitBranch, + ListChecks, + Loader2, + MessageSquareQuote, + X, +} from "lucide-react"; +import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Textarea } from "@/components/ui/textarea"; +import { describeInteractionAudience } from "@/lib/interaction-audience"; +import { interactionResolutionErrorMessage } from "@/lib/interaction-resolution-error"; +import { + buildIssueThreadInteractionSummary, + buildSuggestedTaskTree, + collectSuggestedTaskClientKeys, + getCheckboxConfirmationSelectedLabels, + getItemVerdictProgress, + getQuestionAnswerLabels, + normalizeRequestConfirmationTargetHref, + type AskUserQuestionsAnswer, + type AskUserQuestionsInteraction, + type IssueThreadInteraction, + type RequestCheckboxConfirmationInteraction, + type RequestConfirmationInteraction, + type RequestItemVerdictsInteraction, + type RequestItemVerdictValue, + type SuggestTasksInteraction, + type SuggestedTaskTreeNode, +} from "@/lib/issue-thread-interactions"; +import { cn } from "@/lib/utils"; + +type SharedInteractionProps = Omit< + ComponentProps, + "interaction" | "primaryActionOnRight" +>; + +export interface TaskChatCompactInteractionCardProps extends SharedInteractionProps { + interaction: IssueThreadInteraction; +} + +const OTHER_ANSWER_ID = "__paperclip_other__"; + +const KIND_COPY = { + suggest_tasks: { fallbackTitle: "Suggested tasks", label: "Tasks", icon: GitBranch }, + ask_user_questions: { fallbackTitle: "Questions", label: "Questions", icon: CircleHelp }, + request_confirmation: { fallbackTitle: "Confirmation", label: "Confirmation", icon: CheckCircle2 }, + request_checkbox_confirmation: { fallbackTitle: "Choose options", label: "Selection", icon: ListChecks }, + request_item_verdicts: { fallbackTitle: "Review items", label: "Review", icon: MessageSquareQuote }, +} as const; + +function statusLabel(status: IssueThreadInteraction["status"]): string { + if (status === "pending") return "Needs response"; + return status.replaceAll("_", " "); +} + +function statusTone(status: IssueThreadInteraction["status"]): string { + if (status === "pending") return "text-(--status-agent-running)"; + if (status === "accepted" || status === "answered") return "text-(--status-task-icon-done)"; + if (status === "rejected" || status === "failed") return "text-destructive"; + return "text-muted-foreground"; +} + +function StatusIcon({ status }: { status: IssueThreadInteraction["status"] }) { + if (status === "pending") { + return ; + } + if (status === "accepted" || status === "answered") { + return ; + } + if (status === "rejected" || status === "failed") { + return ; + } + return ; +} + +function InteractionShell({ + interaction, + audienceLabel, + children, +}: { + interaction: IssueThreadInteraction; + audienceLabel?: string | null; + children: ReactNode; +}) { + const kind = KIND_COPY[interaction.kind]; + const Icon = kind.icon; + return ( +
+
+ +
+
+ + {interaction.title ?? kind.fallbackTitle} + + + + {statusLabel(interaction.status)} + +
+ {audienceLabel ? ( +

{audienceLabel}

+ ) : null} +
+
+
{children}
+
+ ); +} + +function InteractionActionError({ message }: { message: string | null }) { + return ( +
+ {message ? ( +
+ {message} +
+ ) : null} +
+ ); +} + +function Details({ children }: { children?: ReactNode }) { + if (!children) return null; + return ( +
+ + Details + +
+ {children} +
+
+ ); +} + +function ActionRow({ children, hint }: { children: ReactNode; hint?: string | null }) { + return ( +
+ {hint ? {hint} : } + {children} +
+ ); +} + +function targetLabel(interaction: RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction | RequestItemVerdictsInteraction) { + const target = interaction.payload.target; + if (!target) return null; + const label = target.label ?? (target.type === "issue_document" ? target.key : target.key); + const revision = target.revisionNumber == null ? null : `v${target.revisionNumber}`; + return [label, revision].filter(Boolean).join(" · "); +} + +function CompactTarget({ interaction }: { interaction: RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction | RequestItemVerdictsInteraction }) { + const target = interaction.payload.target; + const label = targetLabel(interaction); + if (!target || !label) return null; + const href = target.href ? normalizeRequestConfirmationTargetHref(target.href) : null; + if (href) { + return {label}; + } + return {label}; +} + +function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteraction }) { + const summary = buildIssueThreadInteractionSummary(interaction); + let detail: ReactNode = null; + + if (interaction.kind === "ask_user_questions" && interaction.result?.answers) { + detail = ( +
    + {interaction.payload.questions.map((question) => { + const labels = getQuestionAnswerLabels({ question, answers: interaction.result?.answers ?? [] }); + return ( +
  • + {question.prompt} + {labels.length > 0 ? labels.join(", ") : "No answer"} +
  • + ); + })} +
+ ); + } else if (interaction.kind === "request_checkbox_confirmation") { + const labels = getCheckboxConfirmationSelectedLabels({ payload: interaction.payload, result: interaction.result }); + detail = labels.length > 0 ?

{labels.join(", ")}

: null; + } else if (interaction.kind === "request_item_verdicts") { + const progress = getItemVerdictProgress({ payload: interaction.payload, result: interaction.result }); + detail = progress.decided > 0 ? ( +

+ {progress.approved} approved · {progress.rejected} rejected{progress.deferred > 0 ? ` · ${progress.deferred} deferred` : ""} +

+ ) : null; + } else if (interaction.kind === "request_confirmation" && interaction.result?.reason) { + detail =

{interaction.result.reason}

; + } else if (interaction.kind === "suggest_tasks" && interaction.result?.rejectionReason) { + detail =

{interaction.result.rejectionReason}

; + } + + return ( +
+ +
+

{summary}

+ {detail} +
+
+ ); +} + +function QuestionOption({ + id, + label, + description, + selected, + mode, + onClick, +}: { + id: string; + label: string; + description?: string | null; + selected: boolean; + mode: "single" | "multi"; + onClick: () => void; +}) { + return ( + + ); +} + +function AskUserQuestionsCard({ + interaction, + onSubmitInteractionAnswers, + onCancelInteraction, + errorMessage, +}: { + interaction: AskUserQuestionsInteraction; + onSubmitInteractionAnswers?: SharedInteractionProps["onSubmitInteractionAnswers"]; + onCancelInteraction?: SharedInteractionProps["onCancelInteraction"]; + errorMessage: (error: unknown) => string; +}) { + const questions = interaction.payload.questions; + const [page, setPage] = useState(0); + const [draftAnswers, setDraftAnswers] = useState>(() => + Object.fromEntries((interaction.result?.answers ?? []).map((answer) => [answer.questionId, [...answer.optionIds]])), + ); + const [draftOtherAnswers, setDraftOtherAnswers] = useState>(() => + Object.fromEntries((interaction.result?.answers ?? []).filter((answer) => answer.otherText).map((answer) => [answer.questionId, answer.otherText ?? ""])), + ); + const [otherActiveQuestions, setOtherActiveQuestions] = useState>(() => + Object.fromEntries((interaction.result?.answers ?? []).filter((answer) => answer.otherText).map((answer) => [answer.questionId, true])), + ); + const [working, setWorking] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [actionError, setActionError] = useState(null); + + useEffect(() => setPage((current) => Math.min(current, Math.max(questions.length - 1, 0))), [questions.length]); + + const question = questions[page]; + if (!question) return

No answerable questions were provided.

; + const selected = draftAnswers[question.id] ?? []; + const otherActive = otherActiveQuestions[question.id] === true; + const otherText = draftOtherAnswers[question.id] ?? ""; + const pageComplete = !question.required || selected.length > 0 || (otherActive && otherText.trim().length > 0); + const allRequiredComplete = questions.every((candidate) => { + if (!candidate.required) return true; + return (draftAnswers[candidate.id]?.length ?? 0) > 0 + || (otherActiveQuestions[candidate.id] === true && (draftOtherAnswers[candidate.id]?.trim().length ?? 0) > 0); + }); + const hasFreeTextOption = question.options.some((option) => option.freeText === true); + + function toggleOption(optionId: string, isFreeText = false) { + if (optionId === OTHER_ANSWER_ID || isFreeText) { + setOtherActiveQuestions((current) => ({ ...current, [question.id]: !current[question.id] })); + if (question.selectionMode === "single") setDraftAnswers((current) => ({ ...current, [question.id]: [] })); + return; + } + setDraftAnswers((current) => { + const existing = current[question.id] ?? []; + const next = question.selectionMode === "single" + ? [optionId] + : existing.includes(optionId) ? existing.filter((id) => id !== optionId) : [...existing, optionId]; + return { ...current, [question.id]: next }; + }); + if (question.selectionMode === "single") setOtherActiveQuestions((current) => ({ ...current, [question.id]: false })); + } + + async function submit() { + if (!onSubmitInteractionAnswers || !allRequiredComplete) return; + const answers: AskUserQuestionsAnswer[] = questions.map((candidate) => { + const candidateOther = otherActiveQuestions[candidate.id] ? draftOtherAnswers[candidate.id]?.trim() : ""; + return { + questionId: candidate.id, + optionIds: draftAnswers[candidate.id] ?? [], + ...(candidateOther ? { otherText: candidateOther } : {}), + }; + }); + setWorking(true); + setActionError(null); + try { + await onSubmitInteractionAnswers(interaction, answers); + } catch (error) { + setActionError(errorMessage(error)); + } finally { + setWorking(false); + } + } + + async function cancel() { + if (!onCancelInteraction) return; + setCancelling(true); + setActionError(null); + try { + await onCancelInteraction(interaction); + } catch (error) { + setActionError(errorMessage(error)); + } finally { + setCancelling(false); + } + } + + return ( +
+
+ Question {page + 1} of {questions.length} + {question.selectionMode === "multi" ? "Choose all that apply" : "Choose one"}{question.required ? " · Required" : " · Optional"} +
+

{question.prompt}

+ {question.helpText ?

{question.helpText}

: null} +
+ {question.options.map((option) => { + const isFreeText = option.freeText === true; + const optionSelected = isFreeText ? otherActive : selected.includes(option.id); + return ( +
+ toggleOption(option.id, isFreeText)} + /> + {isFreeText && optionSelected ? ( +