返回 DeepSeek-Reasonix
TranscriptCards.tsx
根目录 / desktop / frontend / src / components / TranscriptCards.tsx
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
145 lines Plain Text