1import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
2export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
3import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
4import * as DateTime from "effect/DateTime";
5import * as Equal from "effect/Equal";
6import {
7 assistantCitationLabel,
8 collectAssistantCitations,
9} from "@t3tools/shared/assistantCitations";
10import { shallow } from "zustand/vanilla/shallow";
11import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives";
12import {
13 commandDisplayText,
14 commandProgramName,
15} from "@t3tools/client-runtime/work-log/command-label";
16import {
17 liveActivityToolStatus,
18 normalizeCompactToolLabel,
19 resolveWorkEntryToolPresentation,
20 summarizeToolGroup,
21 toolGroupAction,
22 toolGroupSummaryKind,
23 type ToolGroupSummaryKind,
24} from "@t3tools/client-runtime/work-log/presentation";
25export {
26 normalizeCompactToolLabel,
27 toolGroupAction,
28} from "@t3tools/client-runtime/work-log/presentation";
29import {
30 deriveRevertTurnCountByUserMessageId,
31 formatDuration,
32 isStreamingMessageTextUpdate,
33 isStreamingTurnItemTextUpdate,
34 timelineEntryIsPersistentResourceCard,
35 workEntryDisplayIndicatesToolFailure,
36 workEntryIndicatesToolSuccess,
37 workEntryIndicatesToolNeutralStatus,
38 workLogEntryIsToolLike,
39 type TimelineEntry,
40 type WorkLogEntry,
41} from "../../session-logic";
42import {
43 type ChatMessage,
44 type ProposedPlan,
45 type ThreadShell,
46 type TurnDiffSummary,
47} from "../../types";
48import {
49 type MessageId,
50 type WorktreeSetupSnapshot,
51 type OrchestrationV2ProjectedTurnItem,
52 type RunAttemptId,
53 RunId,
54 type ThreadId,
55 type TurnItemId,
56} from "@t3tools/contracts";
57import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
58import {
59 resolveT3McpToolDefinition,
60 resolveT3McpToolPresentation,
61 type T3McpToolPresentation,
62} from "@t3tools/shared/t3McpToolPresentation";
63import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
64import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender";
65import { mcpAppReferencesEqual, type McpAppReference } from "@t3tools/shared/mcpApp";
66import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
67import { formatWorkspaceRelativePath } from "../../filePathDisplay";
68import {
69 collectToolFilePaths,
70 formatReadToolLabel,
71 formatSearchToolLabel,
72} from "@t3tools/shared/toolActivity";
73import { isWindowsAbsolutePath } from "@t3tools/shared/path";
74
75function timelineEntryRunId(entry: TimelineEntry): RunId | null {
76 if (entry.kind === "message") {
77 return entry.message.role === "assistant" ? (entry.message.runId ?? null) : null;
78 }
79 if (entry.kind === "proposed-plan") {
80 return entry.proposedPlan.runId;
81 }
82 if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId;
83 return entry.kind === "work" ? (entry.entry.runId ?? null) : null;
84}
85
86/** Whether the entry still represents live activity, not a settled result. */
87function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
88 return (
89 entry.toolLifecycleStatus === "inProgress" ||
90 (entry.toolLifecycleStatus === undefined &&
91 (entry.sourceActivityKind === "task.progress" || workLogEntryIsToolLike(entry)))
92 );
93}
94
95function singleToolCallLabel(entry: WorkLogEntry): string {
96 if (entry.itemType === "reasoning") return entry.detail?.trim().replace(/\s+/g, " ") || "Thought";
97 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");
98 if (toolPresentation) return toolPresentation.displayName;
99 const item = entry.structuredPayload;
100 const title =
101 item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null;
102 if (title) return title;
103 // A lone web search keeps its heading; the query stays in its detail.
104 if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search";
105 return workEntryDisplayLabel(entry, undefined);
106}
107
108function workEntryToolDataRecord(entry: WorkLogEntry): Record<string, unknown> | undefined {
109 return entry.toolData !== null &&
110 typeof entry.toolData === "object" &&
111 !Array.isArray(entry.toolData)
112 ? (entry.toolData as Record<string, unknown>)
113 : undefined;
114}
115
116function workEntryReadRawPaths(entry: WorkLogEntry): readonly string[] {
117 const fromChanged = entry.changedFiles ?? [];
118 if (fromChanged.length > 0) return fromChanged;
119 const structured = entry.structuredPayload;
120 if (structured?.type === "dynamic_tool") {
121 const structuredPaths = collectToolFilePaths({ input: structured.input });
122 if (structuredPaths.length > 0) return structuredPaths;
123 }
124 return collectToolFilePaths(workEntryToolDataRecord(entry));
125}
126
127function workEntryReadPaths(entry: WorkLogEntry, workspaceRoot: string | undefined): string[] {
128 return workEntryReadRawPaths(entry).map((filePath) =>
129 formatWorkspaceRelativePath(filePath, workspaceRoot),
130 );
131}
132
133export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) {
134 if (entry.itemType === "system_notice") return entry.label;
135 if (entry.itemType === "reasoning" || entry.tone === "thinking") {
136 const thought = entry.detail?.trim().replace(/\s+/g, " ");
137 return thought || entry.label;
138 }
139 const toolPresentation = resolveWorkEntryToolPresentation(entry);
140 if (toolPresentation) return toolPresentation.displayName;
141 if (entry.command?.trim()) return commandDisplayText(entry.command);
142 const action = toolGroupAction(entry);
143 if (action === "code-search" || action === "search") {
144 // Adapters title file searches with their target; the item keeps only the pattern.
145 const searchLabel =
146 entry.itemType === "file_search"
147 ? entry.label
148 : formatSearchToolLabel(workEntryToolDataRecord(entry));
149 if (searchLabel) return searchLabel;
150 }
151 const readPaths = action === "read" ? workEntryReadPaths(entry, workspaceRoot) : [];
152 if (action === "read" && readPaths[0]) {
153 return formatReadToolLabel(readPaths[0], readPaths.length - 1);
154 }
155 // Retrying providers keep their progress label; other diagnostics expose
156 // the retained message instead of a generic error heading. File bodies
157 // are never a compact read label.
158 const providerRetry =
159 entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined;
160 const item = entry.structuredPayload;
161 const title =
162 item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) : null;
163 if (title) return title;
164 const compactDetail = entry.detail?.trim();
165 const detailIsSearchOutput =
166 (action === "code-search" || action === "search") && /[\r\n]/.test(compactDetail ?? "");
167 if (compactDetail && !providerRetry && action !== "read" && !detailIsSearchOutput) {
168 return compactDetail;
169 }
170 const [firstPath] = entry.changedFiles ?? [];
171 if (firstPath) {
172 const path = formatWorkspaceRelativePath(firstPath, workspaceRoot);
173 return entry.changedFiles!.length === 1
174 ? path
175 : `${path} +${entry.changedFiles!.length - 1} more`;
176 }
177 if (action === "read" && !entry.viewedImagePath) {
178 return "Read file";
179 }
180 const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label);
181 return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;
182}
183
184/** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */
185export function threadReadTargetId(entry: Pick<WorkLogEntry, "structuredPayload">) {
186 const item = entry.structuredPayload;
187 if (item?.type !== "dynamic_tool") return null;
188 if (resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "thread-read") return null;
189 const input = item.input;
190 const threadId =
191 input !== null && typeof input === "object" && "threadId" in input ? input.threadId : null;
192 return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null;
193}
194
195const THREAD_READ_OBJECT = " a T3 thread";
196
197export function threadReadTargetTitle(
198 shell: Pick<ThreadShell, "title" | "archivedAt" | "deletedAt"> | null,
199) {
200 if (!shell || shell.archivedAt !== null || shell.deletedAt !== null) return null;
201 return shell.title.trim() || null;
202}
203
204/**
205 * Names the read thread in place of the generic object ("Read a T3 thread" becomes
206 * `Read thread “Title”`), keeping the label's tense. Null keeps the generic label.
207 */
208export function threadReadLabelPrefix(label: string) {
209 return label.endsWith(THREAD_READ_OBJECT)
210 ? `${label.slice(0, -THREAD_READ_OBJECT.length)} thread`
211 : null;
212}
213
214/** Inspectable read-file output is the path when we have one, otherwise nothing. */
215export function workEntryReadOutput(
216 entry: Pick<
217 WorkLogEntry,
218 "changedFiles" | "detail" | "viewedImagePath" | "structuredPayload" | "toolData"
219 >,
220 workspaceRoot: string | undefined,
221): string | null {
222 const paths = [
223 ...new Set(
224 workEntryReadRawPaths(entry as WorkLogEntry)
225 .map((path) => {
226 const trimmed = path.trim().replaceAll("\\", "/");
227 if (!workspaceRoot || trimmed.startsWith("/") || isWindowsAbsolutePath(trimmed)) {
228 return trimmed;
229 }
230 const root = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, "");
231 const relative = trimmed.replace(/^\.\//, "").replace(/^\/+/, "");
232 return `${root}/${relative}`;
233 })
234 .filter((path) => path.length > 0),
235 ),
236 ];
237 if (paths.length > 0) {
238 return paths.join("\n");
239 }
240 return null;
241}
242
243export function liveWorkEntryLabel(
244 entry: WorkLogEntry,
245 workspaceRoot: string | undefined,
246 active: boolean,
247) {
248 const status = liveActivityToolStatus(entry.toolLifecycleStatus, active);
249 if (entry.itemType === "reasoning") {
250 return (
251 entry.detail?.trim().replace(/\s+/g, " ") ||
252 (status === "inProgress" ? "Thinking" : "Thought")
253 );
254 }
255 const toolPresentation = resolveWorkEntryToolPresentation({
256 ...entry,
257 toolLifecycleStatus: status,
258 });
259 if (toolPresentation) return toolPresentation.displayName;
260 const command = entry.command?.trim();
261 if (command) {
262 const verb =
263 status === "inProgress"
264 ? "Running"
265 : status === "failed"
266 ? "Failed"
267 : status === "declined"
268 ? "Declined"
269 : status === "stopped"
270 ? "Stopped"
271 : "Ran";
272 return `${verb} ${commandProgramName(command) ?? "command"}`;
273 }
274 return workEntryDisplayLabel(entry, workspaceRoot);
275}
276
277export function workEntryIsVisibleInGroup(
278 entry: WorkLogEntry,
279 expandedToolGroupEntry = false,
280): boolean {
281 if (entry.itemType === "reasoning") return Boolean(entry.detail?.trim());
282 return (
283 (expandedToolGroupEntry &&
284 (entry.toolLifecycleStatus === "inProgress" ||
285 entry.sourceActivityKind === "task.progress")) ||
286 // A stopped call is an outcome ("Stopped sleep"), not an empty row.
287 entry.toolLifecycleStatus === "stopped" ||
288 !workEntryIndicatesToolNeutralStatus(entry)
289 );
290}
291const TIMELINE_MINIMAP_ITEM_SPACING = 8;
292export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
293const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
294const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
295
296export interface WorkGroupScrollAnchor {
297 readonly entryId: string;
298 readonly offset: number;
299}
300
301/** Restore a visible tool, including a position partway through its expanded output. */
302export function resolveWorkGroupScrollIndex(
303 entries: ReadonlyArray<{ readonly id: string }>,
304 anchor: WorkGroupScrollAnchor | undefined,
305): { index: number; viewOffset: number } | undefined {
306 if (!anchor) return undefined;
307 const index = entries.findIndex((entry) => entry.id === anchor.entryId);
308 return index < 0 ? undefined : { index, viewOffset: -anchor.offset };
309}
310
311/** Only newly appended calls may follow the end, never status or output updates. */
312export function shouldFollowWorkGroupAppend(
313 previous: ReadonlyArray<{ readonly id: string }>,
314 entries: ReadonlyArray<{ readonly id: string }>,
315 distanceFromEnd: number,
316): boolean {
317 return (
318 previous.length > 0 &&
319 entries.length > previous.length &&
320 distanceFromEnd <= 1 &&
321 previous.every((entry, index) => entry.id === entries[index]?.id)
322 );
323}
324
325export interface TimelineEndState {
326 readonly isAtEnd?: boolean;
327 readonly isNearEnd?: boolean;
328 readonly contentLength?: number;
329 readonly scroll?: number;
330 readonly scrollLength?: number;
331}
332
333/**
334 * The follow re-arm band (#5566): strict isAtEnd flickers false for a frame
335 * while streaming content grows under the viewport, so follow re-arms within
336 * this distance of the real content bottom instead.
337 */
338const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
339
340export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
341 if (!state) {
342 return undefined;
343 }
344 const { contentLength, scroll, scrollLength } = state;
345 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {
346 return state.isAtEnd;
347 }
348 // contentLength includes the composer inset spacer, but the composer hides
349 // the same amount of viewport, so the inset cancels: plain
350 // contentLength - scroll - scrollLength is the gap between the last real row
351 // and the visible edge above the composer. LegendList's own isAtEnd subtracts
352 // the inset and is true anywhere in the bottom composer-height band, so it is
353 // only a fallback here, never a short-circuit.
354 return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
355}
356
357export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
358 const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);
359 return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;
360}
361
362export function resolveTimelineMinimapTopPercent(index: number, itemCount: number): number {
363 if (itemCount <= 1) {
364 return 0;
365 }
366 return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;
367}
368
369export function resolveTimelineMinimapIndexFromPointer(input: {
370 readonly itemCount: number;
371 readonly railTop: number;
372 readonly railHeight: number;
373 readonly pointerY: number;
374}): number | null {
375 if (input.itemCount <= 0 || input.railHeight <= 0) {
376 return null;
377 }
378 if (input.itemCount === 1) {
379 return 0;
380 }
381
382 const progress = Math.max(0, Math.min(1, (input.pointerY - input.railTop) / input.railHeight));
383 return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))));
384}
385
386export function resolveTimelineMinimapCurrentIndex(input: {
387 readonly scrollTop: number;
388 readonly scrollBottom: number;
389 readonly itemBounds: ReadonlyArray<{
390 readonly top: number | null;
391 readonly height: number | null;
392 }>;
393}): number | null {
394 let precedingIndex: number | null = null;
395
396 for (const [index, item] of input.itemBounds.entries()) {
397 if (item.top === null) {
398 continue;
399 }
400 const inView =
401 item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop;
402 if (inView) {
403 // The first visible marker is the turn at the reader's current position.
404 return index;
405 }
406 if (item.top <= input.scrollTop) {
407 precedingIndex = index;
408 }
409 }
410
411 return precedingIndex;
412}
413
414/**
415 * Side gutter between the viewport edge and the centered content column.
416 * `contentWidth` is the rendered column width, which follows the Chat width
417 * setting, so callers measure it rather than assume a fixed maximum.
418 */
419function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number {
420 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) {
421 return 0;
422 }
423 return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2);
424}
425
426export function resolveTimelineMinimapHasPersistentGutter(
427 viewportWidth: number,
428 contentWidth: number,
429): boolean {
430 return (
431 resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER
432 );
433}
434
435const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
436const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40;
437const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";
438
439/**
440 * The minimap overlays the viewport's left edge while the content column is
441 * centered, so the side gutter between them shrinks under browser zoom, a
442 * narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message
443 * text and swallow its pointer events. Cap the strip's width so it never
444 * extends past the gutter into the content column; 0 disables the strip.
445 */
446export function resolveTimelineMinimapHitStripWidth(
447 viewportWidth: number,
448 contentWidth: number,
449): number {
450 const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth);
451 return Math.max(
452 0,
453 Math.min(
454 TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH,
455 Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT,
456 ),
457 );
458}
459
460// The prev/next buttons are centered 4px into the strip and 20px wide, so
461// their hitbox reaches 14px past the strip's left edge.
462const TIMELINE_MINIMAP_NAVIGATION_REACH = 14;
463
464/**
465 * The prev/next buttons hang outside the strip's height, so the strip's own
466 * width cap does not cover them. Keep them inert to the pointer unless the
467 * gutter can hold them; keyboard focus still reaches them.
468 */
469export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: number): boolean {
470 return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH;
471}
472
473/**
474 * Once the preview is open, keep the full preview and the space leading to it
475 * interactive. The collapsed strip remains gutter-capped so it cannot block
476 * selecting message text.
477 */
478export function resolveTimelineMinimapInteractiveWidth(
479 collapsedWidth: number,
480 expanded: boolean,
481): number | string {
482 return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth;
483}
484
485function computeElapsedMs(startIso: string, endIso: string): number | null {
486 const start = Date.parse(startIso);
487 const end = Date.parse(endIso);
488 if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
489 return Math.max(0, end - start);
490}
491
492function maxIsoTimestamp(a: string | null, b: string | null): string | null {
493 if (a === null) return b;
494 if (b === null) return a;
495 const aMs = Date.parse(a);
496 const bMs = Date.parse(b);
497 if (!Number.isFinite(aMs)) return b;
498 if (!Number.isFinite(bMs)) return a;
499 return bMs > aMs ? b : a;
500}
501
502export interface TimelineDurationMessage {
503 id: string;
504 role: "user" | "assistant" | "system";
505 createdAt: string;
506 updatedAt: string;
507 streaming: boolean;
508}
509
510export type TimelineLatestRun = Pick<
511 ThreadRunSummary,
512 "runId" | "status" | "startedAt" | "completedAt"
513>;
514
515const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
516
517export type MessagesTimelineRow = MessagesTimelineRowContent & {
518 readonly continuesWorkLog?: boolean;
519};
520
521type MessagesTimelineRowContent =
522 | {
523 kind: "worktree-setup";
524 id: string;
525 createdAt: string | null;
526 snapshot: WorktreeSetupSnapshot;
527 embedded: boolean;
528 }
529 | {
530 kind: "work";
531 id: string;
532 createdAt: string;
533 groupedEntries: WorkLogEntry[];
534 isExpandedToolGroup: boolean;
535 displayLabel?: string;
536 }
537 | {
538 kind: "work-live";
539 id: string;
540 createdAt: string;
541 entry: WorkLogEntry;
542 groupedEntries: WorkLogEntry[];
543 groupId: string;
544 expanded: boolean;
545 active: boolean;
546 /** Latest reasoning in the live group, shown above the status line. */
547 thought?: WorkLogEntry;
548 }
549 | {
550 kind: "working";
551 id: string;
552 createdAt: string | null;
553 compaction?: WorkLogEntry;
554 }
555 | {
556 kind: "thinking";
557 id: string;
558 createdAt: string | null;
559 /** Tool calls this row stands in for after the latest one failed. */
560 groupId?: string;
561 expanded?: boolean;
562 }
563 | {
564 kind: "work-toggle";
565 id: string;
566 createdAt: string;
567 runId?: RunId | null;
568 groupId: string;
569 hiddenCount: number;
570 expanded: boolean;
571 summary: string;
572 summaryKind: ToolGroupSummaryKind;
573 toolSurface?: WorkLogEntry["toolSurface"];
574 toolIcon?: WorkLogEntry["toolIcon"];
575 summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
576 hasFailure: boolean;
577 }
578 | {
579 kind: "turn-fold";
580 id: string;
581 createdAt: string;
582 runId: RunId;
583 label: string;
584 expanded: boolean;
585 summaryOnly?: true;
586 }
587 | {
588 kind: "attempt-fold";
589 id: string;
590 createdAt: string;
591 runId: RunId;
592 attemptId: RunAttemptId;
593 label: string;
594 expanded: boolean;
595 }
596 | {
597 kind: "context-compaction";
598 id: string;
599 createdAt: string;
600 label: string;
601 active: boolean;
602 }
603 | {
604 kind: "message";
605 id: string;
606 createdAt: string;
607 message: ChatMessage;
608 projectedItem?: OrchestrationV2ProjectedTurnItem;
609 durationStart: string;
610 showAssistantMeta: boolean;
611 showAssistantCopyButton: boolean;
612 assistantCopyStreaming: boolean;
613 assistantTurnDiffSummary?: TurnDiffSummary | undefined;
614 revertTurnCount?: number | undefined;
615 }
616 | {
617 kind: "assistant-meta";
618 projectedItem?: OrchestrationV2ProjectedTurnItem;
619 id: string;
620 createdAt: string;
621 message: ChatMessage;
622 showAssistantCopyButton: boolean;
623 assistantCopyStreaming: boolean;
624 }
625 | {
626 kind: "event";
627 id: string;
628 createdAt: string;
629 projectedItem: OrchestrationV2ProjectedTurnItem;
630 subagents?: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
631 resourceSummary?: boolean;
632 }
633 | {
634 kind: "proposed-plan";
635 id: string;
636 createdAt: string;
637 proposedPlan: ProposedPlan;
638 }
639 | {
640 kind: "html-render";
641 id: string;
642 createdAt: string;
643 htmlRender: HtmlRenderReference;
644 }
645 | {
646 kind: "mcp-app";
647 id: string;
648 createdAt: string;
649 sourceThreadId: ThreadId;
650 itemId: TurnItemId;
651 revision: string;
652 mcpApp: McpAppReference;
653 };
654
655export interface StableMessagesTimelineRowsState {
656 byId: Map<string, MessagesTimelineRow>;
657 result: MessagesTimelineRow[];
658}
659
660export function computeMessageDurationStart(
661 messages: ReadonlyArray<TimelineDurationMessage>,
662): Map<string, string> {
663 const result = new Map<string, string>();
664 let lastBoundary: string | null = null;
665
666 for (const message of messages) {
667 if (message.role === "user") {
668 lastBoundary = message.createdAt;
669 }
670 result.set(message.id, lastBoundary ?? message.createdAt);
671 if (message.role === "assistant" && !message.streaming) {
672 lastBoundary = message.updatedAt;
673 }
674 }
675
676 return result;
677}
678
679function workGroupId(timelineEntryId: string): string {
680 return `work-group:${timelineEntryId}`;
681}
682
683export type TimelineToolPresentation = T3McpToolPresentation;
684export const resolveTimelineToolPresentation = resolveT3McpToolPresentation;
685
686function expandedWorkGroupRow(
687 groupId: string,
688 createdAt: string,
689 groupedEntries: WorkLogEntry[],
690): Extract<MessagesTimelineRow, { kind: "work" }> {
691 return {
692 kind: "work",
693 id: `${groupId}:details`,
694 createdAt,
695 groupedEntries,
696 isExpandedToolGroup: true,
697 };
698}
699
700export function resolveAssistantMessageCopyState({
701 text,
702 showCopyButton,
703 streaming,
704}: {
705 text: string | null;
706 showCopyButton: boolean;
707 streaming: boolean;
708}) {
709 const hasText = text !== null && text.trim().length > 0;
710 const visible = showCopyButton && hasText && !streaming;
711 return {
712 text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null,
713 visible,
714 };
715}
716
717function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {
718 const lastAssistantMessageIdByResponseKey = new Map<string, string>();
719 let nullTurnResponseIndex = 0;
720
721 for (const timelineEntry of timelineEntries) {
722 if (timelineEntry.kind !== "message") {
723 continue;
724 }
725 const { message } = timelineEntry;
726 if (message.role === "user") {
727 nullTurnResponseIndex += 1;
728 continue;
729 }
730 if (message.role !== "assistant") {
731 continue;
732 }
733
734 const responseKey = message.runId
735 ? `turn:${message.runId}`
736 : `unkeyed:${nullTurnResponseIndex}`;
737 lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
738 }
739
740 return new Set(lastAssistantMessageIdByResponseKey.values());
741}
742
743interface TurnFold {
744 runId: RunId;
745 anchorEntryId: string;
746 createdAt: string;
747 hiddenEntryIds: ReadonlySet<string>;
748 label: string;
749}
750
751interface SupersededAttemptFold {
752 readonly runId: RunId;
753 readonly attemptId: RunAttemptId;
754 readonly anchorEntryId: string;
755 readonly createdAt: string;
756 readonly hiddenEntryIds: ReadonlySet<string>;
757}
758
759/**
760 * Final replies of superseded attempts that finished before the steer replaced
761 * them. A steer stamps the attempt's completedAt when it is dispatched, and the
762 * provider closes a reply it cut off only after that, so a reply whose item
763 * completed earlier was whole. Item status alone cannot tell: an interrupted
764 * stream also closes as `completed`. The reply must be the attempt's last
765 * output; trailing tool or reasoning work means the attempt was still going.
766 */
767function completeSupersededReplyEntryIds(
768 timelineEntries: ReadonlyArray<TimelineEntry>,
769): ReadonlySet<string> {
770 const lastOutputByAttemptId = new Map<RunAttemptId, TimelineEntry>();
771 for (const entry of timelineEntries) {
772 if (
773 entry.attempt?.status !== "superseded" ||
774 (entry.kind === "message" && entry.message.role === "user") ||
775 (entry.kind === "work" &&
776 (entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification")) ||
777 (entry.kind !== "message" && entry.kind !== "work")
778 ) {
779 continue;
780 }
781 lastOutputByAttemptId.set(entry.attempt.id, entry);
782 }
783 const replyEntryIds = new Set<string>();
784 for (const entry of lastOutputByAttemptId.values()) {
785 const supersededAt = entry.attempt?.completedAt ?? null;
786 const item = entry.kind === "message" ? entry.projectedItem?.item : undefined;
787 if (
788 entry.kind === "message" &&
789 !entry.message.streaming &&
790 item?.type === "assistant_message" &&
791 item.status === "completed" &&
792 item.completedAt !== null &&
793 supersededAt !== null &&
794 DateTime.isLessThan(item.completedAt, supersededAt)
795 ) {
796 replyEntryIds.add(entry.id);
797 }
798 }
799 return replyEntryIds;
800}
801
802/**
803 * Groups only provider output owned by an explicitly superseded V2 attempt
804 * that the steer cut off. An attempt that had already finished its reply is
805 * left in the timeline like any other turn output.
806 * User messages remain visible because they are inputs to the logical run,
807 * including the steer message that started the replacement attempt.
808 */
809function deriveSupersededAttemptFolds(
810 timelineEntries: ReadonlyArray<TimelineEntry>,
811 unfoldedRunIds: ReadonlySet<RunId>,
812 liveSubagentEntryIds: ReadonlySet<string>,
813 completeReplyEntryIds: ReadonlySet<string>,
814): ReadonlyMap<string, SupersededAttemptFold> {
815 const completeAttemptIds = new Set(
816 timelineEntries.flatMap((entry) =>
817 entry.attempt !== undefined && completeReplyEntryIds.has(entry.id) ? [entry.attempt.id] : [],
818 ),
819 );
820 const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();
821 for (const entry of timelineEntries) {
822 if (
823 entry.attempt?.status !== "superseded" ||
824 completeAttemptIds.has(entry.attempt.id) ||
825 unfoldedRunIds.has(entry.attempt.runId) ||
826 (entry.kind === "message" && entry.message.role === "user") ||
827 // A published page stays visible, as it does when its turn folds.
828 entry.kind === "html-render" ||
829 entry.kind === "mcp-app" ||
830 timelineEntryIsPersistentResourceCard(entry) ||
831 // A steer supersedes the attempt but leaves its children running.
832 liveSubagentEntryIds.has(entry.id) ||
833 (entry.kind === "work" && entry.entry.itemType === "system_notice")
834 ) {
835 continue;
836 }
837 const entries = entriesByAttemptId.get(entry.attempt.id) ?? [];
838 entries.push(entry);
839 entriesByAttemptId.set(entry.attempt.id, entries);
840 }
841
842 const foldsByAnchorEntryId = new Map<string, SupersededAttemptFold>();
843 for (const entries of entriesByAttemptId.values()) {
844 const firstEntry = entries[0];
845 const attempt = firstEntry?.attempt;
846 if (firstEntry === undefined || attempt === undefined) continue;
847 foldsByAnchorEntryId.set(firstEntry.id, {
848 runId: attempt.runId,
849 attemptId: attempt.id,
850 anchorEntryId: firstEntry.id,
851 createdAt: firstEntry.createdAt,
852 hiddenEntryIds: new Set(entries.map((entry) => entry.id)),
853 });
854 }
855 return foldsByAnchorEntryId;
856}
857
858/**
859 * The latest turn counts as unsettled while it is still running (or has not
860 * recorded a completion). This is deliberately keyed on the turn's own
861 * lifecycle rather than transient working state: right after the user sends
862 * a message, the previous turn is still the "active" one until the server
863 * creates the new turn, and folding must not flicker through that window.
864 */
865function deriveUnsettledRunId(
866 latestRun: TimelineLatestRun | null,
867 runningRunId: RunId | null = null,
868): RunId | null {
869 if (runningRunId !== null) return runningRunId;
870 if (!latestRun) {
871 return null;
872 }
873 const isSettled =
874 latestRun.completedAt !== null &&
875 latestRun.status !== "running" &&
876 latestRun.status !== "starting" &&
877 latestRun.status !== "waiting";
878 return isSettled ? null : latestRun.runId;
879}
880
881/**
882 * Subagent cards that stay out of their turn's folds. A child can keep working
883 * after its launching turn settles (or its attempt is superseded), and the
884 * waiting footer counts it, so its card stays visible until the child ends.
885 * Adjacent cards from one provider turn render as a single grouped row; the
886 * whole group stays visible while any member is live, so a launch batch never
887 * shows half of its children.
888 */
889function liveSubagentCardEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
890 const visible = new Set<string>();
891 let batch: Array<Extract<TimelineEntry, { kind: "event" }>> = [];
892 const flush = () => {
893 if (batch.length === 0) return;
894 if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) {
895 for (const entry of batch) visible.add(entry.id);
896 }
897 batch = [];
898 };
899 for (const entry of entries) {
900 if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") {
901 flush();
902 continue;
903 }
904 const item = entry.projectedItem.item;
905 const previous = batch.at(-1)?.projectedItem.item;
906 if (
907 previous !== undefined &&
908 (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId)
909 ) {
910 flush();
911 }
912 batch.push(entry);
913 }
914 flush();
915 return visible;
916}
917
918/** `runlessKey` stands in for the run of entries that have none. */
919function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null {
920 if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null;
921 if (entry.kind === "message" && entry.message.role === "assistant") {
922 return entry.message.runId ?? runlessKey;
923 }
924 if (entry.kind === "work") {
925 return entry.entry.runId ?? runlessKey;
926 }
927 if (
928 entry.kind === "event" &&
929 (timelineEntryIsPersistentResourceCard(entry) || entry.projectedItem.item.type === "subagent")
930 ) {
931 return entry.projectedItem.item.runId ?? runlessKey;
932 }
933 return null;
934}
935
936/** A steer adds input to its existing turn, without creating a new header. */
937function timelineEntryStartsResponse(entry: TimelineEntry): boolean {
938 return (
939 (entry.kind === "message" &&
940 entry.message.role === "user" &&
941 entry.message.inputIntent !== "steer" &&
942 entry.message.inputIntent !== "promoted_queued_to_steer") ||
943 (entry.kind === "work" && entry.entry.itemType === "notification")
944 );
945}
946
947function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
948 if (entry.kind === "message") return entry.message.runId ?? null;
949 if (entry.kind === "work") return entry.entry.runId ?? null;
950 return null;
951}
952
953function initialCompactionEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
954 const initial = new Set<string>();
955 const startedRuns = new Set<RunId>();
956 let runlessKey: RunId | null = null;
957
958 for (const entry of entries) {
959 if (timelineEntryStartsResponse(entry)) {
960 runlessKey = RunId.make(`runless:${entry.id}`);
961 continue;
962 }
963 const runId = timelineEntryFoldRunId(entry, runlessKey);
964 if (runId === null || startedRuns.has(runId)) continue;
965 if (
966 entry.kind === "message" &&
967 entry.message.text.trim() === "" &&
968 (entry.message.attachments?.length ?? 0) === 0
969 ) {
970 continue;
971 }
972 if (entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") {
973 initial.add(entry.id);
974 } else {
975 startedRuns.add(runId);
976 }
977 }
978 return initial;
979}
980
981/**
982 * A promptless provider restart replaces the native turn without adding a
983 * user message. Keep every provider turn since the initiating prompt in one
984 * visual response until the replacement turn settles. Steers keep that
985 * boundary; an automatic wake starts a new response with its notification.
986 */
987function lastResponseBoundaryIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number {
988 return timelineEntries.findLastIndex(timelineEntryStartsResponse);
989}
990
991function deriveActiveVisualResponseRunIds(input: {
992 timelineEntries: ReadonlyArray<TimelineEntry>;
993 unsettledRunId: RunId | null;
994 isWorking: boolean;
995}): ReadonlySet<RunId> {
996 const runIds = new Set<RunId>();
997 if (input.unsettledRunId === null) {
998 return runIds;
999 }
1000
1001 runIds.add(input.unsettledRunId);
1002 if (!input.isWorking) {
1003 return runIds;
1004 }
1005
1006 const latestResponseBoundaryIndex = lastResponseBoundaryIndex(input.timelineEntries);
1007 for (
1008 let index = latestResponseBoundaryIndex + 1;
1009 index < input.timelineEntries.length;
1010 index += 1
1011 ) {
1012 const runId = timelineEntryRunId(input.timelineEntries[index]!);
1013 if (runId !== null) {
1014 runIds.add(runId);
1015 }
1016 }
1017 return runIds;
1018}
1019
1020function timelineEntryFailedItem(entry: TimelineEntry) {
1021 const item =
1022 entry.kind === "event"
1023 ? entry.projectedItem.item
1024 : entry.kind === "work"
1025 ? entry.entry.projectedItem?.item
1026 : null;
1027 return item?.type === "error" && item.status === "failed" && item.parentItemId === null
1028 ? item
1029 : null;
1030}
1031
1032function failedTimelineRunIds(
1033 entries: ReadonlyArray<TimelineEntry>,
1034 latestRun: TimelineLatestRun | null,
1035) {
1036 const failed = new Set<RunId>();
1037 if (latestRun?.status === "failed") failed.add(latestRun.runId);
1038 for (const entry of entries) {
1039 const runId = timelineEntryFailedItem(entry)?.runId;
1040 if (runId) failed.add(runId);
1041 }
1042 return failed;
1043}
1044
1045/**
1046 * Settled turns fold activity before their terminal assistant message behind
1047 * a "Worked for ..." row. Ordinary trailing work joins the fold, while failures
1048 * and work still in progress stay visible. A prompt without a run (a
1049 * provider-native subagent, or a turn imported from V1) folds its response
1050 * the same way.
1051 */
1052function deriveTurnFolds(input: {
1053 timelineEntries: ReadonlyArray<TimelineEntry>;
1054 terminalAssistantMessageIds: ReadonlySet<string>;
1055 latestRun: TimelineLatestRun | null;
1056 unfoldedRunIds: ReadonlySet<RunId>;
1057 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */
1058 runlessWorkActive: boolean;
1059 liveSubagentEntryIds: ReadonlySet<string>;
1060 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */
1061 completeSupersededReplyEntryIds: ReadonlySet<string>;
1062}): ReadonlyMap<string, TurnFold> {
1063 const initialCompactions = initialCompactionEntryIds(input.timelineEntries);
1064 const interruptedRunIds = new Set<RunId>();
1065 const promptIndexByRunId = new Map<RunId, number>();
1066 for (const [index, entry] of input.timelineEntries.entries()) {
1067 if (timelineEntryStartsResponse(entry)) {
1068 const runId = responseBoundaryRunId(entry);
1069 if (runId !== null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index);
1070 }
1071 if (
1072 entry.kind === "event" &&
1073 entry.projectedItem.item.runId !== null &&
1074 (entry.projectedItem.item.type === "run_interrupt_request" ||
1075 entry.projectedItem.item.type === "run_interrupt_result")
1076 ) {
1077 interruptedRunIds.add(entry.projectedItem.item.runId);
1078 }
1079 }
1080
1081 interface TurnGroup {
1082 entries: Array<TimelineEntry>;
1083 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
1084 hasStreamingMessage: boolean;
1085 /**
1086 * The user message or notification that kicked the turn off. Entry timestamps alone
1087 * undercount the duration (the first entry appears only once the
1088 * provider starts producing output), and a turn cut short by a steer may
1089 * hold a single instantaneous commentary message.
1090 */
1091 startBoundary: string | null;
1092 anchorEntryId: string;
1093 }
1094 const groupsByRunId = new Map<RunId, TurnGroup>();
1095 const runlessFailedKeys = new Set<RunId>();
1096
1097 // Fold state is keyed by run, so each runless prompt lends its response a
1098 // stable key of its own. Decide per prompt, not per thread: a V1 thread's
1099 // first V2 run must not unfold every imported turn above it.
1100 let runlessKey: RunId | null = null;
1101 let responseKey: RunId | null = null;
1102 let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;
1103 for (const [index, entry] of input.timelineEntries.entries()) {
1104 if (timelineEntryStartsResponse(entry)) {
1105 const nextEntry = input.timelineEntries[index + 1];
1106 pendingBoundary = nextEntry
1107 ? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
1108 : null;
1109 const boundaryRunId = responseBoundaryRunId(entry);
1110 runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;
1111 responseKey = boundaryRunId ?? runlessKey;
1112 continue;
1113 }
1114 const ownRunId = timelineEntryFoldRunId(entry, runlessKey);
1115 if (!ownRunId) {
1116 continue;
1117 }
1118 // Resuming a subagent moves its item to the resuming run, but the item
1119 // keeps its place in the turn that started it. It folds with that turn,
1120 // so the later run's fold still opens under its own prompt.
1121 const runId =
1122 responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index
1123 ? responseKey
1124 : ownRunId;
1125 if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) {
1126 runlessFailedKeys.add(runId);
1127 }
1128 let group = groupsByRunId.get(runId);
1129 if (!group) {
1130 group = {
1131 entries: [],
1132 terminalEntry: null,
1133 hasStreamingMessage: false,
1134 // Each user boundary starts at most one turn; a second turn after the
1135 // same user message (e.g. a steer-superseded continuation) falls back
1136 // to its own first entry.
1137 startBoundary: pendingBoundary?.createdAt ?? null,
1138 anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id,
1139 };
1140 pendingBoundary = null;
1141 groupsByRunId.set(runId, group);
1142 }
1143 group.entries.push(entry);
1144 if (entry.kind === "message") {
1145 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
1146 group.terminalEntry = entry;
1147 }
1148 if (entry.message.streaming) {
1149 group.hasStreamingMessage = true;
1150 }
1151 }
1152 }
1153
1154 const foldsByAnchorEntryId = new Map<string, TurnFold>();
1155 for (const [runId, group] of groupsByRunId) {
1156 if (
1157 input.unfoldedRunIds.has(runId) ||
1158 interruptedRunIds.has(runId) ||
1159 runlessFailedKeys.has(runId) ||
1160 (input.runlessWorkActive && runId === runlessKey)
1161 ) {
1162 continue;
1163 }
1164 if (group.hasStreamingMessage) {
1165 continue;
1166 }
1167 const hiddenEntryIds = new Set<string>();
1168 const terminalEntryIndex = group.terminalEntry
1169 ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id)
1170 : group.entries.length;
1171 for (const [index, entry] of group.entries.entries()) {
1172 if (entry.id === group.terminalEntry?.id) {
1173 continue;
1174 }
1175 const isCompaction =
1176 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
1177 if (
1178 isCompaction &&
1179 (entry.entry.toolLifecycleStatus === "failed" ||
1180 entry.entry.toolLifecycleStatus === "stopped")
1181 ) {
1182 continue;
1183 }
1184 const isFoldableTrailingActivity =
1185 entry.kind === "work" &&
1186 entry.entry.toolLifecycleStatus !== "inProgress" &&
1187 !workEntryDisplayIndicatesToolFailure(entry.entry);
1188 if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) {
1189 continue;
1190 }
1191 // Linked resources and still-working children can outlive their
1192 // launching run and stay visible after the surrounding work folds.
1193 if (
1194 timelineEntryIsPersistentResourceCard(entry) ||
1195 input.liveSubagentEntryIds.has(entry.id) ||
1196 input.completeSupersededReplyEntryIds.has(entry.id)
1197 ) {
1198 continue;
1199 }
1200 if (entry.kind === "work" && entry.entry.itemType === "notification") continue;
1201 hiddenEntryIds.add(entry.id);
1202 }
1203 if (hiddenEntryIds.size === 0) {
1204 continue;
1205 }
1206 const hidesNonCompactionWork = group.entries.some(
1207 (entry) =>
1208 hiddenEntryIds.has(entry.id) &&
1209 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),
1210 );
1211 const startsCompacted = group.entries.some(
1212 (entry) =>
1213 initialCompactions.has(entry.id) &&
1214 entry.kind === "work" &&
1215 entry.entry.toolLifecycleStatus === "completed",
1216 );
1217 if (!hidesNonCompactionWork && !startsCompacted) {
1218 continue;
1219 }
1220
1221 const firstEntry = group.entries[0];
1222 const lastEntry = group.entries.at(-1);
1223 if (!firstEntry || !lastEntry) {
1224 continue;
1225 }
1226
1227 const isLatestInterruptedTurn =
1228 input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
1229 // A turn cut short by a steer leaves trailing work entries behind its
1230 // terminal message — take whichever ended last.
1231 const lastProjectedItem =
1232 lastEntry.kind === "work"
1233 ? lastEntry.entry.projectedItem
1234 : lastEntry.kind === "event"
1235 ? lastEntry.projectedItem
1236 : undefined;
1237 const lastItemCompletedAt = lastProjectedItem?.item.completedAt;
1238 const lastEntryEnd =
1239 lastEntry.kind === "message"
1240 ? lastEntry.message.updatedAt
1241 : lastItemCompletedAt
1242 ? DateTime.formatIso(lastItemCompletedAt)
1243 : lastEntry.createdAt;
1244 const elapsedMs =
1245 input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
1246 ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
1247 : computeElapsedMs(
1248 group.startBoundary ?? firstEntry.createdAt,
1249 maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??
1250 lastEntryEnd,
1251 );
1252 const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;
1253 const label = isLatestInterruptedTurn
1254 ? duration
1255 ? `You stopped after ${duration}`
1256 : "You stopped this response"
1257 : duration
1258 ? `Worked for ${duration}`
1259 : "Worked";
1260
1261 foldsByAnchorEntryId.set(group.anchorEntryId, {
1262 runId,
1263 anchorEntryId: group.anchorEntryId,
1264 createdAt: group.startBoundary ?? firstEntry.createdAt,
1265 hiddenEntryIds,
1266 label: startsCompacted ? `${label} (context compacted)` : label,
1267 });
1268 }
1269 return foldsByAnchorEntryId;
1270}
1271
1272/**
1273 * When a settled turn ends with tool calls after its terminal text, treat the
1274 * text and tools as one visual response. The message metadata becomes the
1275 * footer for the whole block instead of separating the prose from the tools.
1276 */
1277function attachTrailingToolGroupsToAssistant(
1278 rows: ReadonlyArray<MessagesTimelineRow>,
1279): MessagesTimelineRow[] {
1280 const messageRowsWithoutMeta = new Set<string>();
1281 const metaRowsAfterIndex = new Map<
1282 number,
1283 Extract<MessagesTimelineRow, { kind: "assistant-meta" }>
1284 >();
1285
1286 for (const [messageIndex, row] of rows.entries()) {
1287 const runId = row.kind === "message" ? (row.message.runId ?? null) : null;
1288 if (
1289 row.kind !== "message" ||
1290 row.message.role !== "assistant" ||
1291 !row.showAssistantMeta ||
1292 runId === null
1293 ) {
1294 continue;
1295 }
1296
1297 let lastTrailingWorkIndex = -1;
1298 let hasTrailingToolGroup = false;
1299 for (let index = messageIndex + 1; index < rows.length; index += 1) {
1300 const candidate = rows[index];
1301 if (!candidate || candidate.kind === "message") {
1302 break;
1303 }
1304 if (candidate.kind === "work-toggle" && candidate.runId === runId) {
1305 hasTrailingToolGroup = true;
1306 lastTrailingWorkIndex = index;
1307 continue;
1308 }
1309 if (
1310 candidate.kind === "event" &&
1311 candidate.resourceSummary &&
1312 candidate.projectedItem.item.runId === runId
1313 ) {
1314 hasTrailingToolGroup = true;
1315 lastTrailingWorkIndex = index;
1316 continue;
1317 }
1318 if (
1319 candidate.kind === "work" &&
1320 candidate.groupedEntries.some((entry) => entry.runId === runId)
1321 ) {
1322 if (
1323 !candidate.isExpandedToolGroup &&
1324 candidate.groupedEntries.some(
1325 (entry) =>
1326 workLogEntryIsToolLike(entry) ||
1327 (entry.projectedItem?.item.type === "error" &&
1328 entry.projectedItem.item.status === "failed"),
1329 )
1330 ) {
1331 hasTrailingToolGroup = true;
1332 }
1333 if (hasTrailingToolGroup) {
1334 lastTrailingWorkIndex = index;
1335 }
1336 }
1337 }
1338
1339 if (lastTrailingWorkIndex < 0) {
1340 continue;
1341 }
1342
1343 messageRowsWithoutMeta.add(row.id);
1344 metaRowsAfterIndex.set(lastTrailingWorkIndex, {
1345 kind: "assistant-meta",
1346 id: `assistant-meta:${row.message.id}`,
1347 ...(row.projectedItem === undefined ? {} : { projectedItem: row.projectedItem }),
1348 createdAt: rows[lastTrailingWorkIndex]?.createdAt ?? row.message.updatedAt,
1349 message: row.message,
1350 showAssistantCopyButton: row.showAssistantCopyButton,
1351 assistantCopyStreaming: row.assistantCopyStreaming,
1352 });
1353 }
1354
1355 const result: MessagesTimelineRow[] = [];
1356 for (const [index, row] of rows.entries()) {
1357 if (row.kind === "message" && messageRowsWithoutMeta.has(row.id)) {
1358 result.push({ ...row, showAssistantMeta: false, showAssistantCopyButton: false });
1359 } else {
1360 result.push(row);
1361 }
1362 const metaRow = metaRowsAfterIndex.get(index);
1363 if (metaRow) {
1364 result.push(metaRow);
1365 }
1366 }
1367 return result;
1368}
1369
1370// Delegation already has a durable child card. Remove its tool row only after
1371// the returned task ID identifies that child; pending calls can share a prompt.
1372function withoutSubagentDelegationRows(entries: ReadonlyArray<TimelineEntry>) {
1373 const childrenByRun = new Map<RunId, Set<string>>();
1374 for (const entry of entries) {
1375 if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") continue;
1376 const item = entry.projectedItem.item;
1377 if (item.origin !== "app_owned" || item.runId === null) continue;
1378 const children = childrenByRun.get(item.runId) ?? new Set<string>();
1379 children.add(item.subagentId);
1380 childrenByRun.set(item.runId, children);
1381 }
1382 return entries.filter((entry) => {
1383 if (entry.kind !== "work" || workEntryDisplayIndicatesToolFailure(entry.entry)) return true;
1384 const item = entry.entry.projectedItem?.item ?? entry.entry.structuredPayload;
1385 if (
1386 item?.type !== "dynamic_tool" ||
1387 item.runId === null ||
1388 (item.status !== "running" && item.status !== "completed") ||
1389 resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "delegate"
1390 )
1391 return true;
1392 const output = compactDynamicToolOutput(item.output);
1393 if (output?.isError) return true;
1394 if (output?.taskId !== undefined) {
1395 return !childrenByRun.get(item.runId)?.has(output.taskId);
1396 }
1397 return true;
1398 });
1399}
1400
1401const supersededReasoningEntries = new WeakMap<TimelineEntry, TimelineEntry>();
1402
1403/** A steer or subsequent activity ends thinking even if the provider omits its completion. */
1404function settleSupersededReasoning(entries: ReadonlyArray<TimelineEntry>) {
1405 return entries.map((entry, index) => {
1406 if (
1407 index === entries.length - 1 ||
1408 entry.kind !== "work" ||
1409 entry.entry.itemType !== "reasoning" ||
1410 entry.entry.toolLifecycleStatus !== "inProgress"
1411 ) {
1412 return entry;
1413 }
1414 let settled = supersededReasoningEntries.get(entry);
1415 if (!settled) {
1416 settled = { ...entry, entry: { ...entry.entry, toolLifecycleStatus: "completed" } };
1417 supersededReasoningEntries.set(entry, settled);
1418 }
1419 return settled;
1420 });
1421}
1422
1423function timelineRowEntries(entries: ReadonlyArray<TimelineEntry>) {
1424 return withoutSubagentDelegationRows(settleSupersededReasoning(entries));
1425}
1426
1427/** The turn folds the timeline would draw, before applying expansion state. */
1428function deriveTimelineTurnFolds(
1429 input: Pick<
1430 MessagesTimelineRowsInput,
1431 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
1432 >,
1433) {
1434 const timelineEntries = timelineRowEntries(input.timelineEntries);
1435 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
1436 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
1437 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
1438 timelineEntries,
1439 unsettledRunId,
1440 isWorking: input.isWorking,
1441 });
1442 return deriveTurnFolds({
1443 timelineEntries,
1444 terminalAssistantMessageIds: deriveTerminalAssistantMessageIds(timelineEntries),
1445 latestRun: input.latestRun ?? null,
1446 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1447 runlessWorkActive: input.isWorking && input.runlessWorkActive === true,
1448 liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),
1449 completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries),
1450 });
1451}
1452
1453export function deriveMessagesTimelineRows(input: {
1454 timelineEntries: ReadonlyArray<TimelineEntry>;
1455 latestRun?: TimelineLatestRun | null;
1456 runningRunId?: RunId | null;
1457 expandedRunIds?: ReadonlySet<RunId>;
1458 expandedAttemptIds?: ReadonlySet<RunAttemptId>;
1459 expandedWorkGroupIds?: ReadonlySet<string>;
1460 isWorking: boolean;
1461 /**
1462 * The live work has no app run (a provider-native subagent thread), so
1463 * runless entries are the current response instead of settled history.
1464 */
1465 runlessWorkActive?: boolean;
1466 activeTurnStartedAt?: string | null;
1467 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
1468 supportsConversationRollback: boolean;
1469 /** Task ids of subagents still working, used by the active tool indicator. */
1470 liveAgentTaskIds?: ReadonlySet<string> | undefined;
1471 /** Live bootstrap progress. Renders a stage card under the first user message. */
1472 worktreeSetup?: WorktreeSetupSnapshot | null;
1473}): MessagesTimelineRow[] {
1474 const timelineEntries = timelineRowEntries(input.timelineEntries);
1475 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
1476 for (const summary of input.turnDiffSummaries) {
1477 if (summary.assistantMessageId) {
1478 turnDiffSummaryByAssistantMessageId.set(summary.assistantMessageId, summary);
1479 }
1480 }
1481 const revertTurnCountByUserMessageId = input.supportsConversationRollback
1482 ? deriveRevertTurnCountByUserMessageId({
1483 timelineEntries: timelineEntries,
1484 checkpoints: input.turnDiffSummaries,
1485 })
1486 : new Map<MessageId, number>();
1487 const nextRows: MessagesTimelineRow[] = [];
1488 const durationStartByMessageId = computeMessageDurationStart(
1489 timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
1490 );
1491 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries);
1492 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
1493 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
1494 const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
1495 const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries);
1496 const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
1497 timelineEntries,
1498 failedRunIds,
1499 liveSubagentEntryIds,
1500 completeSupersededReplyIds,
1501 );
1502 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
1503 timelineEntries: timelineEntries,
1504 unsettledRunId,
1505 isWorking: input.isWorking,
1506 });
1507 const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
1508 const foldsByAnchorEntryId = deriveTurnFolds({
1509 timelineEntries: timelineEntries,
1510 terminalAssistantMessageIds,
1511 latestRun: input.latestRun ?? null,
1512 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1513 runlessWorkActive,
1514 liveSubagentEntryIds,
1515 completeSupersededReplyEntryIds: completeSupersededReplyIds,
1516 });
1517 const collapsedEntryIds = new Set<string>();
1518 for (const fold of foldsByAnchorEntryId.values()) {
1519 if (!input.expandedRunIds?.has(fold.runId)) {
1520 for (const entryId of fold.hiddenEntryIds) {
1521 collapsedEntryIds.add(entryId);
1522 }
1523 }
1524 }
1525 const collapsedSupersededEntryIds = new Set<string>();
1526 for (const fold of supersededFoldsByAnchorEntryId.values()) {
1527 if (!input.expandedAttemptIds?.has(fold.attemptId)) {
1528 for (const entryId of fold.hiddenEntryIds) {
1529 collapsedSupersededEntryIds.add(entryId);
1530 }
1531 }
1532 }
1533 const runIdIsActiveResponse = (runId: RunId | null | undefined) =>
1534 runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId);
1535 const workEntryIsInActiveRun = (entry: WorkLogEntry) =>
1536 input.isWorking &&
1537 entry.toolLifecycleStatus === "inProgress" &&
1538 (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId);
1539 const initialCompactions = initialCompactionEntryIds(timelineEntries);
1540 const activeInitialCompaction = timelineEntries.findLast(
1541 (entry) =>
1542 initialCompactions.has(entry.id) &&
1543 entry.kind === "work" &&
1544 runIdIsActiveResponse(entry.entry.runId) &&
1545 (entry.entry.toolLifecycleStatus === "completed" || workEntryIsInActiveRun(entry.entry)),
1546 );
1547
1548 // A steer continues the current turn. Keep its elapsed-time header below
1549 // the initiating prompt (or automatic wake), rather than moving it down.
1550 const activeTurnHeaderIndex = input.isWorking
1551 ? lastResponseBoundaryIndex(timelineEntries) + 1
1552 : timelineEntries.length;
1553
1554 // Contiguous trailing work entries of the active run collapse into one live
1555 // row that survives between actions: while a tool runs it shows that tool,
1556 // and once everything settles it keeps the latest tool in past tense
1557 // instead of vanishing (#8984).
1558 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
1559 if (input.isWorking && (unsettledRunId !== null || runlessWorkActive)) {
1560 let tailAttemptId: string | null | undefined;
1561 for (let index = timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
1562 const entry = timelineEntries[index]!;
1563 if (
1564 entry.kind !== "work" ||
1565 entry.entry.tone === "error" ||
1566 entry.entry.sourceActivityKind === "runtime.error" ||
1567 entry.entry.itemType === "system_notice" ||
1568 entry.entry.itemType === "notification" ||
1569 !runIdIsActiveResponse(entry.entry.runId) ||
1570 entry.entry.sourceActivityKind === "context-compaction" ||
1571 collapsedEntryIds.has(entry.id) ||
1572 collapsedSupersededEntryIds.has(entry.id) ||
1573 foldsByAnchorEntryId.has(entry.id) ||
1574 supersededFoldsByAnchorEntryId.has(entry.id)
1575 ) {
1576 break;
1577 }
1578 if (tailAttemptId === undefined) {
1579 tailAttemptId = entry.attempt?.id ?? null;
1580 } else if ((entry.attempt?.id ?? null) !== tailAttemptId) {
1581 break;
1582 }
1583 activeToolEntries.push(entry);
1584 }
1585 }
1586 activeToolEntries.reverse();
1587 const visibleActiveToolEntries = activeToolEntries.filter((entry) =>
1588 workEntryIsVisibleInGroup(entry.entry, true),
1589 );
1590 const activeWorkAnchor = activeToolEntries[0];
1591 const latestVisibleToolEntry = visibleActiveToolEntries.at(-1);
1592 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
1593 workEntryIsActiveTurnActivity(entry.entry),
1594 );
1595 const latestToolKeepsActivityLive =
1596 latestRunningToolEntry !== undefined ||
1597 (latestVisibleToolEntry !== undefined &&
1598 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
1599 const latestToolFailed =
1600 latestRunningToolEntry === undefined &&
1601 latestVisibleToolEntry !== undefined &&
1602 latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&
1603 workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);
1604
1605 const latestThoughtEntry = visibleActiveToolEntries.findLast(
1606 (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "",
1607 );
1608 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
1609 const activeWorkRow =
1610 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
1611 ? (() => {
1612 const groupId = workGroupId(activeWorkAnchor.id);
1613 return {
1614 kind: "work-live" as const,
1615 id: latestToolKeepsActivityLive
1616 ? LIVE_ACTIVITY_ROW_ID
1617 : `work-live:${activeWorkAnchor.id}`,
1618 createdAt: activeWorkAnchor.createdAt,
1619 entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
1620 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
1621 groupId,
1622 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
1623 active: latestToolKeepsActivityLive,
1624 ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}),
1625 };
1626 })()
1627 : null;
1628 const activeWorkEntryIds = new Set(
1629 activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],
1630 );
1631 const appendWorkingRow = () => {
1632 nextRows.push({
1633 kind: "working",
1634 id: "working-indicator-row",
1635 createdAt: input.activeTurnStartedAt ?? null,
1636 ...(activeInitialCompaction?.kind === "work"
1637 ? { compaction: activeInitialCompaction.entry }
1638 : {}),
1639 });
1640 };
1641 let hasActivityRow = false;
1642 let hasActiveCompaction = false;
1643 const appendActiveWorkRows = () => {
1644 if (activeWorkRow === null) return;
1645 nextRows.push(activeWorkRow);
1646 hasActivityRow ||= activeWorkRow.active;
1647 if (!activeWorkRow.expanded) return;
1648 nextRows.push(
1649 expandedWorkGroupRow(
1650 activeWorkRow.groupId,
1651 activeWorkRow.createdAt,
1652 activeWorkRow.groupedEntries,
1653 ),
1654 );
1655 };
1656
1657 for (let index = 0; index < timelineEntries.length; index += 1) {
1658 const timelineEntry = timelineEntries[index];
1659 if (!timelineEntry) {
1660 continue;
1661 }
1662
1663 if (input.isWorking && index === activeTurnHeaderIndex) {
1664 appendWorkingRow();
1665 }
1666
1667 if (timelineEntry.id === activeWorkPlacementEntryId) {
1668 appendActiveWorkRows();
1669 }
1670
1671 // The terminal interrupt result is the useful timeline marker. The
1672 // preceding request is transient bookkeeping and duplicates that marker.
1673 if (
1674 timelineEntry.kind === "event" &&
1675 timelineEntry.projectedItem.item.type === "run_interrupt_request"
1676 ) {
1677 continue;
1678 }
1679
1680 const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
1681 if (turnFold) {
1682 nextRows.push({
1683 kind: "turn-fold",
1684 id: `turn-fold:${turnFold.runId}`,
1685 createdAt: turnFold.createdAt,
1686 runId: turnFold.runId,
1687 label: turnFold.label,
1688 expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
1689 ...([...turnFold.hiddenEntryIds].every((id) => initialCompactions.has(id))
1690 ? { summaryOnly: true as const }
1691 : {}),
1692 });
1693 }
1694
1695 if (collapsedEntryIds.has(timelineEntry.id)) {
1696 continue;
1697 }
1698
1699 const supersededFold = supersededFoldsByAnchorEntryId.get(timelineEntry.id);
1700 if (supersededFold) {
1701 nextRows.push({
1702 kind: "attempt-fold",
1703 id: `attempt-fold:${supersededFold.attemptId}`,
1704 createdAt: supersededFold.createdAt,
1705 runId: supersededFold.runId,
1706 attemptId: supersededFold.attemptId,
1707 label: "Superseded attempt",
1708 expanded: input.expandedAttemptIds?.has(supersededFold.attemptId) ?? false,
1709 });
1710 }
1711
1712 if (collapsedSupersededEntryIds.has(timelineEntry.id)) {
1713 continue;
1714 }
1715
1716 if (activeWorkEntryIds.has(timelineEntry.id)) {
1717 continue;
1718 }
1719
1720 if (
1721 timelineEntry.kind === "work" &&
1722 timelineEntry.entry.sourceActivityKind === "context-compaction"
1723 ) {
1724 const active = workEntryIsInActiveRun(timelineEntry.entry);
1725 hasActiveCompaction ||= active;
1726 if (
1727 initialCompactions.has(timelineEntry.id) &&
1728 ((input.isWorking && runIdIsActiveResponse(timelineEntry.entry.runId)) ||
1729 [...foldsByAnchorEntryId.values()].some((fold) =>
1730 fold.hiddenEntryIds.has(timelineEntry.id),
1731 )) &&
1732 (active || timelineEntry.entry.toolLifecycleStatus === "completed")
1733 ) {
1734 continue;
1735 }
1736 nextRows.push({
1737 kind: "context-compaction",
1738 id: timelineEntry.id,
1739 createdAt: timelineEntry.createdAt,
1740 label: timelineEntry.entry.label,
1741 active,
1742 });
1743 continue;
1744 }
1745
1746 if (timelineEntry.kind === "work") {
1747 if (
1748 (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) ||
1749 (timelineEntry.entry.itemType === "error" &&
1750 timelineEntry.entry.toolLifecycleStatus === "failed") ||
1751 timelineEntry.entry.tone === "error" ||
1752 timelineEntry.entry.sourceActivityKind === "runtime.error" ||
1753 timelineEntry.entry.itemType === "system_notice" ||
1754 timelineEntry.entry.itemType === "notification"
1755 ) {
1756 nextRows.push({
1757 kind: "work",
1758 id: timelineEntry.id,
1759 createdAt: timelineEntry.createdAt,
1760 groupedEntries: [timelineEntry.entry],
1761 isExpandedToolGroup: false,
1762 });
1763 continue;
1764 }
1765 const groupedEntries = [timelineEntry.entry];
1766 let cursor = index + 1;
1767 while (cursor < timelineEntries.length) {
1768 const nextEntry = timelineEntries[cursor];
1769 if (
1770 !nextEntry ||
1771 nextEntry.kind !== "work" ||
1772 nextEntry.entry.sourceActivityKind === "context-compaction" ||
1773 nextEntry.entry.tone === "error" ||
1774 nextEntry.entry.sourceActivityKind === "runtime.error" ||
1775 nextEntry.entry.itemType === "system_notice" ||
1776 nextEntry.entry.itemType === "notification" ||
1777 activeWorkEntryIds.has(nextEntry.id) ||
1778 collapsedEntryIds.has(nextEntry.id) ||
1779 collapsedSupersededEntryIds.has(nextEntry.id) ||
1780 foldsByAnchorEntryId.has(nextEntry.id) ||
1781 supersededFoldsByAnchorEntryId.has(nextEntry.id) ||
1782 (nextEntry.entry.runId ?? null) !== (timelineEntry.entry.runId ?? null) ||
1783 nextEntry.attempt?.id !== timelineEntry.attempt?.id
1784 ) {
1785 break;
1786 }
1787 groupedEntries.push(nextEntry.entry);
1788 cursor += 1;
1789 }
1790 const visibleGroupedEntries = groupedEntries.filter((entry) =>
1791 workEntryIsVisibleInGroup(entry, workEntryIsInActiveRun(entry)),
1792 );
1793 if (visibleGroupedEntries.length > 0) {
1794 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
1795 if (activeInProgressToolEntries.length > 0) {
1796 const groupId = workGroupId(timelineEntry.id);
1797 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
1798 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
1799 nextRows.push({
1800 kind: "work-live",
1801 id: `work-live:${timelineEntry.id}`,
1802 createdAt: timelineEntry.createdAt,
1803 entry: latestActiveToolEntry,
1804 groupedEntries: visibleGroupedEntries,
1805 groupId,
1806 expanded,
1807 active: true,
1808 });
1809 hasActivityRow = true;
1810 if (expanded) {
1811 nextRows.push(
1812 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
1813 );
1814 }
1815 } else if (
1816 visibleGroupedEntries.length === 1 &&
1817 workLogEntryIsToolLike(visibleGroupedEntries[0]!)
1818 ) {
1819 const singleEntry = visibleGroupedEntries[0]!;
1820 nextRows.push({
1821 kind: "work",
1822 id: timelineEntry.id,
1823 createdAt: timelineEntry.createdAt,
1824 groupedEntries: visibleGroupedEntries,
1825 isExpandedToolGroup: false,
1826 displayLabel:
1827 toolGroupAction(singleEntry) === "edit"
1828 ? summarizeToolGroup(visibleGroupedEntries).summary
1829 : singleToolCallLabel(singleEntry),
1830 });
1831 } else {
1832 const groupId = workGroupId(timelineEntry.id);
1833 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
1834 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
1835 const primarySourceEntry = visibleGroupedEntries.find(
1836 (entry) => entry.toolSource !== undefined,
1837 );
1838 const primarySourceKey = primarySourceEntry?.toolSource?.key;
1839 const primarySourceIcon = primarySourceKey
1840 ? (visibleGroupedEntries.find(
1841 (entry) =>
1842 entry.toolSource?.key === primarySourceKey && entry.toolIcon !== undefined,
1843 )?.toolIcon ?? primarySourceEntry?.toolSource?.icon)
1844 : undefined;
1845 const groupToolSurface =
1846 primarySourceEntry?.toolSurface ??
1847 visibleGroupedEntries.findLast((entry) => entry.toolSurface !== undefined)?.toolSurface;
1848 const groupToolIcon =
1849 primarySourceIcon ??
1850 visibleGroupedEntries.findLast((entry) => entry.toolIcon !== undefined)?.toolIcon;
1851 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
1852 const singleEntry =
1853 visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null;
1854 const usesSingleToolCallLabel =
1855 singleEntry !== null &&
1856 workLogEntryIsToolLike(singleEntry) &&
1857 toolGroupAction(singleEntry) !== "edit";
1858 const summaryToolIcon = usesSingleToolCallLabel
1859 ? resolveWorkEntryToolPresentation(singleEntry, "completed")?.icon
1860 : undefined;
1861 nextRows.push({
1862 kind: "work-toggle",
1863 id: `work-toggle:${timelineEntry.id}`,
1864 createdAt: timelineEntry.createdAt,
1865 runId: timelineEntry.entry.runId ?? null,
1866 groupId,
1867 hiddenCount: visibleGroupedEntries.length,
1868 expanded,
1869 summary: usesSingleToolCallLabel
1870 ? singleToolCallLabel(singleEntry)
1871 : singleEntry !== null && !workLogEntryIsToolLike(singleEntry)
1872 ? singleEntry.label
1873 : summarizeToolGroup(visibleGroupedEntries).summary,
1874 summaryKind,
1875 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
1876 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
1877 ...(summaryToolIcon ? { summaryToolIcon } : {}),
1878 hasFailure:
1879 latestToolEntry !== undefined &&
1880 workEntryDisplayIndicatesToolFailure(latestToolEntry),
1881 });
1882 if (expanded) {
1883 nextRows.push(
1884 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
1885 );
1886 }
1887 }
1888 }
1889 index = cursor - 1;
1890 continue;
1891 }
1892
1893 if (timelineEntry.kind === "proposed-plan") {
1894 nextRows.push({
1895 kind: "proposed-plan",
1896 id: timelineEntry.id,
1897 createdAt: timelineEntry.createdAt,
1898 proposedPlan: timelineEntry.proposedPlan,
1899 });
1900 continue;
1901 }
1902
1903 if (timelineEntry.kind === "html-render") {
1904 nextRows.push({
1905 kind: "html-render",
1906 id: timelineEntry.id,
1907 createdAt: timelineEntry.createdAt,
1908 htmlRender: timelineEntry.htmlRender,
1909 });
1910 continue;
1911 }
1912
1913 if (timelineEntry.kind === "mcp-app") {
1914 nextRows.push({
1915 kind: "mcp-app",
1916 id: timelineEntry.id,
1917 createdAt: timelineEntry.createdAt,
1918 sourceThreadId: timelineEntry.sourceThreadId,
1919 itemId: timelineEntry.itemId,
1920 revision: timelineEntry.revision,
1921 mcpApp: timelineEntry.mcpApp,
1922 });
1923 continue;
1924 }
1925
1926 if (timelineEntry.kind === "event") {
1927 const previous = nextRows.at(-1);
1928 if (
1929 timelineEntry.projectedItem.item.type === "subagent" &&
1930 previous?.kind === "event" &&
1931 previous.projectedItem.item.type === "subagent" &&
1932 previous.projectedItem.item.runId === timelineEntry.projectedItem.item.runId &&
1933 previous.projectedItem.item.providerTurnId ===
1934 timelineEntry.projectedItem.item.providerTurnId
1935 ) {
1936 nextRows[nextRows.length - 1] = {
1937 ...previous,
1938 subagents: [
1939 ...(previous.subagents ?? [previous.projectedItem]),
1940 timelineEntry.projectedItem,
1941 ],
1942 };
1943 continue;
1944 }
1945 nextRows.push({
1946 kind: "event",
1947 id: timelineEntry.id,
1948 createdAt: timelineEntry.createdAt,
1949 projectedItem: timelineEntry.projectedItem,
1950 });
1951 continue;
1952 }
1953
1954 const assistantResponseStillInProgress =
1955 timelineEntry.message.role === "assistant" &&
1956 runIdIsActiveResponse(timelineEntry.message.runId);
1957
1958 const durationStart =
1959 durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;
1960
1961 // While the turn is still running, the latest assistant message is only
1962 // provisionally terminal — withhold the metadata row until the turn
1963 // settles so commentary doesn't flash timestamps mid-work.
1964 const showAssistantMeta =
1965 timelineEntry.message.role === "assistant" &&
1966 terminalAssistantMessageIds.has(timelineEntry.message.id) &&
1967 !assistantResponseStillInProgress;
1968
1969 nextRows.push({
1970 kind: "message",
1971 id: timelineEntry.id,
1972 createdAt: timelineEntry.createdAt,
1973 message: timelineEntry.message,
1974 ...(timelineEntry.projectedItem === undefined
1975 ? {}
1976 : { projectedItem: timelineEntry.projectedItem }),
1977 durationStart,
1978 showAssistantMeta,
1979 showAssistantCopyButton: showAssistantMeta,
1980 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
1981 assistantTurnDiffSummary:
1982 timelineEntry.message.role === "assistant"
1983 ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
1984 : undefined,
1985 revertTurnCount:
1986 timelineEntry.message.role === "user"
1987 ? revertTurnCountByUserMessageId.get(timelineEntry.message.id)
1988 : undefined,
1989 });
1990 }
1991
1992 // Until the agent's turn is live, the setup card sits under the send with
1993 // the working header above it (the header reads "Setting up worktree…" and
1994 // later swaps its text in place, so nothing moves at the handoff). "Live"
1995 // means the turn is in the timeline, not just that the server dispatched
1996 // it: the card must not vanish in the gap between. Once the turn is live
1997 // the stage list leaves the timeline; a script that is still running is
1998 // surfaced by the working header itself. A failed or cancelled setup stays
1999 // under the send so its outcome and actions remain reachable.
2000 const setupHandedOff =
2001 input.worktreeSetup !== null &&
2002 input.worktreeSetup !== undefined &&
2003 worktreeSetupAgentStarted(input.worktreeSetup) &&
2004 input.latestRun?.startedAt != null;
2005 // A finished setup keeps the slot until the turn is live, so the card does
2006 // not jump above the working header in the gap before the run starts.
2007 const setupOwnsWorkingSlot =
2008 !setupHandedOff &&
2009 (input.worktreeSetup?.phase === "running" || input.worktreeSetup?.phase === "done");
2010 if (input.worktreeSetup && (!setupHandedOff || input.worktreeSetup.phase !== "running")) {
2011 const setupRow = {
2012 kind: "worktree-setup",
2013 id: WORKTREE_SETUP_ROW_ID,
2014 createdAt: input.worktreeSetup.startedAt,
2015 snapshot: input.worktreeSetup,
2016 embedded: setupHandedOff,
2017 } as const;
2018 const firstUserRowIndex = nextRows.findIndex(
2019 (row) => row.kind === "message" && row.message.role === "user",
2020 );
2021 // While the setup runs, the working header leads the card in the same
2022 // slot it keeps once the agent's own turn takes over. The main pass may
2023 // already have placed that header (a bootstrap counts as working).
2024 const workingRowIndex = setupOwnsWorkingSlot
2025 ? nextRows.findIndex((row) => row.kind === "working")
2026 : -1;
2027 if (workingRowIndex >= 0) {
2028 nextRows.splice(workingRowIndex + 1, 0, setupRow);
2029 } else {
2030 const insertAt = firstUserRowIndex >= 0 ? firstUserRowIndex + 1 : nextRows.length;
2031 nextRows.splice(
2032 insertAt,
2033 0,
2034 ...(setupOwnsWorkingSlot
2035 ? [
2036 {
2037 kind: "working",
2038 id: "working-indicator-row",
2039 createdAt: input.worktreeSetup.startedAt,
2040 } as const,
2041 setupRow,
2042 ]
2043 : [setupRow]),
2044 );
2045 }
2046 }
2047
2048 // A setup that owns the working slot sits under it and shows no activity
2049 // row of its own; every other state gets the usual tail.
2050 const hasWorkingRow = nextRows.some((row) => row.kind === "working");
2051 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) {
2052 appendWorkingRow();
2053 }
2054 if (
2055 input.isWorking &&
2056 !setupOwnsWorkingSlot &&
2057 !hasActiveCompaction &&
2058 (!hasActivityRow || latestToolFailed)
2059 ) {
2060 // A failed latest tool hands the row back to thinking, but its group
2061 // stays reachable through the same disclosure the live row offers.
2062 const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined;
2063 if (failedGroupAnchor) {
2064 const groupId = workGroupId(failedGroupAnchor.id);
2065 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
2066 nextRows.push({
2067 kind: "thinking",
2068 id: LIVE_ACTIVITY_ROW_ID,
2069 createdAt: input.activeTurnStartedAt ?? null,
2070 groupId,
2071 expanded,
2072 });
2073 if (expanded) {
2074 nextRows.push(
2075 expandedWorkGroupRow(
2076 groupId,
2077 failedGroupAnchor.createdAt,
2078 visibleActiveToolEntries.map((entry) => entry.entry),
2079 ),
2080 );
2081 }
2082 } else {
2083 nextRows.push({
2084 kind: "thinking",
2085 id: LIVE_ACTIVITY_ROW_ID,
2086 createdAt: input.activeTurnStartedAt ?? null,
2087 });
2088 }
2089 }
2090
2091 const result = attachTrailingToolGroupsToAssistant(
2092 attachCreatedThreadSummaries(nextRows, timelineEntries),
2093 );
2094 return result.map((row, index) =>
2095 timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
2096 ? { ...row, continuesWorkLog: true }
2097 : row,
2098 );
2099}
2100
2101/** Adjacent work stays one visual list even when virtualization splits its groups. */
2102function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean {
2103 return (
2104 row !== undefined &&
2105 (row.kind === "work" ||
2106 row.kind === "work-toggle" ||
2107 row.kind === "work-live" ||
2108 row.kind === "thinking")
2109 );
2110}
2111
2112// Keep created chats below the final answer even when the work that created them folds away.
2113function attachCreatedThreadSummaries(
2114 rows: MessagesTimelineRow[],
2115 timelineEntries: ReadonlyArray<TimelineEntry>,
2116): MessagesTimelineRow[] {
2117 const terminalIndexes = new Map<RunId, number>();
2118 const collapsedRuns = new Set<RunId>();
2119 const createdByRun = new Map<RunId, Array<Extract<MessagesTimelineRow, { kind: "event" }>>>();
2120 for (const [index, row] of rows.entries()) {
2121 if (row.kind === "message" && row.showAssistantMeta && row.message.runId) {
2122 terminalIndexes.set(row.message.runId, index);
2123 }
2124 if (row.kind === "turn-fold" && !row.expanded) collapsedRuns.add(row.runId);
2125 }
2126 for (const entry of timelineEntries) {
2127 const projectedItem =
2128 entry.kind === "work"
2129 ? entry.entry.projectedItem
2130 : entry.kind === "event"
2131 ? entry.projectedItem
2132 : undefined;
2133 if (projectedItem?.item.type === "thread_created" && projectedItem.item.runId) {
2134 const runId = projectedItem.item.runId;
2135 const entries = createdByRun.get(runId) ?? [];
2136 entries.push({ kind: "event", id: entry.id, createdAt: entry.createdAt, projectedItem });
2137 createdByRun.set(runId, entries);
2138 }
2139 }
2140 return rows.flatMap((row, index): MessagesTimelineRow[] => {
2141 if (row.kind === "event" && row.projectedItem.item.type === "thread_created") {
2142 const runId = row.projectedItem.item.runId;
2143 const terminalIndex = runId === null ? undefined : terminalIndexes.get(runId);
2144 if (terminalIndex !== undefined && (collapsedRuns.has(runId!) || index > terminalIndex))
2145 return [];
2146 }
2147 if (row.kind === "message" && row.showAssistantMeta && row.message.runId) {
2148 return [
2149 row,
2150 ...(createdByRun.get(row.message.runId) ?? []).map((entry) => ({
2151 ...entry,
2152 id: `summary:${entry.id}`,
2153 resourceSummary: true,
2154 })),
2155 ];
2156 }
2157 return [row];
2158 });
2159}
2160
2161const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
2162
2163type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
2164
2165export interface MessagesTimelineRowsProjection {
2166 readonly input: MessagesTimelineRowsInput;
2167 readonly rows: MessagesTimelineRow[];
2168}
2169
2170/**
2171 * The turn fold that holds an entry, keyed as `expandedRunIds` expects. Runless
2172 * (imported V1) turns fold under a synthetic key, so the entry's own run id is
2173 * not enough to open them.
2174 */
2175export function timelineEntryTurnFoldRunId(
2176 input: Pick<
2177 MessagesTimelineRowsInput,
2178 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
2179 >,
2180 entryId: string,
2181): RunId | null {
2182 return timelineTurnFoldRunIdsByEntryId(input).get(entryId) ?? null;
2183}
2184
2185/** Every folded entry's fold key, computed once for callers that check many entries. */
2186export function timelineTurnFoldRunIdsByEntryId(
2187 input: Pick<
2188 MessagesTimelineRowsInput,
2189 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
2190 >,
2191): ReadonlyMap<string, RunId> {
2192 const byEntryId = new Map<string, RunId>();
2193 for (const fold of deriveTimelineTurnFolds(input).values()) {
2194 for (const entryId of fold.hiddenEntryIds) byEntryId.set(entryId, fold.runId);
2195 }
2196 return byEntryId;
2197}
2198
2199function sameCheckpointSummaries(
2200 previous: MessagesTimelineRowsInput["turnDiffSummaries"],
2201 next: MessagesTimelineRowsInput["turnDiffSummaries"],
2202): boolean {
2203 if (previous === next) return true;
2204 if (previous.length !== next.length) return false;
2205 for (const [index, summary] of previous.entries()) {
2206 if (!shallow(summary, next[index])) return false;
2207 }
2208 return true;
2209}
2210
2211function replaceStreamingMessageRows(
2212 input: MessagesTimelineRowsInput,
2213 previous: MessagesTimelineRowsProjection,
2214): MessagesTimelineRow[] | null {
2215 const {
2216 timelineEntries: previousEntries,
2217 latestRun: previousRun,
2218 turnDiffSummaries: previousCheckpoints,
2219 expandedRunIds: previousExpandedRuns,
2220 expandedAttemptIds: previousExpandedAttempts,
2221 expandedWorkGroupIds: previousExpandedGroups,
2222 ...previousContext
2223 } = previous.input;
2224 const {
2225 timelineEntries,
2226 latestRun,
2227 turnDiffSummaries,
2228 expandedRunIds,
2229 expandedAttemptIds,
2230 expandedWorkGroupIds,
2231 ...context
2232 } = input;
2233 // V2 shell and checkpoint selectors produce fresh summaries for each event.
2234 // Equivalent summaries must not invalidate a text-only projection.
2235 if (
2236 timelineEntries.length !== previousEntries.length ||
2237 !shallow(previousContext, context) ||
2238 !shallow(previousRun, latestRun) ||
2239 !shallow(previousExpandedRuns, expandedRunIds) ||
2240 !shallow(previousExpandedAttempts, expandedAttemptIds) ||
2241 !shallow(previousExpandedGroups, expandedWorkGroupIds) ||
2242 !sameCheckpointSummaries(previousCheckpoints, turnDiffSummaries)
2243 ) {
2244 return null;
2245 }
2246 const replacements = new Map<ChatMessage, Extract<TimelineEntry, { kind: "message" }>>();
2247 for (const [index, entry] of timelineEntries.entries()) {
2248 const previousEntry = previousEntries[index]!;
2249 if (entry === previousEntry) continue;
2250 if (
2251 entry.kind !== "message" ||
2252 previousEntry.kind !== "message" ||
2253 entry.id !== previousEntry.id ||
2254 entry.createdAt !== previousEntry.createdAt ||
2255 entry.attempt !== previousEntry.attempt
2256 ) {
2257 return null;
2258 }
2259 if (
2260 entry.projectedItem !== previousEntry.projectedItem &&
2261 (entry.projectedItem === undefined ||
2262 previousEntry.projectedItem === undefined ||
2263 !isStreamingTurnItemTextUpdate(previousEntry.projectedItem, entry.projectedItem))
2264 ) {
2265 return null;
2266 }
2267 if (
2268 entry.message === previousEntry.message &&
2269 entry.projectedItem === previousEntry.projectedItem
2270 )
2271 continue;
2272 if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null;
2273 replacements.set(previousEntry.message, entry);
2274 }
2275 if (replacements.size === 0) return previous.rows;
2276 return previous.rows.map((row) => {
2277 if (row.kind !== "message" && row.kind !== "assistant-meta") return row;
2278 const entry = replacements.get(row.message);
2279 return entry
2280 ? {
2281 ...row,
2282 message: entry.message,
2283 ...(entry.projectedItem === undefined ? {} : { projectedItem: entry.projectedItem }),
2284 }
2285 : row;
2286 });
2287}
2288
2289/** Keep one projection per timeline. Reuse rows only when streaming content changes. */
2290export function deriveMessagesTimelineRowsWithState(
2291 input: MessagesTimelineRowsInput,
2292 previous: MessagesTimelineRowsProjection | null = null,
2293): MessagesTimelineRowsProjection {
2294 return {
2295 input,
2296 rows:
2297 (previous === null ? null : replaceStreamingMessageRows(input, previous)) ??
2298 deriveMessagesTimelineRows(input),
2299 };
2300}
2301
2302export function computeStableMessagesTimelineRows(
2303 rows: MessagesTimelineRow[],
2304 previous: StableMessagesTimelineRowsState,
2305): StableMessagesTimelineRowsState {
2306 const next = new Map<string, MessagesTimelineRow>();
2307 let anyChanged = rows.length !== previous.byId.size;
2308
2309 const result = rows.map((row, index) => {
2310 const prevRow = previous.byId.get(row.id);
2311 const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row;
2312 next.set(row.id, nextRow);
2313 if (!anyChanged && previous.result[index] !== nextRow) {
2314 anyChanged = true;
2315 }
2316 return nextRow;
2317 });
2318
2319 return anyChanged ? { byId: next, result } : previous;
2320}
2321
2322/** Shallow field comparison per row variant — avoids deep equality cost. */
2323function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {
2324 if (a.kind !== b.kind || a.id !== b.id) return false;
2325
2326 switch (a.kind) {
2327 case "working":
2328 return (
2329 a.createdAt === (b as typeof a).createdAt && a.compaction === (b as typeof a).compaction
2330 );
2331 case "thinking": {
2332 const bt = b as typeof a;
2333 return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;
2334 }
2335 case "worktree-setup":
2336 return a.snapshot === (b as typeof a).snapshot;
2337
2338 case "assistant-meta": {
2339 const bm = b as typeof a;
2340 return (
2341 a.createdAt === bm.createdAt &&
2342 a.projectedItem === bm.projectedItem &&
2343 a.message === bm.message &&
2344 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
2345 a.assistantCopyStreaming === bm.assistantCopyStreaming
2346 );
2347 }
2348
2349 case "turn-fold": {
2350 const bf = b as typeof a;
2351 return (
2352 a.createdAt === bf.createdAt &&
2353 a.label === bf.label &&
2354 a.expanded === bf.expanded &&
2355 a.summaryOnly === bf.summaryOnly
2356 );
2357 }
2358
2359 case "attempt-fold": {
2360 const bf = b as typeof a;
2361 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
2362 }
2363
2364 case "context-compaction": {
2365 const bc = b as typeof a;
2366 return a.createdAt === bc.createdAt && a.label === bc.label && a.active === bc.active;
2367 }
2368
2369 case "proposed-plan":
2370 return a.proposedPlan === (b as typeof a).proposedPlan;
2371
2372 case "html-render": {
2373 // Entries rebuild on any tool update; an equal page must keep its mounted frame.
2374 const bh = b as typeof a;
2375 return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender);
2376 }
2377
2378 case "mcp-app": {
2379 // Same reason: an equal app keeps its live frame and its state.
2380 const bm = b as typeof a;
2381 return (
2382 a.createdAt === bm.createdAt &&
2383 a.revision === bm.revision &&
2384 mcpAppReferencesEqual(a.mcpApp, bm.mcpApp)
2385 );
2386 }
2387
2388 case "event":
2389 return (
2390 a.projectedItem === (b as typeof a).projectedItem &&
2391 a.resourceSummary === (b as typeof a).resourceSummary &&
2392 Equal.equals(a.subagents, (b as typeof a).subagents)
2393 );
2394
2395 case "work": {
2396 const bw = b as typeof a;
2397 return (
2398 a.isExpandedToolGroup === bw.isExpandedToolGroup &&
2399 a.displayLabel === bw.displayLabel &&
2400 Equal.equals(a.groupedEntries, bw.groupedEntries)
2401 );
2402 }
2403
2404 case "work-live": {
2405 const bw = b as typeof a;
2406 return (
2407 a.createdAt === bw.createdAt &&
2408 a.groupId === bw.groupId &&
2409 a.expanded === bw.expanded &&
2410 a.active === bw.active &&
2411 Equal.equals(a.entry, bw.entry) &&
2412 Equal.equals(a.groupedEntries, bw.groupedEntries)
2413 );
2414 }
2415
2416 case "work-toggle": {
2417 const bw = b as typeof a;
2418 return (
2419 a.createdAt === bw.createdAt &&
2420 a.runId === bw.runId &&
2421 a.groupId === bw.groupId &&
2422 a.hiddenCount === bw.hiddenCount &&
2423 a.expanded === bw.expanded &&
2424 a.summary === bw.summary &&
2425 a.summaryKind === bw.summaryKind &&
2426 a.toolSurface === bw.toolSurface &&
2427 Equal.equals(a.toolIcon, bw.toolIcon) &&
2428 a.hasFailure === bw.hasFailure
2429 );
2430 }
2431
2432 case "message": {
2433 const bm = b as typeof a;
2434 return (
2435 a.message === bm.message &&
2436 a.projectedItem === bm.projectedItem &&
2437 a.durationStart === bm.durationStart &&
2438 a.showAssistantMeta === bm.showAssistantMeta &&
2439 a.showAssistantCopyButton === bm.showAssistantCopyButton &&
2440 a.assistantCopyStreaming === bm.assistantCopyStreaming &&
2441 a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
2442 a.revertTurnCount === bm.revertTurnCount
2443 );
2444 }
2445 }
2446}
2447
2448const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
2449const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
2450const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g;
2451
2452function visibleUserMessageText(text: string): string {
2453 const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1");
2454 let visible = "";
2455 let cursor = 0;
2456 for (const match of collectAssistantCitations(text)) {
2457 visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation);
2458 cursor = match.end;
2459 }
2460 return visible + linkLabels(text.slice(cursor));
2461}
2462
2463export function shouldCollapseUserMessage(text: string): boolean {
2464 const visible = visibleUserMessageText(text);
2465 if (visible.trim().length === 0) {
2466 return false;
2467 }
2468
2469 return (
2470 visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH ||
2471 visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES
2472 );
2473}