import { type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, formatSubagentTokenCount, } from "@t3tools/client-runtime/state/subagentRuntime"; const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, Fragment, memo, use, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, EyeIcon, GlobeIcon, HammerIcon, MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { extractTrailingElementContexts, type ParsedElementContextEntry, } from "~/lib/elementContext"; import { extractTrailingPreviewAnnotation, type ParsedPreviewAnnotation, } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, parseReviewCommentMessageSegments, type ReviewCommentContext, } from "../../reviewCommentContext"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. // Propagates through LegendList's memo boundaries for shared callbacks and // non-row-scoped state. `nowIso` is intentionally excluded — self-ticking // components (WorkingTimer, LiveElapsed) handle it. // --------------------------------------------------------------------------- interface TimelineRowSharedState { timestampFormat: TimestampFormat; routeThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; /** Current plan step label for the working row, when the turn has a plan. */ workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; // Header row shown when older turns exist beyond the loaded window. Plain // button, no spinner animation; the label change is the loading indicator. function TimelineLoadEarlierHeader({ loading, onLoadEarlier, fade, }: { loading: boolean; onLoadEarlier: () => void; fade: boolean; }) { return (
); } const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { dataChange: true, itemLayout: true, layout: true, }, } as const; // --------------------------------------------------------------------------- // Props (public API) // --------------------------------------------------------------------------- interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; workingStepLabel?: string | null; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaryByAssistantMessageId: Map; routeThreadKey: string; 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; skills?: ReadonlyArray>; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; contentInsetEndAdjustment: number; /** * Whether the timeline should keep pinning to the live edge as content * grows. Off while the user is reading history; LegendList's own * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's * scroll-mode refs whenever the user drifts near the bottom. */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, workingStepLabel = null, activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); useEffect(() => { return () => { if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } if (disclosureSettleSecondFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } }; }, []); const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); } if (disclosureSettleSecondFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } disclosureSettleFrameRef.current = requestAnimationFrame(() => { disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; disclosureSettleSecondFrameRef.current = null; }); }); }, []); const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }, []); const maintainVisibleContentPosition = useMemo( () => ({ data: true, size: true, shouldRestorePosition: shouldRestoreVisibleContentPosition, }), [shouldRestoreVisibleContentPosition], ); const onToggleTurnFold = useCallback( (turnId: TurnId) => { suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return next; }); }, [suspendEndScrollMaintenanceForDisclosure], ); const onToggleWorkGroup = useCallback( (groupId: string, anchorKey: string) => { suspendEndScrollMaintenanceForDisclosure(anchorKey); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, [suspendEndScrollMaintenanceForDisclosure], ); // An in-session interrupt leaves its turn expanded so the user keeps their // place; the next turn (or a reload, since this is local state) folds it. const previousLatestTurnRef = useRef(latestTurn); useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = latestTurn; if (!latestTurn || previous?.turnId === undefined) { return; } if (latestTurn.turnId === previous.turnId) { if (previous.state === "running" && latestTurn.state === "interrupted") { setExpandedTurnIds((existing) => { const next = new Set(existing); next.add(latestTurn.turnId); return next; }); } return; } setExpandedTurnIds((existing) => { if (!existing.has(previous.turnId)) { return existing; } const next = new Set(existing); next.delete(previous.turnId); return next; }); }, [latestTurn]); const rawRows = useMemo( () => deriveMessagesTimelineRows({ timelineEntries, latestTurn, runningTurnId, expandedTurnIds, expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, latestTurn, runningTurnId, expandedTurnIds, expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, ], ); const rows = useStableRows(rawRows); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { onAnchorReady(anchorMessageId, info.anchorIndex); } }, [anchorMessageId, onAnchorReady], ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => row.kind === "message" ? row.message.id : null, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } if (!state || minimapItems.length === 0) { return; } const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); if (!strip) { continue; } const rowTop = resolveTimelineRowTop(state, item.rowIndex); const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; strip.dataset.inView = inView ? "true" : "false"; } }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); useEffect(() => { if (!timelineViewportElement) { return; } const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [timelineViewportElement, rows.length]); const sharedState = useMemo( () => ({ timestampFormat, routeThreadKey, threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, onOpenAgents, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, onOpenAgents, ], ); const activityState = useMemo( () => ({ isWorking, isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, workingStepLabel, }), [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => (
), [], ); if (rows.length === 0 && !isWorking) { if (hideEmptyPlaceholder) { return null; } return (

Send a message to start the conversation.

); } return (
ref={listRef} data={rows} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling ? false : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={maintainVisibleContentPosition} onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "topbar-scroll-fade", )} ListHeaderComponent={ loadEarlier !== null ? ( ) : topFadeEnabled ? ( TIMELINE_LIST_FADE_HEADER ) : ( TIMELINE_LIST_HEADER ) } ListFooterComponent={TIMELINE_LIST_FOOTER} /> { onManualNavigation(); void listRef.current?.scrollToIndex({ index: item.rowIndex, animated: true, viewOffset: 24, }); }} />
); }); function keyExtractor(item: MessagesTimelineRow) { return item.id; } function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } interface TimelineMinimapItem { readonly id: string; readonly rowIndex: number; readonly userText: string | null; readonly assistantText: string | null; } interface TimelinePositionState { readonly contentLength?: number; readonly scroll?: number; readonly scrollLength?: number; readonly positionAtIndex?: (index: number) => number | undefined; readonly sizeAtIndex?: (index: number) => number | undefined; } function deriveTimelineMinimapItems( rows: ReadonlyArray, ): TimelineMinimapItem[] { const items: TimelineMinimapItem[] = []; for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; if (row?.kind !== "message" || row.message.role !== "user") { continue; } items.push({ id: row.id, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), }); } return items; } function resolveFinalAssistantTextForTurn( rows: ReadonlyArray, userRowIndex: number, ) { let finalAssistantText: string | null = null; for (let index = userRowIndex + 1; index < rows.length; index += 1) { const row = rows[index]; if (row?.kind !== "message") { continue; } if (row.message.role === "user") { break; } if (row.message.role === "assistant") { finalAssistantText = row.message.text ?? null; } } return finalAssistantText; } function compactMinimapPreview(text: string | null | undefined) { const compact = text?.replace(/\s+/g, " ").trim() ?? ""; return compact.length > 0 ? compact : null; } function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { const top = state.positionAtIndex?.(rowIndex); return typeof top === "number" && Number.isFinite(top) ? top : null; } function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) { const height = state.sizeAtIndex?.(rowIndex); return typeof height === "number" && Number.isFinite(height) ? height : null; } function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { return target instanceof Element && target.closest("[data-minimap-preview]") !== null; } function TimelineMinimap({ hasPersistentGutter, hitStripWidth, items, stripMap, onSelect, }: { hasPersistentGutter: boolean; hitStripWidth: number; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; }) { const [activeIndex, setActiveIndex] = useState(null); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); const activeTopPercent = resolvedActiveIndex === null ? 0 : resolveTimelineMinimapTopPercent(resolvedActiveIndex, items.length); const activeTooltipTranslate = resolvedActiveIndex === null ? "-50%" : resolvedActiveIndex === 0 ? "0%" : resolvedActiveIndex === items.length - 1 ? "-100%" : "-50%"; const resolveActiveIndexFromPointer = useCallback( (event: MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); return resolveTimelineMinimapIndexFromPointer({ itemCount: items.length, railTop: rect.top, railHeight: rect.height, pointerY: event.clientY, }); }, [items.length], ); const updateActiveIndexFromPointer = useCallback( (event: MouseEvent) => { const nextIndex = resolveActiveIndexFromPointer(event); setActiveIndex(nextIndex); }, [resolveActiveIndexFromPointer], ); const moveActiveIndex = useCallback( (delta: number) => { setActiveIndex((current) => { const base = current ?? 0; return Math.max(0, Math.min(items.length - 1, base + delta)); }); }, [items.length], ); if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { return null; } return ( ); } // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return (
{row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); }); function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; let visibleText = displayedUserMessage.visibleText; while (true) { const extracted = extractTrailingPreviewAnnotation(visibleText); if (!extracted.annotation) break; previewAnnotations.unshift(extracted.annotation); visibleText = extracted.promptText; } const elementContextState = extractTrailingElementContexts(visibleText); const elementContexts = [ ...displayedUserMessage.elementContexts, ...elementContextState.contexts, ]; const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
{regularImages.length > 0 && (
{regularImages.map((image: NonNullable[number]) => (
{image.previewUrl ? ( ) : (
{image.name}
)}
))}
)} {previewAnnotations.map((annotation, index) => ( ))} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( ))}
) : null}
}> {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
{canRevertAgentWork && } {displayedUserMessage.copyText && ( )}
); } function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); return ( ctx.onRevertUserMessage(messageId)} aria-label="Revert to this message" /> } > Revert to this message ); } function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
); } function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); return ( <>
{row.showAssistantMeta ? (
{!row.message.streaming && ( } > {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )}
) : null}
); } function AssistantCopyButton({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, streaming: row.assistantCopyStreaming, }); if (!assistantCopyState.visible) { return null; } return ; } function ProposedPlanTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); return (
); } /** * Inline folded plan chip: one row per turn that produced plan/todo steps. * Collapsed by default — a segment bar plus the in-progress step label — * and expands in place to the full step list. Replaces the old plan sidebar. */ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ row, }: { row: Extract; }) { const [expanded, setExpanded] = useState(false); const { steps } = row.turnPlan.plan; const completedCount = steps.filter((step) => step.status === "completed").length; const allDone = completedCount === steps.length; // Label priority: the in-progress step, else the next pending step (plan // just created), else the last step (plan finished, rendered muted). const label = steps.find((step) => step.status === "inProgress")?.step ?? steps.find((step) => step.status === "pending")?.step ?? steps.at(-1)?.step ?? "Plan"; const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; return (
{expanded ? (
{steps.map((step) => (
{step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} {step.step}
))}
) : null}
); }); function WorkingTimelineRow({ row }: { row: Extract }) { const { workingStepLabel } = use(TimelineRowActivityCtx); return (
{row.createdAt ? ( <> Working for ) : ( "Working..." )} {workingStepLabel ? ( · {workingStepLabel} ) : null}
); } // --------------------------------------------------------------------------- // Self-ticking labels — update their own text nodes so elapsed-time display // does not create a React commit every second while a response is streaming. // --------------------------------------------------------------------------- /** Live "Working for Xs" label. */ function WorkingTimer({ createdAt }: { createdAt: string }) { const textRef = useRef(null); const initialText = formatWorkingTimerNow(createdAt); useEffect(() => { const updateText = () => { if (textRef.current) { textRef.current.textContent = formatWorkingTimerNow(createdAt); } }; updateText(); const id = setInterval(updateText, 1000); return () => clearInterval(id); }, [createdAt]); return ( {initialText} ); } // --------------------------------------------------------------------------- // Extracted row sections — own their state / store subscriptions so changes // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, }: { groupedEntries: Extract["groupedEntries"]; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), [groupedEntries], ); const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); const groupLabel = onlyToolEntries ? nonEmptyEntries.length === 1 ? "1 tool call" : `${nonEmptyEntries.length} tool calls` : "Work Log"; if (nonEmptyEntries.length === 0) return null; return (
{!onlyToolEntries && (

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( ))}
); }); function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" : "tool calls" : row.hiddenCount === 1 ? "log entry" : "log entries"; return ( ); } /** Subscribes directly to the UI state store for expand/collapse state, * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ turnSummary, routeThreadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary | undefined; routeThreadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; return ( ); }); /** Inner component that only mounts when there are actual changed files, * so the store subscription is unconditional (no hooks after early return). */ function AssistantChangedFilesSectionInner({ turnSummary, checkpointFiles, routeThreadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary; checkpointFiles: TurnDiffSummary["files"]; routeThreadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const activity = use(TimelineRowActivityCtx); const isLatestTurn = activity.latestTurnId === turnSummary.turnId; const persistedExpanded = useUiStateStore( (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const [autoExpanded] = useState(() => shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), ); const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); return ( setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) } onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} onOpenTurnDiff={onOpenTurnDiff} /> ); } // --------------------------------------------------------------------------- // Leaf components // --------------------------------------------------------------------------- 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 UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: { context: ParsedElementContextEntry; }) { const tooltipText = props.context.body ? `${props.context.header}\n${props.context.body}` : props.context.header; return ( {props.context.header} } /> {tooltipText} ); }); function UserMessagePreviewAnnotationCard(props: { annotation: ParsedPreviewAnnotation; image: NonNullable[number] | null; }) { const ctx = use(TimelineRowCtx); return (
{props.image?.previewUrl ? ( ) : null}
{props.annotation.comment ? (
{props.annotation.comment}
) : null}
{props.annotation.targetSummary ? ( {props.annotation.targetSummary} ) : null} {props.annotation.styleChanges.length > 0 ? ( {props.annotation.styleChanges.length} ) : null}
); } const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; function shouldCollapseUserMessage(text: string): boolean { if (text.trim().length === 0) { return false; } return ( text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES ); } const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; return (
{hasVisibleBody ? (
) : null} {canCollapse || props.footer ? (
{canCollapse ? ( ) : null} {props.footer ? (
{props.footer}
) : null}
) : null}
); }); const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; }) { const ctx = use(TimelineRowCtx); const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); const trailingWhitespace = /\s+$/.exec(textWithoutLeadingWhitespace)?.[0] ?? ""; const content = textWithoutLeadingWhitespace.slice( 0, textWithoutLeadingWhitespace.length - trailingWhitespace.length, ); return ( {leadingWhitespace ? : null} {content ? ( ) : null} {trailingWhitespace ? : null} ); }; const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { return (
{reviewCommentSegments.map((segment) => segment.kind === "text" ? ( segment.text.trim().length > 0 ? (
) : null ) : ( ), )}
); } 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( renderInlineMarkdownSegment( props.text.slice(cursor, matchIndex), `user-terminal-context-inline-before:${context.header}:${cursor}`, ), ); } inlineNodes.push( , ); cursor = matchIndex + label.length; } if (inlineNodes.length > 0) { if (cursor < props.text.length) { inlineNodes.push( renderInlineMarkdownSegment( props.text.slice(cursor), `user-message-terminal-context-inline-rest:${cursor}`, ), ); } return (
{inlineNodes}
); } } for (const context of props.terminalContexts) { inlineNodes.push( , ); inlineNodes.push( , ); } if (props.text.length > 0) { inlineNodes.push( , ); } else if (inlinePrefix.length === 0) { return null; } return (
{inlineNodes}
); } if (props.text.length === 0) { return null; } return ( ); }); function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); const fenceLanguage = comment.fenceLanguage ?? "diff"; const renderablePatch = getRenderablePatch( buildReviewCommentRenderablePatch(comment), `review-comment:${comment.id}`, ); return (
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
{comment.sectionTitle} · {comment.rangeLabel}
{comment.text.length > 0 && (
)} {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && ( )} {renderablePatch?.kind === "files" && renderablePatch.files.map((fileDiff) => ( ))} {renderablePatch?.kind === "raw" && (
          {renderablePatch.text}
        
)}
); } // --------------------------------------------------------------------------- // Structural sharing — reuse old row references when data hasn't changed // so LegendList (and React) can skip re-rendering unchanged items. // --------------------------------------------------------------------------- /** Returns a structurally-shared copy of `rows`: for each row whose content * hasn't changed since last call, the previous object reference is reused. */ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { const prevState = useRef({ byId: new Map(), result: [], }); return useMemo(() => { const nextState = computeStableMessagesTimelineRows(rows, prevState.current); prevState.current = nextState; return nextState.result; }, [rows]); } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- 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 formatWorkingTimerNow(startIso: string): string { return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; } type WorkEntryIconName = | "bot" | "check" | "circle-alert" | "eye" | "globe" | "hammer" | "message-circle" | "square-pen" | "terminal" | "wrench" | "x" | "zap"; function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { case "bot": return ; case "check": return ; case "circle-alert": return ; case "eye": return ; case "globe": return ; case "hammer": return ; case "message-circle": return ; case "square-pen": return ; case "terminal": return ; case "wrench": return ; case "x": return ; case "zap": return ; } } function workToneIcon(tone: TimelineWorkEntry["tone"]): { iconName: WorkEntryIconName; className: string; } { if (tone === "error") { return { iconName: "circle-alert", className: "text-foreground", }; } if (tone === "thinking") { return { iconName: "bot", className: "text-foreground", }; } if (tone === "info") { return { iconName: "check", className: "text-icon-muted", }; } return { iconName: "zap", className: "text-foreground", }; } function workEntryPreview( workEntry: Pick, workspaceRoot: string | undefined, ) { 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; const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 ? displayPath : `${displayPath} +${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 buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { const blocks: string[] = []; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { blocks.push(raw.trim()); } else if (workEntry.command?.trim()) { blocks.push(workEntry.command.trim()); } if (workEntry.detail?.trim()) { blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { blocks.push( changedFiles .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) .join("\n"), ); } return blocks.length > 0 ? blocks.join("\n\n") : null; } const toolCallExpandedBodyClassName = "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { return "message-circle"; } if (workEntry.requestKind === "command") return "terminal"; if (workEntry.requestKind === "file-read") return "eye"; if (workEntry.requestKind === "file-change") return "square-pen"; if (workEntry.itemType === "command_execution" || workEntry.command) { return "terminal"; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return "square-pen"; } if (workEntry.itemType === "web_search") return "globe"; if (workEntry.itemType === "image_view") return "eye"; switch (workEntry.itemType) { case "mcp_tool_call": return "wrench"; case "dynamic_tool_call": return "hammer"; case "collab_agent_tool_call": return "bot"; } // Subagent lifecycle rows (grouped by taskId) get agent identity chrome. if (workEntry.taskId) { return "bot"; } return workToneIcon(workEntry.tone).iconName; } 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 stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); /** * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn * batch). Live status is derived from the shared agent panel model at render * time — the row itself never re-renders a roster; the Agents panel is the * only roster. Freezes to past tense when every member settles. Static dot, * no animation. */ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { const { workEntry } = props; const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); const spawn = workEntry.agentSpawn; if (!spawn) { return null; } const memberIds = new Set(spawn.agentTaskIds); const workflowGroup = spawn.workflowId ? agentPanelModel.workflows.find((group) => group.workflow.id === spawn.workflowId) : undefined; const agents = workflowGroup ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers] : agentPanelModel.directAgents.filter((agent) => memberIds.has(agent.id)); const agentCount = Math.max( agents.length, Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), ); const running = agents.filter( (agent) => agent.status === "running" || agent.status === "pending", ).length; const waiting = agents.filter((agent) => agent.status === "waiting").length; const failed = agents.filter((agent) => agent.status === "failed").length; // The coordinator's own status is authoritative for workflows: dynamic // spawns mean the member list can be momentarily all-settled while the // run is still mid-flight (the "completed" lie from live testing). A // workflow is live until the coordinator itself reaches a terminal state. const coordinatorStatus = workflowGroup?.workflow.status; const coordinatorSettled = coordinatorStatus === "completed" || coordinatorStatus === "failed" || coordinatorStatus === "cancelled" || coordinatorStatus === "interrupted"; const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0; // Same rule as the panel footer: providers may aggregate member usage into // the coordinator, so count the coordinator only when no members exist. const totalTokens = agents.reduce( (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), spawn.workflowId && agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, ); const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); const workflowName = workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; // One steady in-flight presentation (monitoring-pill rule): waiting and // stalled agents read as working; only settled states differentiate. const working = running + waiting; const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success"; const lead = live ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}` : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`; const status = live ? livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : working > 0 ? `${working} working` : "working" : failed > 0 ? `${failed} failed` : "✓ completed"; return ( ); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } return ; }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = rawPreview && normalizeCompactToolLabel(rawPreview).toLowerCase() === normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( "flex size-5 shrink-0 items-center justify-center", showWarningIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator ? "text-icon-muted" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" : "font-medium text-foreground"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = workEntryIndicatesToolSuccess(workEntry) || (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded((v) => !v); } }, } : {}; return (

{heading} {preview && ( {preview} )}

{canExpand ? ( ) : null} {showFailedIndicator ? ( } > Failed ) : showSuccessIndicator ? ( } > Completed ) : showNeutralIndicator ? ( } > Empty ) : null}
{expanded && canExpand && expandedBody ? (
{expandedBody}
) : null}
); });