| 1 | // Small transcript row cards: phase lines, steer bubbles, notice cards with |
| 2 | // decision receipts, and compaction cards. |
| 3 | |
| 4 | import { useState } from "react"; |
| 5 | import { CheckCheck, ChevronRight, CirclePlay, ClipboardCheck, FileSearch, Info, TriangleAlert } from "lucide-react"; |
| 6 | import { useT } from "../lib/i18n"; |
| 7 | import type { Item } from "../lib/useController"; |
| 8 | type CompactionItem = Extract<Item, { kind: "compaction" }>; |
| 9 | type NoticeItem = Extract<Item, { kind: "notice" }>; |
| 10 | import type { WireCompletionSummary } from "../lib/types"; |
| 11 | import { TurnResultSummary } from "./TurnResultSummary"; |
| 12 | import { STEER_NOTICE_PREFIX } from "../lib/useController"; |
| 13 | import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard"; |
| 14 | |
| 15 | export function PhaseCard({ id, text }: { id: string; text: string }) { |
| 16 | return <div className="phase" data-entrance={id}><ProcessPhaseIcon size={12} /><span>{text}</span></div>; |
| 17 | } |
| 18 | |
| 19 | // A mid-turn steer is the user's own message, so it renders on the user side |
| 20 | // of the transcript instead of disappearing into the work fold. |
| 21 | export function SteerCard({ id, text }: { id: string; text: string }) { |
| 22 | const t = useT(); |
| 23 | const body = text.startsWith(STEER_NOTICE_PREFIX) ? text.slice(STEER_NOTICE_PREFIX.length) : text; |
| 24 | return ( |
| 25 | <div className="steer-line" data-entrance={id}> |
| 26 | <div className="steer-line__bubble" title={t("transcript.steer")}> |
| 27 | <span className="steer-line__icon" aria-hidden="true">↪</span> |
| 28 | <span className="steer-line__text">{body}</span> |
| 29 | </div> |
| 30 | </div> |
| 31 | ); |
| 32 | } |
| 33 | |
| 34 | function DecisionReceiptLine({ receipt }: { receipt: NonNullable<NoticeItem["decisionReceipt"]> }) { |
| 35 | const t = useT(); |
| 36 | const titleKey = receipt.kind === "ask" |
| 37 | ? "notice.decisionReceiptAsk" |
| 38 | : receipt.kind === "plan" |
| 39 | ? "notice.decisionReceiptPlan" |
| 40 | : receipt.kind === "recovery" |
| 41 | ? "notice.decisionReceiptRecovery" |
| 42 | : "notice.decisionReceiptTool"; |
| 43 | const outcomeKeys: Record<string, string> = { |
| 44 | allow_once: "notice.decisionAllowOnce", |
| 45 | allow_session: "notice.decisionAllowSession", |
| 46 | allow_persistent: "notice.decisionAllowPersistent", |
| 47 | deny: "notice.decisionDeny", |
| 48 | start_execution: "notice.decisionStartExecution", |
| 49 | revise_plan: "notice.decisionRevisePlan", |
| 50 | exit_plan: "notice.decisionExitPlan", |
| 51 | recovery_continue: "notice.decisionRecoveryContinue", |
| 52 | recovery_continue_task: "notice.decisionRecoveryContinueTask", |
| 53 | recovery_revise: "notice.decisionRecoveryRevise", |
| 54 | answered: "notice.decisionAnswered", |
| 55 | }; |
| 56 | const outcome = outcomeKeys[receipt.outcome] |
| 57 | ? t(outcomeKeys[receipt.outcome] as never) |
| 58 | : receipt.outcome || t("notice.decisionReceiptTitle"); |
| 59 | const showOutcome = receipt.kind !== "ask" || receipt.outcome !== "answered"; |
| 60 | return ( |
| 61 | <div className="notice-line__decision-receipt"> |
| 62 | <span className="notice-line__decision-title">{t(titleKey as never)}</span> |
| 63 | {showOutcome && <span className="notice-line__decision-outcome">{outcome}</span>} |
| 64 | {receipt.tool && <code>{receipt.tool}</code>} |
| 65 | {receipt.subject && <span className="notice-line__decision-subject">{receipt.subject}</span>} |
| 66 | </div> |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | export function NoticeCard({ item, onAction, onAccept, onOpenVerification, actionDisabled = false }: { item: NoticeItem; onAction?: () => void; onAccept?: () => void; onOpenVerification?: (summary: WireCompletionSummary) => void; actionDisabled?: boolean }) { |
| 71 | const t = useT(); |
| 72 | const StatusIcon = item.level === "warn" ? TriangleAlert : Info; |
| 73 | const ActionIcon = item.action === "open_changes" ? FileSearch : CirclePlay; |
| 74 | const showVerification = item.variant === "completion" && Boolean(item.completionSummary && onOpenVerification); |
| 75 | const result = item.variant === "completion" ? item.completionSummary : undefined; |
| 76 | const showActions = Boolean((item.action && onAction) || onAccept || showVerification); |
| 77 | return ( |
| 78 | <div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance={item.id} role={item.code === "incomplete_read" ? "status" : undefined}> |
| 79 | {!result && <StatusIcon className="notice-line__icon" size={14} aria-hidden="true" />} |
| 80 | <div className="notice-line__text"> |
| 81 | {result ? <><div className="notice-line__title">{t("notice.completionChangesTitle")}</div><TurnResultSummary summary={result} /></> : item.decisionReceipt ? ( |
| 82 | <DecisionReceiptLine receipt={item.decisionReceipt} /> |
| 83 | ) : ( |
| 84 | <> |
| 85 | {item.title ? <div className="notice-line__title">{item.title}</div> : null} |
| 86 | <div className="notice-line__body">{item.text}</div> |
| 87 | </> |
| 88 | )} |
| 89 | {showActions ? ( |
| 90 | <div className="notice-line__actions"> |
| 91 | {item.action && onAction ? ( |
| 92 | <button className="btn btn--small" type="button" onClick={onAction} disabled={actionDisabled}> |
| 93 | <ActionIcon size={13} aria-hidden="true" /> |
| 94 | <span>{item.action === "recover_context" ? t("notice.protocolRecoveryAction") : item.action === "open_changes" ? t("notice.completionViewChanges") : t("notice.deliveryIncompleteContinue")}</span> |
| 95 | </button> |
| 96 | ) : null} |
| 97 | {showVerification ? ( |
| 98 | <button className="btn btn--small" type="button" onClick={() => item.completionSummary && onOpenVerification?.(item.completionSummary)}> |
| 99 | <ClipboardCheck size={13} aria-hidden="true" /> |
| 100 | <span>{t("notice.completionViewVerification")}</span> |
| 101 | </button> |
| 102 | ) : null} |
| 103 | {onAccept ? ( |
| 104 | <button className="btn btn--small" type="button" onClick={onAccept}> |
| 105 | <CheckCheck size={13} aria-hidden="true" /> |
| 106 | <span>{t("notice.deliveryIncompleteAccept")}</span> |
| 107 | </button> |
| 108 | ) : null} |
| 109 | </div> |
| 110 | ) : null} |
| 111 | {result ? ( |
| 112 | <details className="notice-line__details"> |
| 113 | <summary>{t("notice.details")}</summary> |
| 114 | <pre>{JSON.stringify(result, null, 2)}</pre> |
| 115 | </details> |
| 116 | ) : item.detail ? ( |
| 117 | <details className="notice-line__details"> |
| 118 | <summary>{t("notice.details")}</summary> |
| 119 | <div>{item.detail}</div> |
| 120 | </details> |
| 121 | ) : null} |
| 122 | </div> |
| 123 | </div> |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | export function CompactionCard({ item }: { item: CompactionItem }) { |
| 128 | const t = useT(); |
| 129 | const [open, setOpen] = useState(false); |
| 130 | if (item.pending) { |
| 131 | return <div className="compaction compaction--pending" data-entrance={item.id} data-transcript-layout-variant="static"><ProcessCompactIcon size={12} /><span>{t("compaction.working")}</span></div>; |
| 132 | } |
| 133 | return ( |
| 134 | <div className="compaction" data-entrance={item.id} data-transcript-layout-variant={open ? "compaction-expanded" : "compaction-collapsed"}> |
| 135 | <button type="button" className="compaction__head" onClick={() => { setOpen((v) => !v); }} aria-expanded={open}> |
| 136 | <ProcessCompactIcon size={12} /> |
| 137 | <span>{t("compaction.title")}</span> |
| 138 | <span className="compaction__meta">{t("compaction.messages", { n: item.messages })}{item.trigger ? ` · ${item.trigger}` : ""}</span> |
| 139 | <ChevronRight className={open ? "compaction__chevron--open" : ""} size={12} /> |
| 140 | </button> |
| 141 | {open && <pre className="compaction__body">{item.summary}</pre>} |
| 142 | </div> |
| 143 | ); |
| 144 | } |
| 145 |