import { useEffect, useId, useRef, useState } from "react"; import type { CapabilityThreadInteractionCard, CapabilityThreadInteractionState, } from "../../../src/issue-thread/types"; import { Chip } from "./primitives"; /** * Native interaction cards (contract §5). All five `request_human_input` * kinds render typed controls; the card never asks the user to type an answer * into the composer. * * The submit handler posts to the package server, which stores the response in * the mock control plane before the runner is resumed. The card only leaves * `submitting` when the server acknowledges — there is no optimistic * resolution here. */ export interface CapabilityInteractionResponse { interactionId: string; outcome: "answered" | "accepted" | "rejected"; result: Record; } const STATE_GLYPH: Record = { pending: "⏳", submitting: "⏳", accepted: "✓", answered: "✓", rejected: "✕", stale_target: "⌛", superseded_by_comment: "⌛", expired: "⌛", withdrawn: "⌛", issue_closed: "⌛", }; const RESOLVED_STATES = new Set([ "accepted", "answered", "rejected", "stale_target", "superseded_by_comment", "expired", "withdrawn", "issue_closed", ]); function stateTone(state: CapabilityThreadInteractionState) { if (state === "accepted" || state === "answered") return "success" as const; if (state === "rejected") return "danger" as const; if (state === "pending" || state === "submitting") return "accent" as const; return undefined; } export function InteractionCard({ card, onRespond, onOpenEvidence, autoFocus, }: { card: CapabilityThreadInteractionCard; onRespond?: (response: CapabilityInteractionResponse) => void; onOpenEvidence?: (section: string, recordId: string) => void; autoFocus?: boolean; }) { const titleId = useId(); const [answers, setAnswers] = useState>({}); const [selected, setSelected] = useState(() => card.payload.kind === "checkbox" ? card.payload.options.filter((option) => option.defaultSelected).map((option) => option.id) : card.payload.kind === "suggest_tasks" ? card.payload.tasks.map((task) => task.id) : [], ); const [verdicts, setVerdicts] = useState>({}); const [reasons, setReasons] = useState>({}); const [rejectReason, setRejectReason] = useState(""); const [error, setError] = useState(null); const firstControl = useRef(null); const stateChip = useRef(null); const previousState = useRef(card.state); const resolved = RESOLVED_STATES.has(card.state); const busy = card.state === "submitting"; useEffect(() => { // Contract §9.2: resolving a card moves focus to its state chip. The // controls the user just used unmount on resolve, so without this the // keyboard caret would fall back to `body` exactly when the card changes. const from = previousState.current; previousState.current = card.state; if (from === card.state) return; if (!RESOLVED_STATES.has(from) && RESOLVED_STATES.has(card.state)) { stateChip.current?.focus(); } }, [card.state]); function respond(response: CapabilityInteractionResponse) { setError(null); onRespond?.(response); } function toggle(list: string[], id: string): string[] { return list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id]; } return (

{card.title}

{card.stateLabel}

{card.prompt}

{card.target !== null ? (

Target:{" "} {card.target.label}

) : null} {card.payload.kind === "confirmation" ? (

{card.payload.targetSummary}

) : null} {resolved ? (
{card.resolvedSummary.length > 0 ? (
    {card.resolvedSummary.map((entry) => (
  • {entry}
  • ))}
) : null} {card.reason !== null ?
{card.reason}
: null} {card.supersededBy !== null ? (

{card.supersededBy.label}

) : null}
) : (
{card.payload.kind === "questions" ? card.payload.questions.map((question, index) => (
{question.prompt} {question.control === "text" ? ( setAnswers((current) => ({ ...current, [question.id]: event.target.value })) } /> ) : question.control === "select" ? ( ) : (
{(question.options ?? []).map((option, optionIndex) => ( ))}
)}
)) : null} {card.payload.kind === "checkbox" ? card.payload.options.map((option, index) => ( )) : null} {card.payload.kind === "suggest_tasks" ? card.payload.tasks.map((task) => ( )) : null} {card.payload.kind === "item_verdicts" ? card.payload.items.map((item) => (
{item.title}
{(["approve", "reject", "defer"] as const).map((verdict) => ( ))}
{item.requireReason === true ? (