import { 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 { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; 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 MAX_VISIBLE_WORK_LOG_ENTRIES = 6; 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; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } export const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, activeTurnInProgress, activeTurnStartedAt, scrollContainer, timelineEntries, completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, }: 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(() => { const nextRows: TimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; if (!timelineEntry) { continue; } if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; if (!nextEntry || nextEntry.kind !== "work") break; groupedEntries.push(nextEntry.entry); cursor += 1; } nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries, }); index = cursor - 1; continue; } if (timelineEntry.kind === "proposed-plan") { nextRows.push({ kind: "proposed-plan", id: timelineEntry.id, createdAt: timelineEntry.createdAt, proposedPlan: timelineEntry.proposedPlan, }); continue; } nextRows.push({ kind: "message", id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, durationStart: durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, }); } if (isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: activeTurnStartedAt, }); } return nextRows; }, [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 rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, getScrollElement: () => scrollContainer, // Use stable row ids so virtual measurements do not leak across thread switches. getItemKey: (index: number) => rows[index]?.id ?? index, estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); if (row.kind === "working") return 40; return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, 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); } }; }, []); 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 TimelineProposedPlan = Extract["proposedPlan"]; type TimelineWorkEntry = Extract["entry"]; type TimelineRow = | { kind: "work"; id: string; createdAt: string; groupedEntries: TimelineWorkEntry[]; } | { kind: "message"; id: string; createdAt: string; message: TimelineMessage; durationStart: string; showCompletionDivider: boolean; } | { kind: "proposed-plan"; id: string; createdAt: string; proposedPlan: TimelineProposedPlan; } | { kind: "working"; id: string; createdAt: string | null }; function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); return 120 + Math.min(estimatedLines * 22, 880); } 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 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 displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; return (

{heading} {preview && - {preview}}

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