import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, CircleAlertIcon, EyeIcon, GlobeIcon, HammerIcon, type LucideIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, estimateMessagesTimelineRowHeight, normalizeCompactToolLabel, type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; onVirtualizerSnapshot?: (snapshot: { totalSize: number; measurements: ReadonlyArray<{ id: string; kind: MessagesTimelineRow["kind"]; index: number; size: number; start: number; end: number; }>; }) => void; } export const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, activeTurnInProgress, activeTurnStartedAt, scrollContainer, timelineEntries, completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; if (!timelineRoot) return; const updateWidth = (nextWidth: number) => { setTimelineWidthPx((previousWidth) => { if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { return previousWidth; } return nextWidth; }); }; updateWidth(timelineRoot.getBoundingClientRect().width); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { updateWidth(timelineRoot.getBoundingClientRect().width); }); observer.observe(timelineRoot); return () => { observer.disconnect(); }; }, [hasMessages, isWorking]); const rows = useMemo( () => deriveMessagesTimelineRows({ timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt, }), [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], ); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); if (!activeTurnInProgress) return firstTailRowIndex; const turnStartedAtMs = typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; let firstCurrentTurnRowIndex = -1; if (!Number.isNaN(turnStartedAtMs)) { firstCurrentTurnRowIndex = rows.findIndex((row) => { if (row.kind === "working") return true; if (!row.createdAt) return false; const rowCreatedAtMs = Date.parse(row.createdAt); return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; }); } if (firstCurrentTurnRowIndex < 0) { firstCurrentTurnRowIndex = rows.findIndex( (row) => row.kind === "message" && row.message.streaming, ); } if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { const previousRow = rows[index]; if (!previousRow || previousRow.kind !== "message") continue; if (previousRow.message.role === "user") { return Math.min(index, firstTailRowIndex); } if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { break; } } return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); }, [activeTurnInProgress, activeTurnStartedAt, rows]); const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { minimum: 0, maximum: rows.length, }); const virtualMeasurementScopeKey = timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, getScrollElement: () => scrollContainer, // Scope cached row measurements to the current timeline width so offscreen // rows do not keep stale heights after wrapping changes. getItemKey: (index: number) => { const rowId = rows[index]?.id ?? String(index); return `${virtualMeasurementScopeKey}:${rowId}`; }, estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; return estimateMessagesTimelineRowHeight(row, { expandedWorkGroups, timelineWidthPx, turnDiffSummaryByAssistantMessageId, }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); useEffect(() => { if (timelineWidthPx === null) return; rowVirtualizer.measure(); }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const itemIntersectsViewport = item.end > scrollOffset && item.start < scrollOffset + viewportHeight; if (itemIntersectsViewport) { return false; } const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; }; }, [rowVirtualizer]); const pendingMeasureFrameRef = useRef(null); const onTimelineImageLoad = useCallback(() => { if (pendingMeasureFrameRef.current !== null) return; pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { pendingMeasureFrameRef.current = null; rowVirtualizer.measure(); }); }, [rowVirtualizer]); useEffect(() => { return () => { const frame = pendingMeasureFrameRef.current; if (frame !== null) { window.cancelAnimationFrame(frame); } }; }, []); useLayoutEffect(() => { if (!onVirtualizerSnapshot) { return; } onVirtualizerSnapshot({ totalSize: rowVirtualizer.getTotalSize(), measurements: rowVirtualizer.measurementsCache .slice(0, virtualizedRowCount) .flatMap((measurement) => { const row = rows[measurement.index]; if (!row) { return []; } return [ { id: row.id, kind: row.kind, index: measurement.index, size: measurement.size, start: measurement.start, end: measurement.end, }, ]; }), }); }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< Record >({}); const onToggleAllDirectories = useCallback((turnId: TurnId) => { setAllDirectoriesExpandedByTurnId((current) => ({ ...current, [turnId]: !(current[turnId] ?? true), })); }, []); const renderRowContent = (row: TimelineRow) => (
{row.kind === "work" && (() => { const groupId = row.id; const groupedEntries = row.groupedEntries; const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const showHeader = hasOverflow || !onlyToolEntries; const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return (
{showHeader && (

{groupLabel} ({groupedEntries.length})

{hasOverflow && ( )}
)}
{visibleEntries.map((workEntry) => ( ))}
); })()} {row.kind === "message" && row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
{userImages.length > 0 && (
{userImages.map( (image: NonNullable[number]) => (
{image.previewUrl ? ( ) : (
{image.name}
)}
), )}
)} {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( )}
{displayedUserMessage.copyText && ( )} {canRevertAgentWork && ( )}

{formatTimestamp(row.message.createdAt, timestampFormat)}

); })()} {row.kind === "message" && row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); return ( <> {row.showCompletionDivider && (
{completionSummary ? `Response • ${completionSummary}` : "Response"}
)}
{(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; const summaryStat = summarizeTurnDiffStats(checkpointFiles); const changedFileCountLabel = String(checkpointFiles.length); const allDirectoriesExpanded = allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; return (

Changed files ({changedFileCountLabel}) {hasNonZeroStat(summaryStat) && ( <> • )}

); })()}

{formatMessageMeta( row.message.createdAt, row.message.streaming ? formatElapsed(row.durationStart, nowIso) : formatElapsed(row.durationStart, row.message.completedAt), timestampFormat, )}

); })()} {row.kind === "proposed-plan" && (
)} {row.kind === "working" && (
{row.createdAt ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` : "Working..."}
)}
); if (!hasMessages && !isWorking) { return (

Send a message to start the conversation.

); } return (
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { const row = rows[virtualRow.index]; if (!row) return null; return (
{renderRowContent(row)}
); })}
)} {nonVirtualizedRows.map((row) => (
{renderRowContent(row)}
))}
); }); type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { return null; } const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } const hours = Math.floor(elapsedSeconds / 3600); const minutes = Math.floor((elapsedSeconds % 3600) / 60); const seconds = elapsedSeconds % 60; if (hours > 0) { return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } function formatMessageMeta( createdAt: string, duration: string | null, timestampFormat: TimestampFormat, ): string { if (!duration) return formatTimestamp(createdAt, timestampFormat); return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } const UserMessageTerminalContextInlineLabel = memo( function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { const tooltipText = props.context.body.length > 0 ? `${props.context.header}\n${props.context.body}` : props.context.header; return ; }, ); const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; }) { if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, props.terminalContexts, ); const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); const inlineNodes: ReactNode[] = []; if (hasEmbeddedInlineLabels) { let cursor = 0; for (const context of props.terminalContexts) { const label = formatInlineTerminalContextLabel(context.header); const matchIndex = props.text.indexOf(label, cursor); if (matchIndex === -1) { inlineNodes.length = 0; break; } if (matchIndex > cursor) { inlineNodes.push( {props.text.slice(cursor, matchIndex)} , ); } inlineNodes.push( , ); cursor = matchIndex + label.length; } if (inlineNodes.length > 0) { if (cursor < props.text.length) { inlineNodes.push( {props.text.slice(cursor)} , ); } return (
{inlineNodes}
); } } for (const context of props.terminalContexts) { inlineNodes.push( , ); inlineNodes.push( , ); } if (props.text.length > 0) { inlineNodes.push({props.text}); } else if (inlinePrefix.length === 0) { return null; } return (
{inlineNodes}
); } if (props.text.length === 0) { return null; } return (
{props.text}
); }); function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; } { if (tone === "error") { return { icon: CircleAlertIcon, className: "text-foreground/92", }; } if (tone === "thinking") { return { icon: BotIcon, className: "text-foreground/92", }; } if (tone === "info") { return { icon: CheckIcon, className: "text-foreground/92", }; } return { icon: ZapIcon, className: "text-foreground/92", }; } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } function workEntryPreview( workEntry: Pick, ) { if (workEntry.command) return workEntry.command; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; return workEntry.changedFiles!.length === 1 ? firstPath : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; } function workEntryRawCommand( workEntry: Pick, ): string | null { const rawCommand = workEntry.rawCommand?.trim(); if (!rawCommand || !workEntry.command) { return null; } return rawCommand === workEntry.command.trim() ? null : rawCommand; } function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; if (workEntry.requestKind === "file-change") return SquarePenIcon; if (workEntry.itemType === "command_execution" || workEntry.command) { return TerminalIcon; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return SquarePenIcon; } if (workEntry.itemType === "web_search") return GlobeIcon; if (workEntry.itemType === "image_view") return EyeIcon; switch (workEntry.itemType) { case "mcp_tool_call": return WrenchIcon; case "dynamic_tool_call": case "collab_agent_tool_call": return HammerIcon; } return workToneIcon(workEntry.tone).icon; } function capitalizePhrase(value: string): string { const trimmed = value.trim(); if (trimmed.length === 0) { return value; } return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; }) { const { workEntry } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); const preview = workEntryPreview(workEntry); const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; return (

{heading} {preview && (rawCommand ? ( {" "} - {preview} } />

{rawCommand}
) : ( - {preview} ))}

{hasChangedFiles && !previewIsChangedFiles && (
{workEntry.changedFiles?.slice(0, 4).map((filePath) => ( {filePath} ))} {(workEntry.changedFiles?.length ?? 0) > 4 && ( +{(workEntry.changedFiles?.length ?? 0) - 4} )}
)}
); });