mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(ui): complete task protocol interactions
This commit is contained in:
1 parent
10765bad75
commit
7e9d93f1d2
8 files changed
+1142
-36
No files matched your search
@@ -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<typeof IssueThreadInteractionCard>,
|
||||
"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 <Circle aria-hidden className={cn("h-3.5 w-3.5 fill-current", statusTone(status))} />;
|
||||
}
|
||||
if (status === "accepted" || status === "answered") {
|
||||
return <Check aria-hidden className={cn("h-3.5 w-3.5", statusTone(status))} />;
|
||||
}
|
||||
if (status === "rejected" || status === "failed") {
|
||||
return <X aria-hidden className={cn("h-3.5 w-3.5", statusTone(status))} />;
|
||||
}
|
||||
return <Circle aria-hidden className={cn("h-3.5 w-3.5", statusTone(status))} />;
|
||||
}
|
||||
|
||||
function InteractionShell({
|
||||
interaction,
|
||||
audienceLabel,
|
||||
children,
|
||||
}: {
|
||||
interaction: IssueThreadInteraction;
|
||||
audienceLabel?: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const kind = KIND_COPY[interaction.kind];
|
||||
const Icon = kind.icon;
|
||||
return (
|
||||
<article className="overflow-hidden rounded-md border border-border bg-card/60">
|
||||
<header className="flex min-w-0 items-start gap-2 px-3 py-2.5">
|
||||
<Icon aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<strong className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
{interaction.title ?? kind.fallbackTitle}
|
||||
</strong>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs capitalize text-muted-foreground"
|
||||
data-testid="interaction-status-badge"
|
||||
>
|
||||
<StatusIcon status={interaction.status} />
|
||||
{statusLabel(interaction.status)}
|
||||
</span>
|
||||
</div>
|
||||
{audienceLabel ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{audienceLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="border-t border-border/70 px-3 py-3">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractionActionError({ message }: { message: string | null }) {
|
||||
return (
|
||||
<div aria-live="assertive" data-testid="interaction-action-error">
|
||||
{message ? (
|
||||
<div className="mt-2 rounded-sm border border-destructive/60 bg-destructive/10 px-2.5 py-2 text-sm text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({ children }: { children?: ReactNode }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<details className="mt-2">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<ChevronDown aria-hidden className="h-3.5 w-3.5" /> Details
|
||||
</summary>
|
||||
<div className="mt-2 rounded-sm bg-muted/40 px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ children, hint }: { children: ReactNode; hint?: string | null }) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||
{hint ? <span className="mr-auto text-xs text-muted-foreground">{hint}</span> : <span className="mr-auto" />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <a href={href} className="inline-flex rounded-sm border border-border px-2 py-0.5 font-mono text-xs text-foreground hover:bg-muted/60">{label}</a>;
|
||||
}
|
||||
return <span className="inline-flex rounded-sm border border-border px-2 py-0.5 font-mono text-xs text-muted-foreground">{label}</span>;
|
||||
}
|
||||
|
||||
function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteraction }) {
|
||||
const summary = buildIssueThreadInteractionSummary(interaction);
|
||||
let detail: ReactNode = null;
|
||||
|
||||
if (interaction.kind === "ask_user_questions" && interaction.result?.answers) {
|
||||
detail = (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{interaction.payload.questions.map((question) => {
|
||||
const labels = getQuestionAnswerLabels({ question, answers: interaction.result?.answers ?? [] });
|
||||
return (
|
||||
<li key={question.id} className="text-xs text-muted-foreground">
|
||||
<span className="text-foreground">{question.prompt}</span>
|
||||
<span className="block truncate">{labels.length > 0 ? labels.join(", ") : "No answer"}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
} else if (interaction.kind === "request_checkbox_confirmation") {
|
||||
const labels = getCheckboxConfirmationSelectedLabels({ payload: interaction.payload, result: interaction.result });
|
||||
detail = labels.length > 0 ? <p className="mt-1 text-xs text-muted-foreground">{labels.join(", ")}</p> : null;
|
||||
} else if (interaction.kind === "request_item_verdicts") {
|
||||
const progress = getItemVerdictProgress({ payload: interaction.payload, result: interaction.result });
|
||||
detail = progress.decided > 0 ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{progress.approved} approved · {progress.rejected} rejected{progress.deferred > 0 ? ` · ${progress.deferred} deferred` : ""}
|
||||
</p>
|
||||
) : null;
|
||||
} else if (interaction.kind === "request_confirmation" && interaction.result?.reason) {
|
||||
detail = <p className="mt-1 text-xs text-muted-foreground">{interaction.result.reason}</p>;
|
||||
} else if (interaction.kind === "suggest_tasks" && interaction.result?.rejectionReason) {
|
||||
detail = <p className="mt-1 text-xs text-muted-foreground">{interaction.result.rejectionReason}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={interaction.status} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-foreground">{summary}</p>
|
||||
{detail}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionOption({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
mode,
|
||||
onClick,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
selected: boolean;
|
||||
mode: "single" | "multi";
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
role={mode === "single" ? "radio" : "checkbox"}
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-sm border px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected ? "border-primary/60 bg-primary/8" : "border-border/70 bg-background/50 hover:bg-muted/50",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border",
|
||||
mode === "single" ? "rounded-full" : "rounded-sm",
|
||||
selected ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{selected ? (mode === "single" ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : <Check className="h-3 w-3" />) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
|
||||
{description ? <span className="block text-xs leading-4 text-muted-foreground">{description}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, string[]>>(() =>
|
||||
Object.fromEntries((interaction.result?.answers ?? []).map((answer) => [answer.questionId, [...answer.optionIds]])),
|
||||
);
|
||||
const [draftOtherAnswers, setDraftOtherAnswers] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries((interaction.result?.answers ?? []).filter((answer) => answer.otherText).map((answer) => [answer.questionId, answer.otherText ?? ""])),
|
||||
);
|
||||
const [otherActiveQuestions, setOtherActiveQuestions] = useState<Record<string, boolean>>(() =>
|
||||
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<string | null>(null);
|
||||
|
||||
useEffect(() => setPage((current) => Math.min(current, Math.max(questions.length - 1, 0))), [questions.length]);
|
||||
|
||||
const question = questions[page];
|
||||
if (!question) return <p className="text-sm text-muted-foreground">No answerable questions were provided.</p>;
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>Question {page + 1} of {questions.length}</span>
|
||||
<span>{question.selectionMode === "multi" ? "Choose all that apply" : "Choose one"}{question.required ? " · Required" : " · Optional"}</span>
|
||||
</div>
|
||||
<p id={`${interaction.id}-${question.id}-prompt`} className="text-sm font-medium leading-5 text-foreground">{question.prompt}</p>
|
||||
{question.helpText ? <p className="mt-1 text-xs leading-4 text-muted-foreground">{question.helpText}</p> : null}
|
||||
<div
|
||||
className="mt-3 grid gap-1.5"
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => {
|
||||
const isFreeText = option.freeText === true;
|
||||
const optionSelected = isFreeText ? otherActive : selected.includes(option.id);
|
||||
return (
|
||||
<div key={option.id} className="space-y-1.5">
|
||||
<QuestionOption
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={optionSelected}
|
||||
mode={question.selectionMode}
|
||||
onClick={() => toggleOption(option.id, isFreeText)}
|
||||
/>
|
||||
{isFreeText && optionSelected ? (
|
||||
<Textarea
|
||||
aria-label={`Describe your answer for ${question.prompt}`}
|
||||
value={otherText}
|
||||
onChange={(event) => setDraftOtherAnswers((current) => ({ ...current, [question.id]: event.target.value }))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-16 resize-y bg-background text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasFreeTextOption ? (
|
||||
<div className="space-y-1.5">
|
||||
<QuestionOption
|
||||
id={`${interaction.id}-${question.id}-other`}
|
||||
label="Other"
|
||||
selected={otherActive}
|
||||
mode={question.selectionMode}
|
||||
onClick={() => toggleOption(OTHER_ANSWER_ID)}
|
||||
/>
|
||||
{otherActive ? (
|
||||
<Textarea
|
||||
aria-label={`Other answer for ${question.prompt}`}
|
||||
value={otherText}
|
||||
onChange={(event) => setDraftOtherAnswers((current) => ({ ...current, [question.id]: event.target.value }))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-16 resize-y bg-background text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<InteractionActionError message={actionError} />
|
||||
<ActionRow hint={`${page + 1} / ${questions.length}`}>
|
||||
{onCancelInteraction ? (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working || cancelling} onClick={() => void cancel()}>
|
||||
{cancelling ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null} Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{page > 0 ? (
|
||||
<Button type="button" size="sm" variant="outline" disabled={working || cancelling} onClick={() => setPage((current) => current - 1)}>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" /> Back
|
||||
</Button>
|
||||
) : null}
|
||||
{page < questions.length - 1 ? (
|
||||
<Button type="button" size="sm" disabled={!pageComplete || working || cancelling} onClick={() => setPage((current) => current + 1)}>
|
||||
Next <ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={!allRequiredComplete || working || cancelling || !onSubmitInteractionAnswers} onClick={() => void submit()}>
|
||||
{working ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}
|
||||
{interaction.payload.submitLabel ?? "Submit answers"}
|
||||
</Button>
|
||||
)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationCard({
|
||||
interaction,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
errorMessage,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
|
||||
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
|
||||
externalReferences?: SharedInteractionProps["externalReferences"];
|
||||
errorMessage: (error: unknown) => string;
|
||||
}) {
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const collectsRejectReason = Boolean(
|
||||
interaction.payload.rejectRequiresReason
|
||||
|| interaction.payload.allowDeclineReason
|
||||
|| interaction.payload.declineReasonPlaceholder,
|
||||
);
|
||||
|
||||
async function resolve(action: "accept" | "reject") {
|
||||
if (action === "reject" && interaction.payload.rejectRequiresReason && !reason.trim()) return;
|
||||
setWorking(action);
|
||||
setActionError(null);
|
||||
try {
|
||||
if (action === "accept") await onAcceptInteraction?.(interaction);
|
||||
else await onRejectInteraction?.(interaction, reason.trim() || undefined);
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
|
||||
<CompactTarget interaction={interaction} />
|
||||
</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<Details><MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody></Details>
|
||||
) : null}
|
||||
{rejecting ? (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<label htmlFor={`${interaction.id}-reject-reason`} className="text-xs font-medium text-foreground">
|
||||
{interaction.payload.rejectReasonLabel ?? "What should change?"}{interaction.payload.rejectRequiresReason ? "" : " (optional)"}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${interaction.id}-reject-reason`}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={interaction.payload.declineReasonPlaceholder ?? "Add a short note"}
|
||||
className="min-h-16 resize-y bg-background text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
<ActionRow>
|
||||
{rejecting ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null} onClick={() => setRejecting(false)}>Back</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={working !== null || (Boolean(interaction.payload.rejectRequiresReason) && !reason.trim()) || !onRejectInteraction} onClick={() => void resolve("reject")}>
|
||||
{working === "reject" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}
|
||||
{interaction.payload.rejectLabel ?? "Reject"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null || !onRejectInteraction} onClick={() => collectsRejectReason ? setRejecting(true) : void resolve("reject")}>
|
||||
{interaction.payload.rejectLabel ?? "Reject"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={working !== null || !onAcceptInteraction} onClick={() => void resolve("accept")}>
|
||||
{working === "accept" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}
|
||||
{interaction.payload.acceptLabel ?? "Approve"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxConfirmationCard({
|
||||
interaction,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
errorMessage,
|
||||
}: {
|
||||
interaction: RequestCheckboxConfirmationInteraction;
|
||||
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
|
||||
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
|
||||
externalReferences?: SharedInteractionProps["externalReferences"];
|
||||
errorMessage: (error: unknown) => string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(() => new Set(interaction.payload.defaultSelectedOptionIds ?? []));
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const collectsRejectReason = Boolean(
|
||||
interaction.payload.rejectRequiresReason
|
||||
|| interaction.payload.allowDeclineReason
|
||||
|| interaction.payload.declineReasonPlaceholder,
|
||||
);
|
||||
const minimum = interaction.payload.minSelected ?? 0;
|
||||
const maximum = interaction.payload.maxSelected ?? Number.POSITIVE_INFINITY;
|
||||
const validCount = selected.size >= minimum && selected.size <= maximum;
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < maximum) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(action: "accept" | "reject") {
|
||||
if (action === "accept" && !validCount) return;
|
||||
if (action === "reject" && interaction.payload.rejectRequiresReason && !reason.trim()) return;
|
||||
setWorking(action);
|
||||
setActionError(null);
|
||||
try {
|
||||
if (action === "accept") await onAcceptInteraction?.(interaction, undefined, [...selected]);
|
||||
else await onRejectInteraction?.(interaction, reason.trim() || undefined);
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
const countHint = maximum < Number.POSITIVE_INFINITY
|
||||
? `${selected.size} selected · choose ${minimum}–${maximum}`
|
||||
: minimum > 0 ? `${selected.size} selected · at least ${minimum}` : `${selected.size} selected`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
|
||||
<CompactTarget interaction={interaction} />
|
||||
</div>
|
||||
<div className="mt-3 grid gap-1" role="group" aria-label={interaction.payload.prompt}>
|
||||
{interaction.payload.options.map((option) => (
|
||||
<label key={option.id} className="flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-muted/50">
|
||||
<Checkbox checked={selected.has(option.id)} onCheckedChange={() => toggle(option.id)} className="mt-0.5" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm leading-5 text-foreground">{option.label}</span>
|
||||
{option.description ? <span className="block text-xs leading-4 text-muted-foreground">{option.description}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<Details><MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody></Details>
|
||||
) : null}
|
||||
{rejecting ? (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<label htmlFor={`${interaction.id}-reject-reason`} className="text-xs font-medium text-foreground">
|
||||
{interaction.payload.rejectReasonLabel ?? "What should change?"}{interaction.payload.rejectRequiresReason ? "" : " (optional)"}
|
||||
</label>
|
||||
<Textarea id={`${interaction.id}-reject-reason`} value={reason} onChange={(event) => setReason(event.target.value)} placeholder={interaction.payload.declineReasonPlaceholder ?? "Add a short note"} className="min-h-16 resize-y bg-background text-sm" autoFocus />
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
<ActionRow hint={countHint}>
|
||||
{rejecting ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null} onClick={() => setRejecting(false)}>Back</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={working !== null || (Boolean(interaction.payload.rejectRequiresReason) && !reason.trim()) || !onRejectInteraction} onClick={() => void resolve("reject")}>
|
||||
{working === "reject" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}{interaction.payload.rejectLabel ?? "Decline"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null || !onRejectInteraction} onClick={() => collectsRejectReason ? setRejecting(true) : void resolve("reject")}>{interaction.payload.rejectLabel ?? "Decline"}</Button>
|
||||
<Button type="button" size="sm" disabled={working !== null || !validCount || !onAcceptInteraction} onClick={() => void resolve("accept")}>
|
||||
{working === "accept" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}{interaction.payload.acceptLabel ?? "Confirm selection"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestedTaskRow({
|
||||
node,
|
||||
depth,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
node: SuggestedTaskTreeNode;
|
||||
depth: number;
|
||||
selected: Set<string>;
|
||||
onToggle: (node: SuggestedTaskTreeNode) => void;
|
||||
}) {
|
||||
if (node.task.hiddenInPreview) return null;
|
||||
const hiddenCount = collectSuggestedTaskClientKeys(node).filter((key) => key !== node.task.clientKey && node.children.some((child) => child.task.clientKey === key && child.task.hiddenInPreview)).length;
|
||||
return (
|
||||
<li>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-muted/50" style={{ paddingLeft: `${depth * 16 + 8}px` }}>
|
||||
<Checkbox checked={selected.has(node.task.clientKey)} onCheckedChange={() => onToggle(node)} className="mt-0.5" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm leading-5 text-foreground">{node.task.title}</span>
|
||||
{hiddenCount > 0 ? <span className="block text-xs text-muted-foreground">+ {hiddenCount} hidden follow-up</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
{node.children.length > 0 ? (
|
||||
<ul>{node.children.map((child) => <SuggestedTaskRow key={child.task.clientKey} node={child} depth={depth + 1} selected={selected} onToggle={onToggle} />)}</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestedTasksCard({
|
||||
interaction,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
errorMessage,
|
||||
}: {
|
||||
interaction: SuggestTasksInteraction;
|
||||
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
|
||||
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
|
||||
errorMessage: (error: unknown) => string;
|
||||
}) {
|
||||
const roots = useMemo(() => buildSuggestedTaskTree(interaction.payload.tasks), [interaction.payload.tasks]);
|
||||
const taskByKey = useMemo(() => new Map(interaction.payload.tasks.map((task) => [task.clientKey, task])), [interaction.payload.tasks]);
|
||||
const [selected, setSelected] = useState(() => new Set(interaction.payload.tasks.map((task) => task.clientKey)));
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
function toggle(node: SuggestedTaskTreeNode) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
const subtree = collectSuggestedTaskClientKeys(node);
|
||||
if (next.has(node.task.clientKey)) subtree.forEach((key) => next.delete(key));
|
||||
else {
|
||||
subtree.forEach((key) => next.add(key));
|
||||
let parentKey = node.task.parentClientKey;
|
||||
while (parentKey) {
|
||||
next.add(parentKey);
|
||||
parentKey = taskByKey.get(parentKey)?.parentClientKey;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function resolve(action: "accept" | "reject") {
|
||||
setWorking(action);
|
||||
setActionError(null);
|
||||
try {
|
||||
if (action === "accept") await onAcceptInteraction?.(interaction, [...selected]);
|
||||
else await onRejectInteraction?.(interaction, reason.trim() || undefined);
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error));
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm leading-5 text-foreground">Select the tasks to create.</p>
|
||||
<ul className="mt-2" aria-label="Suggested tasks">{roots.map((node) => <SuggestedTaskRow key={node.task.clientKey} node={node} depth={0} selected={selected} onToggle={toggle} />)}</ul>
|
||||
{rejecting ? (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<label htmlFor={`${interaction.id}-reject-reason`} className="text-xs font-medium text-foreground">Why should these tasks change? (optional)</label>
|
||||
<Textarea id={`${interaction.id}-reject-reason`} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Add a short note" className="min-h-16 resize-y bg-background text-sm" autoFocus />
|
||||
</div>
|
||||
) : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
<ActionRow hint={`${selected.size} of ${interaction.payload.tasks.length} selected`}>
|
||||
{rejecting ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null} onClick={() => setRejecting(false)}>Back</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={working !== null || !onRejectInteraction} onClick={() => void resolve("reject")}>
|
||||
{working === "reject" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}Send back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={working !== null || !onRejectInteraction} onClick={() => setRejecting(true)}>Revise</Button>
|
||||
<Button type="button" size="sm" disabled={working !== null || selected.size === 0 || !onAcceptInteraction} onClick={() => void resolve("accept")}>
|
||||
{working === "accept" ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}Create selected
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type VerdictDraft = { verdict: RequestItemVerdictValue; reason: string };
|
||||
|
||||
function ItemVerdictsCard({
|
||||
interaction,
|
||||
onSubmitInteractionVerdicts,
|
||||
externalReferences,
|
||||
errorMessage,
|
||||
}: {
|
||||
interaction: RequestItemVerdictsInteraction;
|
||||
onSubmitInteractionVerdicts?: SharedInteractionProps["onSubmitInteractionVerdicts"];
|
||||
externalReferences?: SharedInteractionProps["externalReferences"];
|
||||
errorMessage: (error: unknown) => string;
|
||||
}) {
|
||||
const items = interaction.payload.items;
|
||||
const resolvedById = useMemo(() => new Map((interaction.result?.items ?? []).map((item) => [item.id, item])), [interaction.result]);
|
||||
const firstPendingIndex = Math.max(items.findIndex((item) => !resolvedById.has(item.id)), 0);
|
||||
const [page, setPage] = useState(firstPendingIndex);
|
||||
const [drafts, setDrafts] = useState<Map<string, VerdictDraft>>(new Map());
|
||||
const [working, setWorking] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const enabledVerdicts = interaction.payload.verdicts ?? ["approve", "reject"];
|
||||
const requireReasonOn = new Set(interaction.payload.requireReasonOn ?? ["reject"]);
|
||||
|
||||
useEffect(() => setPage((current) => Math.min(current, Math.max(items.length - 1, 0))), [items.length]);
|
||||
useEffect(() => {
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
let changed = false;
|
||||
for (const id of resolvedById.keys()) {
|
||||
if (next.delete(id)) changed = true;
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
const nextPending = items.findIndex((candidate) => !resolvedById.has(candidate.id));
|
||||
if (nextPending >= 0) setPage(nextPending);
|
||||
}, [items, resolvedById]);
|
||||
const item = items[page];
|
||||
if (!item) return <p className="text-sm text-muted-foreground">No review items were provided.</p>;
|
||||
const resolved = resolvedById.get(item.id);
|
||||
const draft = drafts.get(item.id);
|
||||
const itemHref = item.href ? normalizeRequestConfirmationTargetHref(item.href) : null;
|
||||
const pageComplete = Boolean(resolved || (draft && (!requireReasonOn.has(draft.verdict) || draft.reason.trim())));
|
||||
|
||||
function setVerdict(verdict: RequestItemVerdictValue) {
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
const existing = next.get(item.id);
|
||||
next.set(item.id, { verdict, reason: existing?.reason ?? "" });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setReason(reason: string) {
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
const existing = next.get(item.id);
|
||||
if (existing) next.set(item.id, { ...existing, reason });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!onSubmitInteractionVerdicts) return;
|
||||
const verdicts = [...drafts].map(([id, value]) => ({ id, verdict: value.verdict, ...(value.reason.trim() ? { reason: value.reason.trim() } : {}) }));
|
||||
if (verdicts.length === 0 || verdicts.some((entry) => requireReasonOn.has(entry.verdict) && !entry.reason)) return;
|
||||
setWorking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await onSubmitInteractionVerdicts(interaction, verdicts);
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allDraftsValid = drafts.size > 0 && [...drafts.values()].every(
|
||||
(candidate) => !requireReasonOn.has(candidate.verdict) || candidate.reason.trim(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>Item {page + 1} of {items.length}</span>
|
||||
<span>{drafts.size + resolvedById.size} decided</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-5 text-foreground">{item.label}</p>
|
||||
{item.description ? <p className="text-xs leading-4 text-muted-foreground">{item.description}</p> : null}
|
||||
</div>
|
||||
{itemHref ? <a href={itemHref} className="text-xs font-medium text-primary hover:underline">Open</a> : null}
|
||||
</div>
|
||||
{item.previewMarkdown ? (
|
||||
<div className="mt-2 rounded-sm bg-muted/40 px-2.5 py-2 text-xs"><MarkdownBody externalReferences={externalReferences}>{item.previewMarkdown}</MarkdownBody></div>
|
||||
) : null}
|
||||
{resolved ? (
|
||||
<p className="mt-3 inline-flex rounded-sm border border-border px-2 py-1 text-xs capitalize text-muted-foreground">{resolved.verdict}{resolved.reason ? ` · ${resolved.reason}` : ""}</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={`Verdict for ${item.label}`}>
|
||||
{enabledVerdicts.map((verdict) => (
|
||||
<Button key={verdict} type="button" size="sm" variant={draft?.verdict === verdict ? "secondary" : "outline"} aria-pressed={draft?.verdict === verdict} onClick={() => setVerdict(verdict)} className="capitalize">
|
||||
{verdict}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!resolved && draft && requireReasonOn.has(draft.verdict) ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<label htmlFor={`${interaction.id}-${item.id}-reason`} className="text-xs font-medium text-foreground">{interaction.payload.reasonLabel ?? "Reason"}</label>
|
||||
<Textarea id={`${interaction.id}-${item.id}-reason`} value={draft.reason} onChange={(event) => setReason(event.target.value)} placeholder="Add a short reason" className="min-h-16 resize-y bg-background text-sm" autoFocus />
|
||||
</div>
|
||||
) : null}
|
||||
{interaction.payload.detailsMarkdown ? <Details><MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody></Details> : null}
|
||||
<InteractionActionError message={actionError} />
|
||||
<ActionRow hint={`${page + 1} / ${items.length}`}>
|
||||
{page > 0 ? <Button type="button" size="sm" variant="outline" disabled={working} onClick={() => setPage((current) => current - 1)}><ChevronLeft aria-hidden className="h-4 w-4" /> Back</Button> : null}
|
||||
{page < items.length - 1 ? (
|
||||
<>
|
||||
{drafts.size > 0 ? <Button type="button" size="sm" variant="outline" disabled={!allDraftsValid || working || !onSubmitInteractionVerdicts} onClick={() => void submit()}>Apply {drafts.size}</Button> : null}
|
||||
<Button type="button" size="sm" disabled={!pageComplete || working} onClick={() => setPage((current) => current + 1)}>Next <ChevronRight aria-hidden className="h-4 w-4" /></Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={!allDraftsValid || working || !onSubmitInteractionVerdicts} onClick={() => void submit()}>
|
||||
{working ? <Loader2 aria-hidden className="h-4 w-4 animate-spin" /> : null}Apply decisions
|
||||
</Button>
|
||||
)}
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatCompactInteractionCard({
|
||||
interaction,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
externalReferences,
|
||||
}: TaskChatCompactInteractionCardProps) {
|
||||
const creatorLabel = interaction.createdByAgentId
|
||||
? agentMap?.get(interaction.createdByAgentId)?.name
|
||||
: interaction.createdByUserId
|
||||
? interaction.createdByUserId === currentUserId ? "You" : userLabelMap?.get(interaction.createdByUserId)
|
||||
: null;
|
||||
const addresseeLabel = interaction.addresseeAgentId ? agentMap?.get(interaction.addresseeAgentId)?.name : null;
|
||||
const audience = describeInteractionAudience({ interaction, creatorLabel, addresseeLabel });
|
||||
const errorMessage = (error: unknown) => interactionResolutionErrorMessage(error, audience);
|
||||
const audienceLabel = interaction.status === "pending" && !audience.isOpen ? audience.shortSummary : null;
|
||||
|
||||
return (
|
||||
<InteractionShell interaction={interaction} audienceLabel={audienceLabel}>
|
||||
{interaction.status !== "pending" ? (
|
||||
<ResolvedInteraction interaction={interaction} />
|
||||
) : interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsCard interaction={interaction} onSubmitInteractionAnswers={onSubmitInteractionAnswers} onCancelInteraction={onCancelInteraction} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<ConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<CheckboxConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestedTasksCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} errorMessage={errorMessage} />
|
||||
) : (
|
||||
<ItemVerdictsCard interaction={interaction} onSubmitInteractionVerdicts={onSubmitInteractionVerdicts} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
)}
|
||||
</InteractionShell>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IssueThreadInteraction, RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { expiredSecretProposalInteraction } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import {
|
||||
expiredSecretProposalInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingRequestItemVerdictsInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
||||
import { TaskChatThreadView } from "./TaskChatThreadView";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
@@ -46,7 +51,7 @@ function createRequestConfirmation(
|
||||
}
|
||||
|
||||
function interactionItem(
|
||||
interaction: RequestConfirmationInteraction,
|
||||
interaction: IssueThreadInteraction,
|
||||
): TaskChatInteractionItem {
|
||||
return { id: `interaction:${interaction.id}`, kind: "interaction", interaction };
|
||||
}
|
||||
@@ -82,7 +87,7 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(container.textContent).toContain("Approve the plan");
|
||||
});
|
||||
|
||||
it("puts the primary CTA on the right via a reversed action row", () => {
|
||||
it("puts the primary CTA at the right edge of the compact action row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
@@ -99,7 +104,77 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(reject).not.toBeUndefined();
|
||||
const row = approve?.parentElement;
|
||||
expect(row).toBe(reject?.parentElement);
|
||||
expect(row?.className).toContain("flex-row-reverse");
|
||||
expect(Array.from(row?.querySelectorAll("button") ?? []).at(-1)).toBe(approve);
|
||||
expect(container.textContent).not.toContain("proposed by");
|
||||
expect(container.textContent).not.toContain("Review and approve the latest plan.");
|
||||
});
|
||||
|
||||
it("shows one question at a time and preserves answers across pages", async () => {
|
||||
const submit = vi.fn();
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard
|
||||
item={interactionItem(pendingAskUserQuestionsInteraction)}
|
||||
onSubmitInteractionAnswers={submit}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Question 1 of 2");
|
||||
expect(container.textContent).toContain("How aggressive should");
|
||||
expect(container.textContent).not.toContain("What should the answered-state card emphasize");
|
||||
|
||||
const firstAnswer = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Only collapse hidden descendants"));
|
||||
await act(async () => firstAnswer?.click());
|
||||
const next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Next");
|
||||
await act(async () => next?.click());
|
||||
|
||||
expect(container.textContent).toContain("Question 2 of 2");
|
||||
expect(container.textContent).toContain("What should the answered-state card emphasize");
|
||||
expect(container.textContent).not.toContain("How aggressive should");
|
||||
|
||||
const secondAnswer = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Inline answer pills"));
|
||||
await act(async () => secondAnswer?.click());
|
||||
const send = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Send answers");
|
||||
await act(async () => send?.click());
|
||||
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
expect(submit.mock.calls[0]?.[1]).toEqual([
|
||||
{ questionId: "collapse-depth", optionIds: ["visible-root"] },
|
||||
{ questionId: "post-submit-summary", optionIds: ["answers-inline"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("paginates item verdicts instead of expanding the whole review set", async () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard
|
||||
item={interactionItem(pendingRequestItemVerdictsInteraction)}
|
||||
onSubmitInteractionVerdicts={() => undefined}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Item 1 of 5");
|
||||
expect(container.textContent).toContain("Spring launch recap");
|
||||
expect(container.textContent).not.toContain("Monthly changelog digest");
|
||||
|
||||
const approve = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "approve");
|
||||
await act(async () => approve?.click());
|
||||
const next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Next");
|
||||
await act(async () => next?.click());
|
||||
|
||||
expect(container.textContent).toContain("Item 2 of 5");
|
||||
expect(container.textContent).toContain("Monthly changelog digest");
|
||||
expect(container.textContent).not.toContain("Spring launch recap");
|
||||
});
|
||||
|
||||
it("demotes an expired confirmation to a marker row", () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
|
||||
import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
|
||||
@@ -10,17 +12,15 @@ export interface TaskChatInteractionCardProps extends InteractionCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* v7-grammar wrapper for issue-thread interactions (plan confirmation,
|
||||
* question card, suggested tasks…) inside the redesigned thread: cards are the
|
||||
* only rows that get bubble-level emphasis, so the shared
|
||||
* IssueThreadInteractionCard renders full-width with the standard bubble
|
||||
* entrance. Generic expired confirmations demote to a marker row — superseded
|
||||
* asks are history, not calls to action (legacy-thread parity). Secret proposals
|
||||
* remain full receipts because their safe binding metadata and recovery guidance
|
||||
* are part of the terminal outcome.
|
||||
* Task-page presentation for durable issue interactions. The new task view uses
|
||||
* a compact, protocol-card-aligned renderer with paginated questions and
|
||||
* verdicts; the classic issue thread keeps its existing card. Tool approvals and
|
||||
* secret proposals deliberately retain the specialized shared renderer because
|
||||
* its risk, expiry, and safe-binding receipts are security-relevant.
|
||||
*/
|
||||
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
|
||||
const interaction = item.interaction;
|
||||
if (shouldHideInteractionCard(interaction)) return null;
|
||||
if (
|
||||
interaction.kind === "request_confirmation"
|
||||
&& interaction.status === "expired"
|
||||
@@ -38,13 +38,19 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract
|
||||
/>
|
||||
);
|
||||
}
|
||||
const useSpecializedRenderer = interaction.kind === "request_confirmation"
|
||||
&& Boolean(interaction.payload.toolAction || interaction.payload.secretProposal);
|
||||
return (
|
||||
<div
|
||||
id={`interaction-${interaction.id}`}
|
||||
className="tc-enter-bubble w-full"
|
||||
data-testid="task-chat-interaction"
|
||||
>
|
||||
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
|
||||
{useSpecializedRenderer ? (
|
||||
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
|
||||
) : (
|
||||
<TaskChatCompactInteractionCard interaction={interaction} {...cardProps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,4 +115,32 @@ describe("TaskChatProtocolCard", () => {
|
||||
await act(async () => button?.click());
|
||||
expect(onDecision).toHaveBeenCalledWith({ action: "accept" });
|
||||
});
|
||||
|
||||
it("submits structured runtime input through the production card", async () => {
|
||||
const onDecision = vi.fn().mockResolvedValue(undefined);
|
||||
renderCard(root, {
|
||||
id: "input-request",
|
||||
kind: "protocol",
|
||||
surface: "runtime_request",
|
||||
runId: "run-1",
|
||||
requestId: "request-input",
|
||||
requestKind: "user_input",
|
||||
turnId: "turn-1",
|
||||
requestType: "input",
|
||||
status: "pending",
|
||||
prompt: "Which environment should the run target?",
|
||||
choices: [],
|
||||
fields: [{ name: "environment", label: "Environment", placeholder: "staging" }],
|
||||
}, onDecision);
|
||||
const textarea = container.querySelector("textarea")!;
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(textarea, "production");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const submit = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "Submit response");
|
||||
await act(async () => submit?.click());
|
||||
expect(onDecision).toHaveBeenCalledWith({ action: "submit", values: { environment: "production" } });
|
||||
expect(container.textContent).toContain("Submitting…");
|
||||
});
|
||||
});
|
||||
@@ -350,12 +350,17 @@ function RuntimeRequestCard({ item, onDecision }: { item: TaskChatRuntimeRequest
|
||||
<Button type="submit" size="sm" disabled={!onDecision || submitting || !canSubmitInput}>
|
||||
{submitting ? "Submitting…" : "Submit response"}
|
||||
</Button>
|
||||
{item.choices.some((choice) => choice.key === "decline") ? (
|
||||
<Button type="button" size="sm" variant="outline" disabled={!onDecision || submitting} onClick={() => void submit({ action: "decline" })}>
|
||||
Deny
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="sm" variant="outline" disabled={!onDecision || submitting} onClick={() => void submit({ action: "cancel" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : item.choices.length > 0 ? (
|
||||
) : item.status === "pending" && item.choices.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.choices.map((choice) => (
|
||||
<Button
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
import type { TaskChatItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem } from "./task-chat-model";
|
||||
|
||||
describe("TaskChatRunnerTurn", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -22,9 +22,14 @@ describe("TaskChatRunnerTurn", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render(
|
||||
const render = (
|
||||
items: TaskChatItem[],
|
||||
status = "running",
|
||||
runId = "run-1",
|
||||
onRuntimeRequestDecision?: (item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision) => void,
|
||||
) => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
|
||||
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} onRuntimeRequestDecision={onRuntimeRequestDecision} />
|
||||
</ThemeProvider>,
|
||||
));
|
||||
|
||||
@@ -103,4 +108,29 @@ describe("TaskChatRunnerTurn", () => {
|
||||
expect(container.textContent).not.toContain("Turn completed");
|
||||
expect(container.textContent).not.toContain("10,520");
|
||||
});
|
||||
|
||||
it("keeps a pending runtime request actionable while the run history is folded", async () => {
|
||||
const onDecision = vi.fn();
|
||||
render([{
|
||||
id: "request",
|
||||
kind: "protocol",
|
||||
surface: "runtime_request",
|
||||
runId: "run-1",
|
||||
requestId: "request-1",
|
||||
requestKind: "command_approval",
|
||||
turnId: "turn-1",
|
||||
requestType: "permission",
|
||||
status: "pending",
|
||||
prompt: "Allow command?",
|
||||
choices: [{ key: "accept", label: "Allow once" }],
|
||||
fields: [],
|
||||
}], "running", "run-1", onDecision);
|
||||
|
||||
const cards = Array.from(container.querySelectorAll('[data-testid="task-chat-runtime-request"]'));
|
||||
const visibleCard = cards.find((card) => card.closest(".tc-turn-fold") === null);
|
||||
expect(visibleCard).toBeDefined();
|
||||
const button = Array.from(visibleCard!.querySelectorAll("button")).find((candidate) => candidate.textContent === "Allow once");
|
||||
await act(async () => button?.click());
|
||||
expect(onDecision).toHaveBeenCalledWith(expect.objectContaining({ requestId: "request-1" }), { action: "accept" });
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ export function TaskChatRunnerTurn({
|
||||
&& item.surface === "runtime_request"
|
||||
&& item.status === "pending",
|
||||
);
|
||||
const visibleHistoryItems = pendingRuntimeRequest
|
||||
? historyItems.filter((item) => item.id !== pendingRuntimeRequest.id)
|
||||
: historyItems;
|
||||
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
|
||||
const observedFinal = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
|
||||
// A reconnect/replay can briefly rebuild the transcript without the final
|
||||
@@ -117,28 +120,28 @@ export function TaskChatRunnerTurn({
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<TaskChatLiveTail
|
||||
items={historyItems}
|
||||
items={visibleHistoryItems}
|
||||
excludeFinal
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!open ? (
|
||||
{!open && progress ? (
|
||||
<div className="flex min-w-0 flex-col py-1">
|
||||
{progress ? (
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingRuntimeRequest ? (
|
||||
<TaskChatProtocolCard
|
||||
item={pendingRuntimeRequest}
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
) : !final ? <CurrentActivity items={items} /> : null}
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingRuntimeRequest ? (
|
||||
<div className="py-1">
|
||||
<TaskChatProtocolCard
|
||||
item={pendingRuntimeRequest}
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
</div>
|
||||
) : !open && !final ? <CurrentActivity items={items} /> : null}
|
||||
{final ? (
|
||||
<div className="tc-enter-bubble w-full" data-testid="task-chat-final-response">
|
||||
<div className="break-words px-1 py-2 text-sm text-foreground" data-testid="task-chat-agent-bubble">
|
||||
|
||||
@@ -93,6 +93,19 @@ const resultEvents = [
|
||||
prp("run.terminal", { turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "needs_review" }),
|
||||
];
|
||||
|
||||
const runtimeInputEvent = prp("runtime_request.created", {
|
||||
request: {
|
||||
requestId: "request-input",
|
||||
requestKind: "user_input",
|
||||
turnId: "turn-1",
|
||||
type: "item/tool/requestUserInput",
|
||||
status: "pending",
|
||||
prompt: "Which environment should the verification target?",
|
||||
details: { fields: [{ name: "environment", label: "Environment", placeholder: "staging" }] },
|
||||
actions: ["submit", "decline", "cancel"],
|
||||
},
|
||||
});
|
||||
|
||||
function TaskPageFrame({ children, composerDisabledReason }: { children: ReactNode; composerDisabledReason?: string }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-4 text-foreground">
|
||||
@@ -127,6 +140,13 @@ function RuntimeRequestStory() {
|
||||
return <TaskPageFrame composerDisabledReason="Waiting for a runtime decision"><TaskChatThreadView scroll={false} header={<TaskHeader />} items={items} onRuntimeRequestDecision={resolve} /></TaskPageFrame>;
|
||||
}
|
||||
|
||||
function RuntimeInputStory() {
|
||||
const initial = useMemo(() => protocolItems([runtimeInputEvent]), []);
|
||||
const [items, setItems] = useState(initial);
|
||||
const resolve = (request: TaskChatRuntimeRequestItem) => setItems((current) => current.map((item) => item.id === request.id && item.kind === "protocol" && item.surface === "runtime_request" ? { ...item, status: "resolved" } : item));
|
||||
return <TaskPageFrame composerDisabledReason="Waiting for a runtime response"><TaskChatThreadView scroll={false} header={<TaskHeader />} items={items} onRuntimeRequestDecision={resolve} /></TaskPageFrame>;
|
||||
}
|
||||
|
||||
function InteractionStory() {
|
||||
const [question, setQuestion] = useState(pendingAskUserQuestionsInteraction);
|
||||
const items: TaskChatItem[] = [
|
||||
@@ -190,6 +210,14 @@ export const RuntimeRequests: Story = {
|
||||
await expect(within(canvasElement).getByTestId("task-chat-runtime-request")).toHaveTextContent("resolved");
|
||||
},
|
||||
};
|
||||
export const RuntimeInput: Story = {
|
||||
render: () => <RuntimeInputStory />,
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.type(within(canvasElement).getByLabelText("Environment"), "production");
|
||||
await userEvent.click(within(canvasElement).getByRole("button", { name: "Submit response" }));
|
||||
await expect(within(canvasElement).getByTestId("task-chat-runtime-request")).toHaveTextContent("resolved");
|
||||
},
|
||||
};
|
||||
export const Interactions: Story = { render: () => <InteractionStory /> };
|
||||
export const ResultsAndTerminalStates: Story = { render: () => <ThreadStory items={protocolItems(resultEvents.slice(1))} /> };
|
||||
export const DesktopKitchenSink: Story = {
|
||||
@@ -210,9 +238,13 @@ export const PendingQuestions: Story = {
|
||||
render: () => <InteractionStory />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByText("Only collapse hidden descendants"));
|
||||
await userEvent.click(canvas.getByText("Inline answer pills"));
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Send answers" }));
|
||||
const questionCard = canvas.getByText("Resolve open UX decisions before Phase 1").closest("article");
|
||||
if (!questionCard) throw new Error("Question interaction card did not render");
|
||||
const questions = within(questionCard);
|
||||
await userEvent.click(questions.getByText("Only collapse hidden descendants"));
|
||||
await userEvent.click(questions.getByRole("button", { name: "Next" }));
|
||||
await userEvent.click(questions.getByText("Inline answer pills"));
|
||||
await userEvent.click(questions.getByRole("button", { name: "Send answers" }));
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByRole("button", { name: "Send answers" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
Reference in new issue
Block a user