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