返回 DeepSeek-Reasonix
Transcript.tsx
根目录 / desktop / frontend / src / components / Transcript.tsx
1 import { createContext, memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2 import type { ControllerLiveStore, ExtensionItem, Item, LiveStream } from "../lib/useController";
3 import type { CheckpointMeta } from "../lib/types";
4 import type { InvocationMetadataMap } from "../lib/invocationDisplay";
5 import { useT } from "../lib/i18n";
6 import { AssistantMessage, InvocationMetadataContext, TurnActions, UserMessage } from "./Message";
7 import { ProcessBrainIcon, ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
8 import { ToolCard } from "./ToolCard";
9 import { ExtensionCard } from "./ExtensionCard";
10 import { ArrowDown, ChevronRight, CirclePlay, Info, TriangleAlert } from "lucide-react";
11 import { Welcome } from "./Welcome";
12 import { ReadOnlyBatch } from "./ReadOnlyBatch";
13 import { ToolGroup, isCreationGroupableTool, toolGroupKind, type ToolGroupKind } from "./ToolGroup";
14 import { getDisplayMode, onDisplayModeChange, type DisplayMode } from "../lib/displayMode";
15 import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFoldPreference } from "../lib/processFoldPreference";
16 import { STEER_NOTICE_PREFIX, isSteerNoticeText } from "../lib/useController";
17 import { useGSAPCollapse } from "../lib/useGSAPCollapse";
18 import { useEntranceAnimation } from "../lib/useEntranceAnimation";
19 import { useScrollManager } from "../lib/useScrollManager";
20 import { buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
21 import { appendTurnActionCopyText } from "../lib/turnActionCopy";
22 import { displayReasoningText } from "../lib/reasoningDisplay";
23 import { observeScrollContentSize } from "../lib/scrollContentObserver";
24
25 type ToolItem = Extract<Item, { kind: "tool" }>;
26 type AssistantItem = Extract<Item, { kind: "assistant" }>;
27 type NoticeItem = Extract<Item, { kind: "notice" }>;
28 type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
29
30 const QUESTION_NAV_MIN_COUNT = 2;
31 const LiveStreamContext = createContext<LiveStream | undefined>(undefined);
32 type AssistantReasoningDisplay = "normal" | "hide";
33
34 const LiveAssistantMessage = memo(function LiveAssistantMessage({
35 item,
36 defaultExpanded = false,
37 expandWhileStreaming = true,
38 truncateStreamingReasoning = false,
39 creationMode = false,
40 reasoningDisplay = "normal",
41 }: {
42 item: AssistantItem;
43 defaultExpanded?: boolean;
44 expandWhileStreaming?: boolean;
45 truncateStreamingReasoning?: boolean;
46 creationMode?: boolean;
47 reasoningDisplay?: AssistantReasoningDisplay;
48 }) {
49 const live = useContext(LiveStreamContext);
50 const shown = useMemo(
51 () => {
52 const merged =
53 live && live.id === item.id
54 ? {
55 ...item,
56 text: live.text,
57 reasoning: live.reasoning,
58 streaming: true,
59 reasoningComplete: live.reasoningComplete,
60 reasoningDurationMs:
61 live.reasoningStartedAt && live.reasoningCompletedAt && live.reasoningCompletedAt >= live.reasoningStartedAt
62 ? live.reasoningCompletedAt - live.reasoningStartedAt
63 : item.reasoningDurationMs,
64 }
65 : item;
66 if (reasoningDisplay === "hide") {
67 return { ...merged, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
68 }
69 return merged;
70 },
71 [item, live?.id, live?.text, live?.reasoning, live?.reasoningComplete, live?.reasoningStartedAt, live?.reasoningCompletedAt, reasoningDisplay],
72 );
73 return (
74 <AssistantMessage
75 item={shown}
76 defaultExpanded={defaultExpanded}
77 expandWhileStreaming={expandWhileStreaming}
78 truncateStreamingReasoning={truncateStreamingReasoning}
79 creationMode={creationMode}
80 />
81 );
82 });
83
84 function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
85 const t = useT();
86 const live = useContext(LiveStreamContext);
87 const [open, setOpen] = useState(true);
88 const bodyRef = useRef<HTMLDivElement>(null);
89 useGSAPCollapse(bodyRef, open);
90 const shown = live && live.id === item.id
91 ? {
92 reasoning: live.reasoning,
93 streaming: true,
94 reasoningComplete: live.reasoningComplete,
95 }
96 : item;
97 const reasoning = shown.reasoning.trim();
98 if (!reasoning) return null;
99 const visibleReasoning = displayReasoningText(shown.reasoning, {
100 streaming: shown.streaming,
101 truncateStreaming: true,
102 });
103 const running = shown.streaming && !shown.reasoningComplete;
104 return (
105 <div className={`turn-collapse__reasoning-phase${open ? " turn-collapse__reasoning-phase--open" : ""}`}>
106 <button
107 type="button"
108 className="turn-collapse__reasoning-head"
109 data-running={running ? "" : undefined}
110 onClick={() => setOpen((v) => !v)}
111 aria-expanded={open}
112 >
113 <ProcessBrainIcon size={12} />
114 <span>{running ? t("msg.thinkingRunning") : t("msg.thinking")}</span>
115 <ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
116 </button>
117 <div ref={bodyRef} className="turn-collapse__inline-reasoning">{visibleReasoning}</div>
118 </div>
119 );
120 }
121
122 // ── Layer budgets ─────────────────────────────────────────────────────────────
123 // Hot zone: the most recent N user turns are always fully rendered. All data
124 // stays in memory (items[]), so expanding a warm turn is instant — no API call.
125 // Cold zone: a "load more" button paginates the warm zone in batches.
126 //
127 // items[0] ─┐
128 // ... │ Cold zone ─── paginated, shown on "load more"
129 // ├──────────── warmTurnStart
130 // ... │ Warm zone ─── collapsible summary cards (individual expand)
131 // ├──────────── hotStartIdx
132 // items[N] ─┤ Hot zone ─── fully rendered
133 // ... │
134 // items[end] ┘
135
136 const HOT_TURNS = 30;
137 const WARM_PAGE_SIZE = 20; // cold-zone pagination batch
138
139 // ── Helpers ───────────────────────────────────────────────────────────────────
140
141 function turnWorkDurationMs(items: readonly Item[]): number {
142 const persisted = items.reduce((ms, it) => {
143 if (it.kind !== "assistant") return ms;
144 return Math.max(ms, it.workDurationMs ?? 0);
145 }, 0);
146 if (persisted > 0) return persisted;
147 return items.reduce((ms, it) => {
148 if (it.kind === "tool") return ms + (it.durationMs ?? 0);
149 if (it.kind === "assistant") return ms + (it.reasoningDurationMs ?? 0);
150 return ms;
151 }, 0);
152 }
153
154 function useTick(on: boolean): number {
155 const [, setN] = useState(0);
156 useEffect(() => {
157 if (!on) return;
158 const id = window.setInterval(() => setN((n) => n + 1), 1000);
159 return () => window.clearInterval(id);
160 }, [on]);
161 return Date.now();
162 }
163
164 function formatWorkDuration(durationMs: number, t: ReturnType<typeof useT>): string {
165 if (!Number.isFinite(durationMs) || durationMs <= 0) return "";
166 const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
167 const minutes = Math.floor(totalSeconds / 60);
168 const seconds = totalSeconds % 60;
169 if (minutes <= 0) return t("transcript.durationSeconds", { s: totalSeconds });
170 if (seconds <= 0) return t("transcript.durationMinutes", { m: minutes });
171 return t("transcript.durationMinutesSeconds", { m: minutes, s: seconds });
172 }
173
174 function workStatusLabel(durationMs: number, running: boolean, t: ReturnType<typeof useT>): string {
175 const duration = formatWorkDuration(durationMs, t);
176 if (running) {
177 return duration ? t("transcript.workingDuration", { duration }) : t("transcript.working");
178 }
179 return duration ? t("transcript.workedDuration", { duration }) : t("transcript.worked");
180 }
181
182 function assistantReasoningOnly(item: AssistantItem): AssistantItem {
183 return { ...item, text: "" };
184 }
185
186 function assistantAnswerOnly(item: AssistantItem): AssistantItem {
187 return { ...item, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
188 }
189
190 function assistantHasVisibleAnswer(item: AssistantItem, liveId: string | undefined, liveHasAnswerText: boolean): boolean {
191 if (item.text.trim() !== "") return true;
192 return liveId === item.id && liveHasAnswerText;
193 }
194
195 type TurnDisplayParts = {
196 processItems: Item[];
197 outsideItems: Array<NoticeItem | AssistantItem | ExtensionItem>;
198 };
199
200 // Splits a turn by channel, not by position: reasoning, tools, phases, info
201 // notices, and compaction cards are process material and fold; every assistant
202 // message with answer text is model output addressed to the user and stays
203 // outside the fold. Warnings must survive the fold auto-closing on completion,
204 // and steers are the user's own words — neither belongs to the model's work
205 // process.
206 //
207 // The turn is returned as ordered segments so the conversation keeps its real
208 // timeline: process that ran after an answer or steer opens a new segment
209 // (and thus a new fold) instead of being pulled ahead of it. Warn notices and
210 // delivery status cards stay visible but do not split the fold — a mid-turn
211 // warning is not a conversational boundary, and a delivery pause must keep its
212 // continue action reachable instead of collapsing with the process items.
213 function partitionTurnItems(
214 items: readonly Item[],
215 liveId?: string,
216 liveHasAnswerText = false,
217 liveHasReasoning = false,
218 ): TurnDisplayParts[] {
219 const segments: TurnDisplayParts[] = [];
220 let current: TurnDisplayParts = { processItems: [], outsideItems: [] };
221 let currentHasConversation = false;
222 const flushSegment = () => {
223 if (current.processItems.length === 0 && current.outsideItems.length === 0) return;
224 segments.push(current);
225 current = { processItems: [], outsideItems: [] };
226 currentHasConversation = false;
227 };
228 const pushProcess = (item: Item) => {
229 if (currentHasConversation) flushSegment();
230 current.processItems.push(item);
231 };
232 for (const item of items) {
233 if (item.kind === "user") continue;
234 if (item.kind === "notice") {
235 if (isSteerNoticeText(item.text)) {
236 current.outsideItems.push(item);
237 currentHasConversation = true;
238 } else if (item.level === "warn" || item.variant === "delivery") {
239 current.outsideItems.push(item);
240 } else {
241 pushProcess(item);
242 }
243 continue;
244 }
245 if (item.kind === "extension") {
246 // Extension cards carry their own actions and progress — keep them
247 // visible like warnings instead of folding them into the process
248 // collapse, but never treat them as a conversational boundary.
249 current.outsideItems.push(item);
250 continue;
251 }
252 if (item.kind !== "assistant") {
253 pushProcess(item);
254 continue;
255 }
256 const hasReasoning = Boolean(item.reasoning || (liveId === item.id && liveHasReasoning));
257 if (assistantHasVisibleAnswer(item, liveId, liveHasAnswerText)) {
258 if (hasReasoning) pushProcess(assistantReasoningOnly(item));
259 current.outsideItems.push(item);
260 currentHasConversation = true;
261 continue;
262 }
263 if (hasReasoning) pushProcess(item);
264 }
265 flushSegment();
266 return segments;
267 }
268
269 // ── Transcript component ──────────────────────────────────────────────────────
270
271 export function Transcript({
272 items,
273 live: liveProp,
274 liveStore,
275 tabId,
276 footerHeight = 0,
277 onPrompt,
278 onDeliveryContinue,
279 onEditPrompt,
280 onRewind,
281 checkpoints = [],
282 actionPending = false,
283 rewindDisabled = false,
284 running = false,
285 questionNavigator = true,
286 welcomeVariant = "default",
287 creationMode = false,
288 actionHoverMenus = false,
289 rewindSignal = 0,
290 revealSignal = 0,
291 hydrating = false,
292 hasOlderHistory = false,
293 olderHistoryCount = 0,
294 loadingOlderHistory = false,
295 onLoadOlderHistory,
296 turnStartAt,
297 invocationMetadata = {},
298 }: {
299 items: Item[];
300 live?: LiveStream;
301 liveStore?: ControllerLiveStore;
302 tabId?: string;
303 footerHeight?: number;
304 onPrompt: (text: string) => void;
305 onDeliveryContinue?: () => void;
306 onEditPrompt?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
307 onRewind?: (turn: number, scope: string) => void;
308 checkpoints?: CheckpointMeta[];
309 actionPending?: boolean;
310 rewindDisabled?: boolean;
311 running?: boolean;
312 questionNavigator?: boolean;
313 welcomeVariant?: "default" | "creation";
314 creationMode?: boolean;
315 actionHoverMenus?: boolean;
316 rewindSignal?: number;
317 revealSignal?: number;
318 hydrating?: boolean;
319 hasOlderHistory?: boolean;
320 olderHistoryCount?: number;
321 loadingOlderHistory?: boolean;
322 onLoadOlderHistory?: () => void;
323 turnStartAt?: number;
324 invocationMetadata?: InvocationMetadataMap;
325 }) {
326 const t = useT();
327 const subscribeLive = useCallback(
328 (listener: () => void) => liveStore?.subscribe(tabId, listener) ?? (() => {}),
329 [liveStore, tabId],
330 );
331 const getLiveSnapshot = useCallback(
332 () => liveStore?.getSnapshot(tabId) ?? liveProp,
333 [liveProp, liveStore, tabId],
334 );
335 const live = useSyncExternalStore(subscribeLive, getLiveSnapshot, getLiveSnapshot);
336 const {
337 scrollRef,
338 stick,
339 onScroll,
340 onWheelIntent,
341 onTouchStartIntent,
342 onTouchMoveIntent,
343 onKeyScrollIntent,
344 isAtBottom,
345 smoothScrollTo,
346 scrollToBottomAfterLayout,
347 trackQuestions,
348 scheduleRepinIfWasPinned,
349 resizeFrame,
350 lastClientHeight,
351 lastFooterHeight,
352 } = useScrollManager();
353 const autoScrollFrame = useRef<number | null>(null);
354 const pendingRevealBottomScroll = useRef(false);
355 // Creation uses a custom scrollbar (native WebView2 thumb size is unreliable).
356 // Thin by default; only thickens when pointer is near the right rail / dragging.
357 const [creationScrollbar, setCreationScrollbar] = useState({
358 visible: false,
359 hot: false,
360 thumbTop: 0,
361 thumbHeight: 0,
362 });
363 const creationScrollbarHotRef = useRef(false);
364 const creationScrollbarDragRef = useRef<{ pointerId: number; startY: number; startScrollTop: number } | null>(null);
365 const SCROLLBAR_HOT_ZONE_PX = 18;
366 const SCROLLBAR_MIN_THUMB_PX = 28;
367
368 const syncCreationScrollbarMetrics = useCallback(() => {
369 if (!creationMode) return;
370 const el = scrollRef.current;
371 if (!el) {
372 setCreationScrollbar((prev) => (prev.visible || prev.hot ? { visible: false, hot: false, thumbTop: 0, thumbHeight: 0 } : prev));
373 return;
374 }
375 const { scrollTop, scrollHeight, clientHeight } = el;
376 const overflow = scrollHeight - clientHeight;
377 if (overflow <= 1 || clientHeight <= 0) {
378 setCreationScrollbar((prev) => (prev.visible || prev.hot ? { visible: false, hot: false, thumbTop: 0, thumbHeight: 0 } : prev));
379 return;
380 }
381 const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((clientHeight / scrollHeight) * clientHeight));
382 const maxThumbTop = Math.max(0, clientHeight - thumbHeight);
383 const thumbTop = Math.round((scrollTop / overflow) * maxThumbTop);
384 setCreationScrollbar((prev) => {
385 if (
386 prev.visible &&
387 prev.thumbTop === thumbTop &&
388 prev.thumbHeight === thumbHeight &&
389 prev.hot === creationScrollbarHotRef.current
390 ) {
391 return prev;
392 }
393 return {
394 visible: true,
395 hot: creationScrollbarHotRef.current,
396 thumbTop,
397 thumbHeight,
398 };
399 });
400 }, [SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef]);
401
402 const setCreationScrollbarHot = useCallback((next: boolean) => {
403 if (creationScrollbarHotRef.current === next) return;
404 creationScrollbarHotRef.current = next;
405 setCreationScrollbar((prev) => (prev.hot === next ? prev : { ...prev, hot: next }));
406 }, []);
407
408 useEffect(() => {
409 if (!creationMode) {
410 creationScrollbarHotRef.current = false;
411 creationScrollbarDragRef.current = null;
412 setCreationScrollbar({ visible: false, hot: false, thumbTop: 0, thumbHeight: 0 });
413 return;
414 }
415
416 const onPointerMove = (event: PointerEvent) => {
417 const drag = creationScrollbarDragRef.current;
418 const el = scrollRef.current;
419 if (drag && el) {
420 const overflow = el.scrollHeight - el.clientHeight;
421 if (overflow > 0) {
422 const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((el.clientHeight / el.scrollHeight) * el.clientHeight));
423 const maxThumbTop = Math.max(0, el.clientHeight - thumbHeight);
424 const startThumbTop = (drag.startScrollTop / overflow) * maxThumbTop;
425 const nextThumbTop = Math.min(maxThumbTop, Math.max(0, startThumbTop + (event.clientY - drag.startY)));
426 el.scrollTop = maxThumbTop > 0 ? (nextThumbTop / maxThumbTop) * overflow : 0;
427 syncCreationScrollbarMetrics();
428 }
429 setCreationScrollbarHot(true);
430 return;
431 }
432
433 if (!el || el.scrollHeight <= el.clientHeight + 1) {
434 setCreationScrollbarHot(false);
435 return;
436 }
437 const rect = el.getBoundingClientRect();
438 const inY = event.clientY >= rect.top && event.clientY <= rect.bottom;
439 const fromRight = rect.right - event.clientX;
440 setCreationScrollbarHot(inY && fromRight >= -2 && fromRight <= SCROLLBAR_HOT_ZONE_PX);
441 };
442
443 const endDrag = (event?: PointerEvent) => {
444 if (!creationScrollbarDragRef.current) return;
445 creationScrollbarDragRef.current = null;
446 const el = scrollRef.current;
447 if (!el || !event) {
448 setCreationScrollbarHot(false);
449 return;
450 }
451 const rect = el.getBoundingClientRect();
452 const inY = event.clientY >= rect.top && event.clientY <= rect.bottom;
453 const fromRight = rect.right - event.clientX;
454 setCreationScrollbarHot(inY && fromRight >= -2 && fromRight <= SCROLLBAR_HOT_ZONE_PX);
455 };
456
457 const onPointerUp = (event: PointerEvent) => endDrag(event);
458 const onBlur = () => endDrag();
459
460 syncCreationScrollbarMetrics();
461 window.addEventListener("pointermove", onPointerMove, { passive: true });
462 window.addEventListener("pointerup", onPointerUp, { passive: true });
463 window.addEventListener("pointercancel", onPointerUp, { passive: true });
464 window.addEventListener("blur", onBlur);
465 window.addEventListener("resize", syncCreationScrollbarMetrics);
466 return () => {
467 window.removeEventListener("pointermove", onPointerMove);
468 window.removeEventListener("pointerup", onPointerUp);
469 window.removeEventListener("pointercancel", onPointerUp);
470 window.removeEventListener("blur", onBlur);
471 window.removeEventListener("resize", syncCreationScrollbarMetrics);
472 creationScrollbarHotRef.current = false;
473 creationScrollbarDragRef.current = null;
474 setCreationScrollbar({ visible: false, hot: false, thumbTop: 0, thumbHeight: 0 });
475 };
476 }, [SCROLLBAR_HOT_ZONE_PX, SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, syncCreationScrollbarMetrics]);
477
478 const handleCreationScroll = useCallback(() => {
479 onScroll();
480 if (creationMode) syncCreationScrollbarMetrics();
481 }, [creationMode, onScroll, syncCreationScrollbarMetrics]);
482
483 useLayoutEffect(() => {
484 if (!creationMode) return;
485 syncCreationScrollbarMetrics();
486 }, [creationMode, items.length, syncCreationScrollbarMetrics]);
487
488 useEffect(() => {
489 if (!creationMode || !scrollRef.current) return;
490 return observeScrollContentSize(scrollRef.current, syncCreationScrollbarMetrics);
491 }, [creationMode, scrollRef, syncCreationScrollbarMetrics]);
492
493 const handleCreationScrollbarThumbPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
494 if (!creationMode) return;
495 const el = scrollRef.current;
496 if (!el) return;
497 event.preventDefault();
498 event.stopPropagation();
499 creationScrollbarDragRef.current = {
500 pointerId: event.pointerId,
501 startY: event.clientY,
502 startScrollTop: el.scrollTop,
503 };
504 event.currentTarget.setPointerCapture(event.pointerId);
505 setCreationScrollbarHot(true);
506 }, [creationMode, scrollRef, setCreationScrollbarHot]);
507
508 const handleCreationScrollbarRailPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
509 if (!creationMode) return;
510 if ((event.target as HTMLElement | null)?.closest?.(".transcript__scrollbar-thumb")) return;
511 const el = scrollRef.current;
512 if (!el) return;
513 const rect = el.getBoundingClientRect();
514 const overflow = el.scrollHeight - el.clientHeight;
515 if (overflow <= 1) return;
516 const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((el.clientHeight / el.scrollHeight) * el.clientHeight));
517 const maxThumbTop = Math.max(0, el.clientHeight - thumbHeight);
518 const y = event.clientY - rect.top - thumbHeight / 2;
519 const nextThumbTop = Math.min(maxThumbTop, Math.max(0, y));
520 el.scrollTop = maxThumbTop > 0 ? (nextThumbTop / maxThumbTop) * overflow : 0;
521 syncCreationScrollbarMetrics();
522 setCreationScrollbarHot(true);
523 }, [SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, syncCreationScrollbarMetrics]);
524
525 const pendingQuestionJump = useRef<QuestionAnchor | null>(null);
526 const sessionKey = useMemo(() => `${items[0]?.id ?? ""}|${items[items.length - 1]?.id ?? ""}`, [items]);
527 const warmLayerSessionKey = useMemo(() => `${tabId ?? ""}|${revealSignal}|${items[0]?.id ?? ""}`, [items, revealSignal, tabId]);
528 const entranceRef = useEntranceAnimation<HTMLDivElement>(sessionKey, items.length);
529
530 const [displayMode, setDisplayMode] = useState<DisplayMode>(() => getDisplayMode());
531 useEffect(() => onDisplayModeChange((mode) => setDisplayMode(mode)), []);
532
533 const cancelStreamingAutoScroll = useCallback(() => {
534 if (autoScrollFrame.current !== null) {
535 cancelAnimationFrame(autoScrollFrame.current);
536 autoScrollFrame.current = null;
537 }
538 }, []);
539
540 const handleWheelIntent = useCallback((event: React.WheelEvent<HTMLElement>) => {
541 if (onWheelIntent(event)) cancelStreamingAutoScroll();
542 }, [cancelStreamingAutoScroll, onWheelIntent]);
543
544 const handleTouchMoveIntent = useCallback((event: React.TouchEvent<HTMLElement>) => {
545 if (onTouchMoveIntent(event)) cancelStreamingAutoScroll();
546 }, [cancelStreamingAutoScroll, onTouchMoveIntent]);
547
548 const handleKeyScrollIntent = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
549 if (onKeyScrollIntent(event)) cancelStreamingAutoScroll();
550 }, [cancelStreamingAutoScroll, onKeyScrollIntent]);
551
552 const questions = useMemo<QuestionAnchor[]>(() => {
553 const anchors: QuestionAnchor[] = [];
554 let turn = 0;
555 for (const it of items) {
556 if (it.kind !== "user") continue;
557 anchors.push({ id: it.id, text: compactQuestionText(it.text), turn, checkpointTurn: it.checkpointTurn });
558 turn += 1;
559 }
560 return anchors;
561 }, [items]);
562 const showQuestionNav = questionNavigator && questions.length >= QUESTION_NAV_MIN_COUNT;
563
564 // Track question count and auto-scroll on new messages.
565 useEffect(() => { trackQuestions(questions.length); }, [questions.length, trackQuestions]);
566
567 // Reset the auto-scroll pin when switching tabs so the new session always
568 // starts at the bottom. Without this, stick.current from the previous tab
569 // persists across React re-renders (Transcript is not keyed by tabId) and
570 // disables auto-scroll when the user had scrolled up in the old tab (#4584).
571 useEffect(() => {
572 stick.current = true;
573 pendingRevealBottomScroll.current = true;
574 }, [tabId, revealSignal]);
575
576 useEffect(() => {
577 if (!pendingRevealBottomScroll.current || items.length === 0) return;
578 pendingRevealBottomScroll.current = false;
579 const frame = requestAnimationFrame(() => {
580 scrollToBottomAfterLayout(5);
581 });
582 return () => cancelAnimationFrame(frame);
583 }, [items.length, revealSignal, scrollToBottomAfterLayout, tabId]);
584
585 // Auto-scroll to bottom during streaming. Coalesce fast token/reasoning
586 // updates into one layout read/write per animation frame.
587 const contentVersion = useMemo(() => scrollVersion(items), [items]);
588 useEffect(() => {
589 if (items.length === 0) return;
590 if (!stick.current) return;
591 if (autoScrollFrame.current !== null) return;
592 autoScrollFrame.current = requestAnimationFrame(() => {
593 autoScrollFrame.current = null;
594 if (!stick.current) return;
595 const el = scrollRef.current;
596 if (el) el.scrollTop = el.scrollHeight;
597 });
598 }, [contentVersion, live?.text?.length ?? 0, live?.reasoning?.length ?? 0]);
599 useEffect(() => {
600 return () => {
601 if (autoScrollFrame.current !== null) {
602 cancelAnimationFrame(autoScrollFrame.current);
603 autoScrollFrame.current = null;
604 }
605 };
606 }, []);
607
608 // ResizeObserver for container height changes.
609 useEffect(() => {
610 const el = scrollRef.current;
611 if (!el || typeof ResizeObserver === "undefined") return;
612 lastClientHeight.current = el.clientHeight;
613 const observer = new ResizeObserver((entries) => {
614 const height = entries[0]?.contentRect.height ?? el.clientHeight;
615 const previous = lastClientHeight.current ?? height;
616 lastClientHeight.current = height;
617 if (items.length === 0) return;
618 scheduleRepinIfWasPinned(height - previous);
619 });
620 observer.observe(el);
621 return () => {
622 observer.disconnect();
623 if (resizeFrame.current !== null) {
624 cancelAnimationFrame(resizeFrame.current);
625 resizeFrame.current = null;
626 }
627 };
628 }, [items.length, scheduleRepinIfWasPinned]);
629
630 // Footer height changes → smooth scroll repin with GSAP.
631 useEffect(() => {
632 const el = scrollRef.current;
633 if (!el) return;
634 const previous = lastFooterHeight.current ?? footerHeight;
635 lastFooterHeight.current = footerHeight;
636 if (items.length === 0) return;
637 scheduleRepinIfWasPinned(previous - footerHeight);
638 }, [footerHeight, items.length, scheduleRepinIfWasPinned]);
639
640 // After a non-fork rewind, scroll to the last user message (the
641 // rewound-to point) so the user knows where they are.
642 useEffect(() => {
643 if (rewindSignal <= 0 || questions.length === 0) return;
644 const lastQ = questions[questions.length - 1];
645 const el = document.getElementById(questionAnchorId(lastQ.id));
646 if (!el || !scrollRef.current) return;
647 stick.current = false;
648 scrollRef.current.scrollTop = el.offsetTop - scrollRef.current.offsetTop - 12;
649 // eslint-disable-next-line react-hooks/exhaustive-deps
650 }, [rewindSignal]);
651
652 // Sub-agent calls carry a parentId; collect them under their parent `task`
653 // call so the parent card can render them nested, and skip them at top level.
654 const subcallsByParent = useMemo(() => {
655 const m = new Map<string, ToolItem[]>();
656 for (const it of items) {
657 if (it.kind === "tool" && it.parentId) {
658 const arr = m.get(it.parentId) ?? [];
659 arr.push(it);
660 m.set(it.parentId, arr);
661 }
662 }
663 return m;
664 }, [items]);
665
666 // ── Layer state ────────────────────────────────────────────────────────────
667 const [warmLayerState, setWarmLayerState] = useState<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey));
668 const defaultWarmLayerState = useMemo<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey), [warmLayerSessionKey]);
669 const activeWarmLayerState = warmLayerState.sessionKey === warmLayerSessionKey
670 ? warmLayerState
671 : defaultWarmLayerState;
672 const { expandedWarmTurns, coldPage } = activeWarmLayerState;
673
674 // Compute turn groups from the structural item list. Streaming text updates
675 // keep the same items[] reference, so this stays out of the token hot path.
676 const turnGroups = useMemo(() => buildTurnGroups(items), [items]);
677
678 // hotStartIdx: first index of the hot zone in items[].
679 const hotStartIdx = useMemo(() => {
680 let needed = HOT_TURNS;
681 for (let i = items.length - 1; i >= 0; i--) {
682 if (items[i].kind === "user") {
683 needed--;
684 if (needed <= 0) return i;
685 }
686 }
687 return 0;
688 }, [items]);
689
690 // How many turns are in the cold zone (not yet shown).
691 const { warmStartTurn, warmEndTurn, coldTurnCount } = useMemo(
692 () => warmPagination({ turnCount: turnGroups.length, hotTurns: HOT_TURNS, pageSize: WARM_PAGE_SIZE, coldPage }),
693 [coldPage, turnGroups.length],
694 );
695
696 useLayoutEffect(() => {
697 const question = pendingQuestionJump.current;
698 if (!question) return;
699 const node = document.getElementById(questionAnchorId(question.id));
700 if (!node) return;
701 pendingQuestionJump.current = null;
702 stick.current = false;
703 smoothScrollTo(node, 12);
704 }, [expandedWarmTurns, smoothScrollTo, stick, warmStartTurn]);
705
706 // ── The turn action menu ──────────────────────────────────────────────────
707 const [openAction, setOpenAction] = useState<OpenTurnAction | null>(null);
708 useEffect(() => {
709 if (openAction === null) return;
710 const onDown = (e: MouseEvent) => {
711 const el = e.target as Element | null;
712 if (!el || !el.closest(".turn-actions")) setOpenAction(null);
713 };
714 document.addEventListener("mousedown", onDown);
715 return () => document.removeEventListener("mousedown", onDown);
716 }, [openAction]);
717
718 const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
719 const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
720 const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
721
722 // ── JumpBar integration ───────────────────────────────────────────────────
723 const jumpToQuestion = (question: QuestionAnchor) => {
724 const node = document.getElementById(questionAnchorId(question.id));
725 if (!node) return;
726 pendingQuestionJump.current = null;
727 stick.current = false;
728 smoothScrollTo(node, 12);
729 };
730
731 const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
732 pendingQuestionJump.current = question;
733 // Auto-expand the warm turn when jumping to an old question.
734 const warmTurnStart = turnGroups.length - HOT_TURNS;
735 if (question.turn < warmTurnStart) {
736 const neededColdPage = warmColdPageForTurn({
737 turn: question.turn,
738 turnCount: turnGroups.length,
739 hotTurns: HOT_TURNS,
740 pageSize: WARM_PAGE_SIZE,
741 });
742 setWarmLayerState((prev) => {
743 const paged = warmLayerWithColdPageAtLeast(prev, warmLayerSessionKey, neededColdPage);
744 return warmLayerWithExpandedTurn(paged, warmLayerSessionKey, question.turn, true);
745 });
746 }
747 jumpToQuestion(question);
748 }, [turnGroups.length, warmLayerSessionKey]);
749
750 // ── Hot zone: fully rendered from hotStartIdx to end ─────────────────────
751 // Memoized separately from the assembly so streaming tokens don't rebuild
752 // the warm/cold zone JSX trees. Uses LiveStreamContext for streaming data
753 // (added by upstream PR #3423) instead of per-call renderSegments.
754 const empty = items.length === 0;
755
756 useLayoutEffect(() => {
757 if (!empty) return;
758 const el = scrollRef.current;
759 if (!el) return;
760 el.scrollTop = 0;
761 stick.current = false;
762 const frame = requestAnimationFrame(() => {
763 el.scrollTop = 0;
764 });
765 return () => cancelAnimationFrame(frame);
766 }, [empty, scrollRef, stick, tabId]);
767
768 // The hot-zone memo must not depend on the live stream's full text/reasoning
769 // — that would rebuild the whole element array on every streaming token
770 // (LiveAssistantMessage reads those via LiveStreamContext instead). The memo
771 // only needs presence flags, which flip at most once per turn.
772 const liveId = live?.id;
773 const liveHasAnswerText = Boolean(live?.text.trim());
774 const liveHasReasoning = Boolean(live?.reasoning);
775
776 const hotZoneNodes = useMemo<ReactNode[]>(() => {
777 const out: ReactNode[] = [];
778 const pushTurnActions = (turn: number | undefined, turnItems: readonly Item[]) => {
779 if (turn == null) return;
780 let actionText = "";
781 for (const item of turnItems) {
782 if (item.kind !== "assistant" || item.streaming || !item.text.trim()) continue;
783 actionText = appendTurnActionCopyText(actionText, item.text);
784 }
785 if (!actionText.trim()) return;
786 const openMenu = openAction && openAction.turn === turn ? openAction.menu : null;
787 out.push(
788 <TurnActions
789 key={`ta-${turn}`}
790 text={actionText}
791 turn={turn}
792 openMenu={openMenu}
793 onOpenMenu={(menu) => setOpenAction(menu ? { turn, menu } : null)}
794 checkpoint={checkpointsByTurn.get(turn)}
795 actionPending={actionPending}
796 rewindDisabled={rewindDisabled}
797 hoverMenus={actionHoverMenus}
798 isLastTurn={turn === lastTurn}
799 onRewind={(targetTurn, scope) => {
800 onRewind?.(targetTurn, scope);
801 setOpenAction(null);
802 }}
803 />,
804 );
805 };
806
807 const pushTurnBody = (key: string, turnItems: readonly Item[], turnIsActive: boolean) => {
808 const segments = partitionTurnItems(turnItems, liveId, liveHasAnswerText, liveHasReasoning);
809 const turnHasOutsideContent = segments.some((segment) => segment.outsideItems.length > 0);
810 segments.forEach((segment, segmentIndex) => {
811 const isLastSegment = segmentIndex === segments.length - 1;
812 if (segment.processItems.length > 0) {
813 out.push(
814 <TurnCollapse
815 key={`turn-process-${key}-${segment.processItems[0].id}`}
816 items={segment.processItems}
817 durationMs={isLastSegment ? turnWorkDurationMs(turnItems) : 0}
818 mode={displayMode}
819 subcalls={subcallsByParent}
820 tabId={tabId}
821 creationMode={creationMode}
822 turnStartAt={turnIsActive && isLastSegment ? turnStartAt : undefined}
823 turnActive={turnIsActive && isLastSegment}
824 preferredKind="reasoning"
825 labelStyle={isLastSegment ? "full" : "counts"}
826 hasOutsideContent={turnHasOutsideContent}
827 />,
828 );
829 }
830 for (const item of segment.outsideItems) {
831 if (item.kind === "extension") {
832 out.push(<ExtensionCard key={item.id} item={item} tabId={tabId} />);
833 continue;
834 }
835 if (item.kind === "notice") {
836 if (isSteerNoticeText(item.text)) {
837 out.push(<SteerCard key={item.id} text={item.text} />);
838 continue;
839 }
840 out.push(
841 <NoticeCard
842 key={item.id}
843 item={item}
844 actionDisabled={running}
845 onAction={item.action === "continue_delivery" ? (onDeliveryContinue ?? (() => onPrompt(t("notice.deliveryIncompleteContinuePrompt")))) : undefined}
846 />,
847 );
848 } else {
849 out.push(
850 <LiveAssistantMessage
851 key={item.id}
852 item={assistantAnswerOnly(item)}
853 defaultExpanded={false}
854 expandWhileStreaming={false}
855 truncateStreamingReasoning={true}
856 creationMode={creationMode}
857 reasoningDisplay="hide"
858 />,
859 );
860 }
861 }
862 });
863 };
864
865 const hotGroups = turnGroups.filter((group) => group.startIdx >= hotStartIdx);
866 const firstHotStart = hotGroups[0]?.startIdx ?? items.length;
867 if (hotStartIdx < firstHotStart) {
868 pushTurnBody("prelude", items.slice(hotStartIdx, firstHotStart), false);
869 }
870
871 for (let index = 0; index < hotGroups.length; index++) {
872 const group = hotGroups[index];
873 const user = group.userItem;
874 if (user.kind !== "user") continue;
875 const turn = userTurn.get(user.id);
876 const checkpoint = turn == null ? undefined : checkpointsByTurn.get(turn);
877 const turnItems = items.slice(group.startIdx + 1, group.endIdx);
878 const turnIsActive = running && index === hotGroups.length - 1;
879 out.push(
880 <UserMessage
881 key={user.id}
882 id={user.id}
883 text={user.text}
884 submitText={user.submitText}
885 failed={user.failed}
886 createdAt={user.createdAt}
887 turn={turn}
888 anchorId={questionAnchorId(user.id)}
889 onEdit={onEditPrompt}
890 editDisabled={rewindDisabled || !checkpoint?.canConversation}
891 />,
892 );
893 pushTurnBody(user.id, turnItems, turnIsActive);
894 if (!turnIsActive) pushTurnActions(turn, turnItems);
895 }
896 return out;
897 }, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, turnGroups, tabId, actionHoverMenus, creationMode, lastTurn, turnStartAt, liveId, liveHasAnswerText, liveHasReasoning, t]);
898
899 // ── Assemble rendered output ──────────────────────────────────────────────
900 // Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
901 // don't rebuild it. The hot zone uses LiveAssistantMessage (reads live from
902 // LiveStreamContext) so streaming updates are captured immediately.
903 return (
904 <InvocationMetadataContext.Provider value={invocationMetadata}>
905 <div className="transcript-shell">
906 <div
907 className={`transcript${empty ? " transcript--empty" : ""}${creationMode ? " transcript--creation-scrollbar" : ""}${creationMode && creationScrollbar.hot ? " transcript--scrollbar-hot" : ""}`}
908 ref={scrollRef}
909 onScroll={creationMode ? handleCreationScroll : onScroll}
910 onWheelCapture={handleWheelIntent}
911 onTouchStartCapture={onTouchStartIntent}
912 onTouchMoveCapture={handleTouchMoveIntent}
913 onKeyDownCapture={handleKeyScrollIntent}
914 >
915 {empty && !hydrating && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
916
917 <LiveStreamContext.Provider value={live}>
918 {hasOlderHistory && (
919 <button
920 type="button"
921 className="warm-collapse"
922 onClick={onLoadOlderHistory}
923 disabled={loadingOlderHistory}
924 >
925 {loadingOlderHistory ? t("common.loading") : t("transcript.showEarlierHistory", { n: olderHistoryCount })}
926 </button>
927 )}
928 {turnGroups.length > HOT_TURNS && (
929 <WarmZone
930 turnGroups={turnGroups}
931 expandedWarmTurns={expandedWarmTurns}
932 warmStartTurn={warmStartTurn}
933 warmEndTurn={warmEndTurn}
934 coldTurnCount={coldTurnCount}
935 scrollRef={scrollRef}
936 warmItems={items}
937 warmSubcalls={subcallsByParent}
938 warmUserTurn={userTurn}
939 warmCheckpoints={checkpointsByTurn}
940 warmLastTurn={lastTurn}
941 warmDisplayMode={displayMode}
942 warmOpenAction={openAction}
943 warmActionPending={actionPending}
944 warmRewindDisabled={rewindDisabled}
945 warmActionHoverMenus={actionHoverMenus}
946 warmOnRewind={onRewind}
947 warmSetOpenAction={setOpenAction}
948 warmOnEdit={onEditPrompt}
949 warmOnPrompt={onPrompt}
950 warmOnDeliveryContinue={onDeliveryContinue}
951 warmRunning={running}
952 tabId={tabId}
953 creationMode={creationMode}
954 onToggleColdPage={() => setWarmLayerState((prev) => warmLayerWithNextColdPage(prev, warmLayerSessionKey))}
955 onToggleWarmTurn={(g, expand) => {
956 setWarmLayerState((prev) => warmLayerWithExpandedTurn(prev, warmLayerSessionKey, g, expand));
957 }}
958 />
959 )}
960 <div ref={entranceRef}>
961 {hotZoneNodes}
962 </div>
963 </LiveStreamContext.Provider>
964 </div>
965
966 {creationMode && creationScrollbar.visible && (
967 <div
968 className={`transcript__scrollbar${creationScrollbar.hot ? " transcript__scrollbar--hot" : ""}`}
969 onPointerDown={handleCreationScrollbarRailPointerDown}
970 aria-hidden="true"
971 >
972 <div
973 className="transcript__scrollbar-thumb"
974 style={{ top: creationScrollbar.thumbTop, height: creationScrollbar.thumbHeight } as CSSProperties}
975 onPointerDown={handleCreationScrollbarThumbPointerDown}
976 />
977 </div>
978 )}
979
980 {!empty && showQuestionNav && (
981 <QuestionJumpBar questions={questions} onJump={handleJumpToQuestion} />
982 )}
983
984 {!empty && !isAtBottom && (
985 <button
986 type="button"
987 className="transcript__jump-bottom"
988 onClick={() => scrollToBottomAfterLayout(2)}
989 aria-label={t("transcript.jumpToBottom")}
990 title={t("transcript.jumpToBottom")}
991 >
992 <ArrowDown size={18} strokeWidth={2.2} aria-hidden="true" />
993 </button>
994 )}
995 </div>
996 </InvocationMetadataContext.Provider>
997 );
998 }
999
1000 // ── WarmZone sub-component (React.memo for streaming isolation) ────────────
1001 // Receives structural props only; reads streaming state (items, live) via refs
1002 // so it never invalidates on streaming token arrival.
1003
1004 const WarmZone = memo(function WarmZone({
1005 turnGroups,
1006 expandedWarmTurns,
1007 warmStartTurn,
1008 warmEndTurn,
1009 coldTurnCount,
1010 scrollRef,
1011 warmItems,
1012 warmSubcalls,
1013 warmUserTurn,
1014 warmCheckpoints,
1015 warmLastTurn,
1016 warmDisplayMode,
1017 warmOpenAction,
1018 warmActionPending,
1019 warmRewindDisabled,
1020 warmActionHoverMenus,
1021 warmOnRewind,
1022 warmSetOpenAction,
1023 warmOnEdit,
1024 warmOnPrompt,
1025 warmOnDeliveryContinue,
1026 warmRunning,
1027 tabId,
1028 creationMode,
1029 onToggleColdPage,
1030 onToggleWarmTurn,
1031 }: {
1032 turnGroups: TurnGroup[];
1033 expandedWarmTurns: ReadonlySet<number>;
1034 warmStartTurn: number;
1035 warmEndTurn: number;
1036 coldTurnCount: number;
1037 scrollRef: React.RefObject<HTMLDivElement | null>;
1038 warmItems: readonly Item[];
1039 warmSubcalls: ReadonlyMap<string, ToolItem[]>;
1040 warmUserTurn: ReadonlyMap<string, number>;
1041 warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
1042 warmLastTurn?: number;
1043 warmDisplayMode: DisplayMode;
1044 warmOpenAction: OpenTurnAction | null;
1045 warmActionPending: boolean;
1046 warmRewindDisabled: boolean;
1047 warmActionHoverMenus: boolean;
1048 warmOnRewind: ((turn: number, scope: string) => void) | undefined;
1049 warmSetOpenAction: (action: OpenTurnAction | null) => void;
1050 warmOnEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
1051 warmOnPrompt: (text: string) => void;
1052 warmOnDeliveryContinue?: () => void;
1053 warmRunning: boolean;
1054 tabId?: string;
1055 creationMode?: boolean;
1056 onToggleColdPage: () => void;
1057 onToggleWarmTurn: (g: number, expand: boolean) => void;
1058 }) {
1059 const t = useT();
1060 const out: React.ReactNode[] = [];
1061
1062 // 1. Cold zone: paginated warm turns (show more button).
1063 if (coldTurnCount > 0) {
1064 out.push(
1065 <button
1066 key="cold-load-more"
1067 type="button"
1068 className="warm-collapse"
1069 onClick={onToggleColdPage}
1070 >
1071 {t("transcript.showEarlierHistory", { n: coldTurnCount })}
1072 </button>,
1073 );
1074 }
1075
1076 // 2. Warm zone: collapsed/expanded warm turn cards.
1077 if (turnGroups.length > HOT_TURNS) {
1078 for (let g = warmStartTurn; g < warmEndTurn; g++) {
1079 const group = turnGroups[g];
1080 if (!group) continue;
1081 const expanded = expandedWarmTurns.has(g);
1082
1083 if (expanded) {
1084 const userText = group.userItem.kind === "user" ? group.userItem.text : "";
1085 out.push(
1086 <WarmTurnCard
1087 key={`warm-${g}`}
1088 userText={warmUserPreview(userText)}
1089 assistantPreview={group.assistantPreview}
1090 toolCount={group.toolCount}
1091 expanded={true}
1092 onToggle={() => onToggleWarmTurn(g, false)}
1093 >
1094 {/* Expanded warm turns render items that are stable (never the
1095 streaming turn), so this captures items/live via a ref. */}
1096 <WarmTurnItems
1097 startIdx={group.startIdx}
1098 endIdx={group.endIdx}
1099 items={warmItems}
1100 subcalls={warmSubcalls}
1101 userTurnMap={warmUserTurn}
1102 checkpoints={warmCheckpoints}
1103 openAction={warmOpenAction}
1104 actionPending={warmActionPending}
1105 rewindDisabled={warmRewindDisabled}
1106 actionHoverMenus={warmActionHoverMenus}
1107 onRewind={warmOnRewind}
1108 setOpenAction={warmSetOpenAction}
1109 onEdit={warmOnEdit}
1110 onPrompt={warmOnPrompt}
1111 onDeliveryContinue={warmOnDeliveryContinue}
1112 running={warmRunning}
1113 tabId={tabId}
1114 creationMode={creationMode}
1115 lastTurn={warmLastTurn}
1116 mode={warmDisplayMode}
1117 />
1118 </WarmTurnCard>,
1119 );
1120 } else {
1121 const userText = group.userItem.kind === "user" ? group.userItem.text : "";
1122 out.push(
1123 <WarmTurnCard
1124 key={`warm-${g}`}
1125 userText={warmUserPreview(userText)}
1126 assistantPreview={group.assistantPreview}
1127 toolCount={group.toolCount}
1128 expanded={false}
1129 onToggle={() => {
1130 onToggleWarmTurn(g, true);
1131 const el = scrollRef.current;
1132 const node = document.getElementById(questionAnchorId(group.userItem.id));
1133 if (el && node) {
1134 requestAnimationFrame(() => {
1135 el.scrollTo({ top: node.offsetTop - el.offsetTop - 80, behavior: "smooth" });
1136 });
1137 }
1138 }}
1139 />,
1140 );
1141 }
1142 }
1143 }
1144
1145 return out;
1146 });
1147
1148 function WarmTurnItems({
1149 startIdx,
1150 endIdx,
1151 items,
1152 subcalls,
1153 userTurnMap,
1154 checkpoints,
1155 openAction,
1156 actionPending,
1157 rewindDisabled,
1158 actionHoverMenus,
1159 onRewind,
1160 setOpenAction,
1161 onEdit,
1162 onPrompt,
1163 onDeliveryContinue,
1164 running,
1165 tabId,
1166 creationMode = false,
1167 lastTurn,
1168 mode,
1169 }: {
1170 startIdx: number;
1171 endIdx: number;
1172 items: readonly Item[];
1173 subcalls: ReadonlyMap<string, ToolItem[]>;
1174 userTurnMap: ReadonlyMap<string, number>;
1175 checkpoints: ReadonlyMap<number, CheckpointMeta>;
1176 openAction: OpenTurnAction | null;
1177 actionPending: boolean;
1178 rewindDisabled: boolean;
1179 actionHoverMenus: boolean;
1180 onRewind: ((turn: number, scope: string) => void) | undefined;
1181 setOpenAction: (action: OpenTurnAction | null) => void;
1182 onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
1183 onPrompt: (text: string) => void;
1184 onDeliveryContinue?: () => void;
1185 running: boolean;
1186 tabId?: string;
1187 creationMode?: boolean;
1188 lastTurn?: number;
1189 mode: DisplayMode;
1190 }) {
1191 const t = useT();
1192 const nodes: React.ReactNode[] = [];
1193 const user = items[startIdx];
1194 if (!user || user.kind !== "user") return nodes;
1195
1196 const turn = userTurnMap.get(user.id);
1197 const checkpoint = turn == null ? undefined : checkpoints.get(turn);
1198 const turnItems = items.slice(startIdx + 1, Math.min(endIdx, items.length));
1199 const segments = partitionTurnItems(turnItems);
1200 const turnHasOutsideContent = segments.some((segment) => segment.outsideItems.length > 0);
1201 nodes.push(
1202 <UserMessage
1203 key={user.id}
1204 id={user.id}
1205 text={user.text}
1206 submitText={user.submitText}
1207 failed={user.failed}
1208 createdAt={user.createdAt}
1209 turn={turn}
1210 anchorId={questionAnchorId(user.id)}
1211 onEdit={onEdit}
1212 editDisabled={rewindDisabled || !checkpoint?.canConversation}
1213 />,
1214 );
1215 segments.forEach((segment, segmentIndex) => {
1216 const isLastSegment = segmentIndex === segments.length - 1;
1217 if (segment.processItems.length > 0) {
1218 nodes.push(
1219 <TurnCollapse
1220 key={`warm-process-${user.id}-${segment.processItems[0].id}`}
1221 items={segment.processItems}
1222 durationMs={isLastSegment ? turnWorkDurationMs(turnItems) : 0}
1223 mode={mode}
1224 subcalls={subcalls}
1225 tabId={tabId}
1226 creationMode={creationMode}
1227 preferredKind="reasoning"
1228 labelStyle={isLastSegment ? "full" : "counts"}
1229 hasOutsideContent={turnHasOutsideContent}
1230 />,
1231 );
1232 }
1233 for (const item of segment.outsideItems) {
1234 if (item.kind === "extension") {
1235 nodes.push(<ExtensionCard key={item.id} item={item} tabId={tabId} />);
1236 continue;
1237 }
1238 if (item.kind === "notice") {
1239 if (isSteerNoticeText(item.text)) {
1240 nodes.push(<SteerCard key={item.id} text={item.text} />);
1241 continue;
1242 }
1243 nodes.push(
1244 <NoticeCard
1245 key={item.id}
1246 item={item}
1247 actionDisabled={running}
1248 onAction={item.action === "continue_delivery" ? (onDeliveryContinue ?? (() => onPrompt(t("notice.deliveryIncompleteContinuePrompt")))) : undefined}
1249 />,
1250 );
1251 } else {
1252 nodes.push(
1253 <AssistantMessage
1254 key={item.id}
1255 item={assistantAnswerOnly(item)}
1256 defaultExpanded={false}
1257 creationMode={creationMode}
1258 />,
1259 );
1260 }
1261 }
1262 });
1263
1264 let actionText = "";
1265 for (const item of turnItems) {
1266 if (item.kind !== "assistant" || item.streaming || !item.text.trim()) continue;
1267 actionText = appendTurnActionCopyText(actionText, item.text);
1268 }
1269 if (turn != null && actionText.trim()) {
1270 const openMenu = openAction && openAction.turn === turn ? openAction.menu : null;
1271 nodes.push(
1272 <TurnActions
1273 key={`ta-${turn}`}
1274 text={actionText}
1275 turn={turn}
1276 openMenu={openMenu}
1277 onOpenMenu={(menu) => setOpenAction(menu ? { turn, menu } : null)}
1278 checkpoint={checkpoints.get(turn)}
1279 actionPending={actionPending}
1280 rewindDisabled={rewindDisabled}
1281 hoverMenus={actionHoverMenus}
1282 isLastTurn={turn === lastTurn}
1283 onRewind={(targetTurn, scope) => {
1284 onRewind?.(targetTurn, scope);
1285 setOpenAction(null);
1286 }}
1287 />,
1288 );
1289 }
1290 return nodes;
1291 }
1292
1293 // ── Warm turn summary card ────────────────────────────────────────────────────
1294
1295 function WarmTurnCard({
1296 userText,
1297 assistantPreview,
1298 toolCount,
1299 expanded,
1300 onToggle,
1301 children,
1302 }: {
1303 userText: string;
1304 assistantPreview: string;
1305 toolCount: number;
1306 expanded: boolean;
1307 onToggle: () => void;
1308 children?: React.ReactNode;
1309 }) {
1310 const t = useT();
1311 const contentRef = useRef<HTMLDivElement>(null);
1312 const prevHeightRef = useRef(0);
1313 useGSAPCollapse(contentRef, expanded, { prevHeight: prevHeightRef.current });
1314 // Always render both children so the container's scrollHeight reflects
1315 // the correct content at all times. The inactive one is display:none.
1316 return (
1317 <div className={`warm-turn${expanded ? " warm-turn--expanded" : ""}`}>
1318 <button
1319 type="button"
1320 className="warm-turn__head"
1321 onClick={() => {
1322 // Capture height before DOM swap so the collapse animation
1323 // starts from the correct (expanded) height.
1324 const el = contentRef.current;
1325 if (el) {
1326 el.style.height = "auto";
1327 prevHeightRef.current = el.scrollHeight;
1328 }
1329 onToggle();
1330 }}
1331 aria-expanded={expanded}
1332 >
1333 <span className="warm-turn__chevron">
1334 <ChevronRight className={expanded ? "warm-turn__chevron--open" : ""} size={13} />
1335 </span>
1336 <span className="warm-turn__preview">{userText}</span>
1337 <span className="warm-turn__meta">
1338 {toolCount > 0 && <span>{t("transcript.toolCount", { n: toolCount })}</span>}
1339 </span>
1340 </button>
1341 <div ref={contentRef} className="warm-turn__content">
1342 <div className="warm-turn__body" style={{ display: expanded ? undefined : "none" }}>{children}</div>
1343 {assistantPreview && (
1344 <div className="warm-turn__assistant" style={{ display: expanded ? "none" : undefined }}>{assistantPreview}</div>
1345 )}
1346 </div>
1347 </div>
1348 );
1349 }
1350
1351 // ── TurnCollapse: one process fold per user turn ─────────────────────────────
1352
1353 type TurnCollapseProps = {
1354 items: Item[];
1355 durationMs: number;
1356 mode: DisplayMode;
1357 subcalls: ReadonlyMap<string, ToolItem[]>;
1358 tabId?: string;
1359 creationMode?: boolean;
1360 turnStartAt?: number;
1361 turnActive?: boolean;
1362 preferredKind?: "tool" | "reasoning" | "process";
1363 // "full" carries the turn's work-duration label; "counts" is for earlier
1364 // segments of a multi-fold turn, which only list what they contain — the
1365 // turn's wall-clock belongs to the segment where the turn ends.
1366 labelStyle?: "full" | "counts";
1367 // Whether the turn renders anything outside this fold (answer text, warning,
1368 // steer). When nothing is outside, the fold is the turn's only content and
1369 // must not collapse it away.
1370 hasOutsideContent?: boolean;
1371 };
1372
1373 function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode = false, turnStartAt, turnActive = false, preferredKind, labelStyle = "full", hasOutsideContent = true }: TurnCollapseProps) {
1374 const t = useT();
1375 const live = useContext(LiveStreamContext);
1376 const [foldPreference, setFoldPreference] = useState<ProcessFoldPreference>(getProcessFoldPreference);
1377 const [open, setOpen] = useState(() => getProcessFoldPreference() === "expanded" || !hasOutsideContent);
1378 const userOverriddenOpen = useRef(false);
1379 const prevRunningRef = useRef(false);
1380 const bodyRef = useRef<HTMLDivElement>(null);
1381 useEffect(() => onProcessFoldPreferenceChange(setFoldPreference), []);
1382
1383 // Keep only items the body will actually render — an expandable fold over
1384 // nothing is worse than no fold. Assistant items reach the fold stripped to
1385 // their reasoning (answer text renders outside), so reasoning presence is
1386 // the only thing that keeps them.
1387 const displayItems = useMemo(() => {
1388 return items.filter((it) => {
1389 if (it.kind === "assistant") {
1390 return Boolean(it.reasoning || (live?.id === it.id && live.reasoning));
1391 }
1392 if (it.kind === "phase") return true;
1393 if (it.kind === "notice") return true;
1394 if (it.kind === "compaction") return true;
1395 if (it.kind !== "tool") return false;
1396 if (it.parentId || it.name === "todo_write" || it.name === "exit_plan_mode") return false;
1397 return true;
1398 });
1399 }, [items, mode, live?.id, live?.reasoning]);
1400
1401 const seconds = Math.round(durationMs / 1000);
1402
1403 const hasRunningProcess = displayItems.some((it) => {
1404 if (it.kind === "tool") return it.status === "running";
1405 if (it.kind !== "assistant") return false;
1406 if (live?.id === it.id) return !live.reasoningComplete;
1407 return it.streaming && !it.reasoningComplete;
1408 });
1409 const hasLiveAssistant = displayItems.some((it) => it.kind === "assistant" && live?.id === it.id);
1410 const hasRunningWork = turnActive || hasRunningProcess || hasLiveAssistant;
1411 const now = useTick(hasRunningWork);
1412 const runningDurationMs = hasRunningWork
1413 ? turnStartAt
1414 ? Math.max(0, now - turnStartAt)
1415 : live?.reasoningStartedAt
1416 ? Math.max(0, now - live.reasoningStartedAt)
1417 : 0
1418 : 0;
1419 const effectiveDurationMs = hasRunningWork ? Math.max(durationMs, runningDurationMs) : durationMs;
1420
1421 useGSAPCollapse(bodyRef, open);
1422 useEffect(() => {
1423 const wasRunning = prevRunningRef.current;
1424 prevRunningRef.current = hasRunningWork;
1425 if (hasRunningWork) {
1426 if (!wasRunning) userOverriddenOpen.current = false;
1427 if (!userOverriddenOpen.current) setOpen(true);
1428 } else if (wasRunning && !userOverriddenOpen.current && hasOutsideContent && foldPreference !== "expanded") {
1429 setOpen(false);
1430 }
1431 }, [hasRunningWork, hasOutsideContent, foldPreference]);
1432 // Switching the preference is an explicit act that also applies to folds
1433 // already on screen, not only future ones; it clears per-fold manual
1434 // overrides so the whole transcript lands in one consistent state.
1435 const prevFoldPreference = useRef(foldPreference);
1436 useEffect(() => {
1437 if (prevFoldPreference.current === foldPreference) return;
1438 prevFoldPreference.current = foldPreference;
1439 userOverriddenOpen.current = false;
1440 if (foldPreference === "expanded") {
1441 setOpen(true);
1442 } else if (!hasRunningWork && hasOutsideContent) {
1443 setOpen(false);
1444 }
1445 }, [foldPreference, hasRunningWork, hasOutsideContent]);
1446
1447 if (displayItems.length === 0) return null;
1448
1449 const collapseKind = preferredKind ?? (displayItems.some((it) => it.kind === "tool")
1450 ? "tool"
1451 : displayItems.some((it) => it.kind === "assistant" && Boolean(it.reasoning))
1452 ? "reasoning"
1453 : "process");
1454 const baseLabel = collapseKind === "reasoning"
1455 ? workStatusLabel(effectiveDurationMs, hasRunningWork, t)
1456 : seconds > 0
1457 ? t("transcript.processedDuration", { s: seconds })
1458 : t("transcript.processed");
1459 // Surface what the closed fold hides — a bare duration reads as pure timing
1460 // and users have no way to know process detail sits behind it.
1461 const toolCount = displayItems.reduce((n, it) => n + (it.kind === "tool" ? 1 : 0), 0);
1462 const thoughtCount = displayItems.reduce((n, it) => n + (it.kind === "assistant" ? 1 : 0), 0);
1463 const countParts: string[] = [];
1464 if (toolCount > 0) countParts.push(t("transcript.toolCount", { n: toolCount }));
1465 if (thoughtCount > 0) countParts.push(t("transcript.thoughtCount", { n: thoughtCount }));
1466 const label = labelStyle === "counts"
1467 ? (countParts.length > 0 ? countParts.join(" · ") : t("transcript.processed"))
1468 : countParts.length > 0
1469 ? `${baseLabel} · ${countParts.join(" · ")}`
1470 : baseLabel;
1471 const creationLabel = collapseKind === "tool"
1472 ? t("creation.toolCallsLabel")
1473 : collapseKind === "reasoning"
1474 ? label
1475 : label;
1476
1477 // Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch
1478 const body: ReactNode[] = [];
1479 const roBatch: ToolItem[] = [];
1480 const toolBatch: ToolItem[] = [];
1481 let toolBatchKind: ToolGroupKind | null = null;
1482 const flushRO = () => {
1483 if (roBatch.length === 0) return;
1484 body.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcalls} tabId={tabId} />);
1485 roBatch.length = 0;
1486 };
1487 const flushToolBatch = () => {
1488 if (!toolBatchKind || toolBatch.length === 0) return;
1489 body.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcalls} tabId={tabId} />);
1490 toolBatch.length = 0;
1491 toolBatchKind = null;
1492 };
1493 for (const it of displayItems) {
1494 if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
1495 const kind = toolGroupKind(it as ToolItem);
1496 if (kind) {
1497 if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
1498 toolBatchKind = kind;
1499 toolBatch.push(it as ToolItem);
1500 continue;
1501 }
1502 }
1503 if (it.kind !== "tool") {
1504 flushToolBatch();
1505 flushRO();
1506 }
1507 if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && it.readOnly) {
1508 roBatch.push(it as ToolItem);
1509 continue;
1510 }
1511 if (it.kind === "tool") {
1512 flushToolBatch();
1513 flushRO();
1514 }
1515 switch (it.kind) {
1516 case "tool":
1517 if (it.parentId) break;
1518 if (it.name === "todo_write") break;
1519 if (it.name === "exit_plan_mode") break;
1520 body.push(<ToolCard key={it.id} item={it as ToolItem} subcalls={subcalls.get(it.id)} tabId={tabId} />);
1521 break;
1522 case "phase": body.push(<PhaseCard key={it.id} text={it.text} />); break;
1523 case "notice": body.push(<NoticeCard key={it.id} item={it} />); break;
1524 case "compaction": body.push(<CompactionCard key={it.id} item={it} />); break;
1525 case "assistant":
1526 // Answer text renders outside the fold (partitionTurnItems strips it),
1527 // so the fold only ever shows the reasoning segment.
1528 body.push(<InlineAssistantReasoning key={`${it.id}-reasoning`} item={it as AssistantItem} />);
1529 break;
1530 }
1531 }
1532 flushToolBatch();
1533 flushRO();
1534
1535 return (
1536 <div className={`turn-collapse${open ? " turn-collapse--open" : ""}`} data-kind={collapseKind} data-entrance={displayItems[0]?.id || undefined}>
1537 <button
1538 type="button"
1539 className="reasoning__head"
1540 onClick={() => {
1541 userOverriddenOpen.current = true;
1542 setOpen((v) => !v);
1543 }}
1544 aria-expanded={open}
1545 >
1546 <span className="turn-collapse__label" data-creation-label={creationLabel}>{label}</span>
1547 {!hasRunningWork && <ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />}
1548 </button>
1549 <div ref={bodyRef} className="turn-collapse__body">{body}</div>
1550 </div>
1551 );
1552 }
1553
1554 // ── JumpBar, PhaseCard, NoticeCard, CompactionCard ────────────────────────────
1555
1556 function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
1557 const t = useT();
1558 const [hovered, setHovered] = useState<number | null>(null);
1559 const [active, setActive] = useState<number | null>(null);
1560 const barRef = useRef<HTMLDivElement>(null);
1561 const previewTop = useRef(0);
1562 const [showPreview, setShowPreview] = useState(false);
1563
1564 useEffect(() => {
1565 if (questions.length === 0) return;
1566 setActive(questions[questions.length - 1]?.turn ?? null);
1567 }, [questions]);
1568
1569 useEffect(() => {
1570 if (active === null) return;
1571 const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
1572 el?.scrollIntoView({ block: "nearest" });
1573 }, [active]);
1574
1575 const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
1576 const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
1577
1578 const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
1579 const el = barRef.current;
1580 if (!el) return null;
1581 const markers = el.querySelectorAll<HTMLElement>(".jump-item");
1582 const barRect = el.getBoundingClientRect();
1583 let closest = -1;
1584 let closestDist = Infinity;
1585 let closestY = 0;
1586 markers.forEach((item, index) => {
1587 const rect = item.getBoundingClientRect();
1588 const midY = rect.top + rect.height / 2;
1589 const dist = Math.abs(clientY - midY);
1590 if (dist < closestDist) {
1591 closestDist = dist;
1592 closest = index;
1593 closestY = midY - barRect.top;
1594 }
1595 });
1596 const question = questions[closest];
1597 if (!question) return null;
1598 return { question, previewY: closestY };
1599 };
1600
1601 const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
1602 const closest = closestQuestionFromY(e.clientY);
1603 if (!closest) return;
1604 previewTop.current = closest.previewY;
1605 setHovered(closest.question.turn);
1606 setShowPreview(true);
1607 };
1608
1609 const scrollTo = (question: QuestionAnchor) => {
1610 setActive(question.turn);
1611 onJump(question);
1612 };
1613
1614 const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
1615 const closest = closestQuestionFromY(e.clientY);
1616 if (!closest) return;
1617 e.preventDefault();
1618 previewTop.current = closest.previewY;
1619 setHovered(closest.question.turn);
1620 setShowPreview(true);
1621 scrollTo(closest.question);
1622 };
1623
1624 const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
1625 e.preventDefault();
1626 scrollTo(question);
1627 };
1628
1629 const dotProps = (
1630 idx: number,
1631 turn: number,
1632 ): { style: CSSProperties; "data-d"?: string } => {
1633 const isActive = active === turn;
1634 if (hoverIdx < 0) {
1635 return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
1636 }
1637 const d = Math.abs(idx - hoverIdx);
1638 const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
1639 const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
1640 return {
1641 style: { width, transitionDelay: `${d * 20}ms`, background },
1642 "data-d": d <= 2 ? String(d) : undefined,
1643 };
1644 };
1645
1646 return (
1647 <nav
1648 className="jump-bar"
1649 ref={barRef}
1650 aria-label={t("questionNav.label")}
1651 onMouseMove={onMove}
1652 onMouseLeave={() => {
1653 setHovered(null);
1654 setShowPreview(false);
1655 }}
1656 >
1657 <div className="jump-scroll" onMouseDown={onRailMouseDown} onClick={onRailMouseDown}>
1658 {questions.map((question, index) => (
1659 <button
1660 className="jump-item"
1661 key={question.id}
1662 type="button"
1663 data-turn={question.turn}
1664 aria-label={t("questionNav.jump", { n: question.turn + 1 })}
1665 onMouseDown={(e) => onItemMouseDown(e, question)}
1666 onClick={(e) => {
1667 e.stopPropagation();
1668 if (e.detail === 0) scrollTo(question);
1669 }}
1670 >
1671 <span className="jump-dot" {...dotProps(index, question.turn)} />
1672 </button>
1673 ))}
1674 </div>
1675 {showPreview && hoveredQuestion && (
1676 <div className="jump-preview" style={{ top: previewTop.current }} role="tooltip">
1677 <span className="jump-text">{hoveredQuestion.text}</span>
1678 </div>
1679 )}
1680 </nav>
1681 );
1682 }
1683
1684 type CompactionItem = Extract<Item, { kind: "compaction" }>;
1685
1686 function PhaseCard({ text }: { text: string }) {
1687 return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
1688 }
1689
1690 // A mid-turn steer is the user's own message, so it renders on the user side
1691 // of the transcript instead of disappearing into the work fold.
1692 function SteerCard({ text }: { text: string }) {
1693 const t = useT();
1694 const body = text.startsWith(STEER_NOTICE_PREFIX) ? text.slice(STEER_NOTICE_PREFIX.length) : text;
1695 return (
1696 <div className="steer-line" data-entrance="true">
1697 <div className="steer-line__bubble" title={t("transcript.steer")}>
1698 <span className="steer-line__icon" aria-hidden="true">↪</span>
1699 <span className="steer-line__text">{body}</span>
1700 </div>
1701 </div>
1702 );
1703 }
1704
1705 function DecisionReceiptLine({ receipt }: { receipt: NonNullable<NoticeItem["decisionReceipt"]> }) {
1706 const t = useT();
1707 const titleKey = receipt.kind === "ask"
1708 ? "notice.decisionReceiptAsk"
1709 : receipt.kind === "plan"
1710 ? "notice.decisionReceiptPlan"
1711 : receipt.kind === "recovery"
1712 ? "notice.decisionReceiptRecovery"
1713 : "notice.decisionReceiptTool";
1714 const outcomeKeys: Record<string, string> = {
1715 allow_once: "notice.decisionAllowOnce",
1716 allow_session: "notice.decisionAllowSession",
1717 allow_persistent: "notice.decisionAllowPersistent",
1718 deny: "notice.decisionDeny",
1719 start_execution: "notice.decisionStartExecution",
1720 revise_plan: "notice.decisionRevisePlan",
1721 exit_plan: "notice.decisionExitPlan",
1722 recovery_continue: "notice.decisionRecoveryContinue",
1723 recovery_continue_task: "notice.decisionRecoveryContinueTask",
1724 recovery_revise: "notice.decisionRecoveryRevise",
1725 answered: "notice.decisionAnswered",
1726 };
1727 const outcome = outcomeKeys[receipt.outcome]
1728 ? t(outcomeKeys[receipt.outcome] as never)
1729 : receipt.outcome || t("notice.decisionReceiptTitle");
1730 const showOutcome = receipt.kind !== "ask" || receipt.outcome !== "answered";
1731 return (
1732 <div className="notice-line__decision-receipt">
1733 <span className="notice-line__decision-title">{t(titleKey as never)}</span>
1734 {showOutcome && <span className="notice-line__decision-outcome">{outcome}</span>}
1735 {receipt.tool && <code>{receipt.tool}</code>}
1736 {receipt.subject && <span className="notice-line__decision-subject">{receipt.subject}</span>}
1737 </div>
1738 );
1739 }
1740
1741 export function NoticeCard({ item, onAction, actionDisabled = false }: { item: NoticeItem; onAction?: () => void; actionDisabled?: boolean }) {
1742 const t = useT();
1743 const StatusIcon = item.level === "warn" ? TriangleAlert : Info;
1744 return (
1745 <div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance="true">
1746 <StatusIcon className="notice-line__icon" size={14} aria-hidden="true" />
1747 <div className="notice-line__text">
1748 {item.decisionReceipt ? (
1749 <DecisionReceiptLine receipt={item.decisionReceipt} />
1750 ) : (
1751 <>
1752 {item.title ? <div className="notice-line__title">{item.title}</div> : null}
1753 <div className="notice-line__body">{item.text}</div>
1754 </>
1755 )}
1756 {item.action && onAction ? (
1757 <div className="notice-line__actions">
1758 <button className="btn btn--small" type="button" onClick={onAction} disabled={actionDisabled}>
1759 <CirclePlay size={13} aria-hidden="true" />
1760 <span>{t("notice.deliveryIncompleteContinue")}</span>
1761 </button>
1762 </div>
1763 ) : null}
1764 {item.detail ? (
1765 <details className="notice-line__details">
1766 <summary>{t("notice.details")}</summary>
1767 <div>{item.detail}</div>
1768 </details>
1769 ) : null}
1770 </div>
1771 </div>
1772 );
1773 }
1774
1775 function CompactionCard({ item }: { item: CompactionItem }) {
1776 const t = useT();
1777 const [open, setOpen] = useState(false);
1778 if (item.pending) {
1779 return <div className="compaction compaction--pending" data-entrance={item.id}><ProcessCompactIcon size={12} /><span>{t("compaction.working")}</span></div>;
1780 }
1781 return (
1782 <div className="compaction" data-entrance={item.id}>
1783 <button type="button" className="compaction__head" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
1784 <ProcessCompactIcon size={12} />
1785 <span>{t("compaction.title")}</span>
1786 <span className="compaction__meta">{t("compaction.messages", { n: item.messages })}{item.trigger ? ` · ${item.trigger}` : ""}</span>
1787 <ChevronRight className={open ? "compaction__chevron--open" : ""} size={12} />
1788 </button>
1789 {open && <pre className="compaction__body">{item.summary}</pre>}
1790 </div>
1791 );
1792 }
1793
1793 lines Plain Text