import { GitPullRequestIcon } from "lucide-react"; import { type AssistantCitation, type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type ToolActivityIcon, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; 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 = () => {}; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { createContext, Fragment, memo, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { LegendList, type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { createMessageAttachmentPreviewProjector, deriveTimelineEntries, selectMessageImageResources, workEntryDisplayIndicatesToolFailure, workEntrySignalsSevereFailure, workLogEntryIsToolLike, } from "../../session-logic"; import { type ChatMessage, type ChatFileAttachment, type ChatImageAttachment, isBrowserPreviewAttachment, isFileAttachment, isImageAttachment, isVideoAttachment, type TurnDiffSummary, } from "../../types"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { T3Wordmark } from "../T3Wordmark"; import { BotIcon, BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CircleAlertIcon, DownloadIcon, EyeIcon, GlobeIcon, HammerIcon, MessageCircleIcon, Minimize2Icon, MousePointerClickIcon, PaintbrushIcon, SearchIcon, SmartphoneIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { buildAttachmentVideoAsset, buildExpandedImagePreview, ExpandedImagePreview, } from "./ExpandedImagePreview"; import { SNAP_SHOT_ATTACHMENT_FRAME_CLASS, SnapShotAttachmentDetails, } from "./SnapShotAttachmentDetails"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { CHAT_TIMELINE_ANCHOR_OFFSET, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { AssistantCitationSource, type AssistantCitationRequest, type AssistantCitationTarget, } from "./AssistantCitationSource"; import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRowsWithState, type MessagesTimelineRowsProjection, liveWorkEntryLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryDisplayLabel, workEntryIsVisibleInGroup, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, type WorkGroupScrollAnchor, } 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 { deriveAgentSpawnSummary } from "./agentSpawnSummary"; 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 { citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; timestampFormat: TimestampFormat; routeThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } interface TimelineRowActivityState { isWorking: boolean; isPreparingWorktree: boolean; isCompacting: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; } const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); interface WorkGroupViewState { scrollPositions: Map; expandedEntries: Set; } const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; onToggleEntry: (collapsed: boolean) => void; } | null>(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 (
); } function TimelineListFooter({ composerInset }: { readonly composerInset: number }) { return (
); } const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { dataChange: true, // Composer inset changes must not move already-visible messages. New // rows and row growth still keep live-follow pinned through the other // triggers below. footerLayout: false, itemLayout: true, layout: true, }, } as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) // --------------------------------------------------------------------------- interface MessagesTimelineProps { citationRequest?: AssistantCitationRequest | null; citationHistoryLoading?: boolean; onCiteAssistantText?: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, ) => boolean; agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; isPreparingWorktree?: boolean; isCompacting?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; supportsConversationRollback: boolean; onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; onFileDownload?: (attachment: ChatFileAttachment) => 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; /** * Whether the real rows extend past the viewport above the composer. * Reported after scrolls, row size changes, and viewport resizes. */ onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ loadEarlier?: CitationHistoryPage | null; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ citationRequest = null, citationHistoryLoading = false, onCiteAssistantText, isWorking, isPreparingWorktree = false, isCompacting = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaries, routeThreadKey, onOpenTurnDiff, supportsConversationRollback, onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, isRevertingCheckpoint, onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, onFileDownload = NOOP_OPEN_ATTACHMENT, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const expandCitedTurn = useCallback((turnId: TurnId) => { setExpandedTurnIds((current) => current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); // Scroll/disclosure state outlives virtualized rows, but never the current thread. const workGroupViewState = useMemo( () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), [routeThreadKey], ); 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, collapsed = false) => { 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; // Wait for row measurement and the disclosure click's blur check. // Closing output can reveal the end without a scroll event. if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) { onToolOutputCollapsedAtEnd?.(); } }); }); }, [listRef, onToolOutputCollapsedAtEnd], ); 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, expandedWorkGroupIds.has(groupId)); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, [expandedWorkGroupIds, 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 rowsProjectionRef = useRef<{ threadKey: string; workspaceRoot: string | undefined; projection: MessagesTimelineRowsProjection; } | null>(null); const rawRows = useMemo(() => { const previous = rowsProjectionRef.current; const projection = deriveMessagesTimelineRowsWithState( { timelineEntries, latestTurn, runningTurnId, expandedTurnIds, expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, }, previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot ? previous.projection : null, ); rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection }; return projection.rows; }, [ rowsProjectionRef, routeThreadKey, workspaceRoot, timelineEntries, latestTurn, runningTurnId, expandedTurnIds, expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, ]); const rows = useStableRows(rawRows); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); const { target: readyCitationRequest, positioning: citationPositioning, onListLoad: onCitationListLoad, alwaysRender: citationAlwaysRender, } = useAssistantCitationTarget({ request: citationRequest, entries: timelineEntries, rows, listRef, viewport: timelineViewportElement, historyLoading: citationHistoryLoading, loadEarlier, onExpandTurn: expandCitedTurn, onManualNavigation, }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); 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.role === "user" ? row.message.id : null), { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); const timelineListFooter = useMemo( () => , [anchoredEndSpace, contentInsetEndAdjustment], ); const measureContentOverflow = useCallback( () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { composerInset: contentInsetEndAdjustment, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }), [contentInsetEndAdjustment, listRef], ); // LegendList lays rows out from layout effects, so a read on the next frame // sees the settled positions. One frame is shared across bursts of size // changes. const contentOverflowFrameRef = useRef(null); const cancelContentOverflowFrame = useCallback(() => { if (contentOverflowFrameRef.current !== null) { cancelAnimationFrame(contentOverflowFrameRef.current); contentOverflowFrameRef.current = null; } }, []); const reportContentOverflow = useCallback(() => { if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return; contentOverflowFrameRef.current = requestAnimationFrame(() => { contentOverflowFrameRef.current = null; onContentOverflowChange(measureContentOverflow()); }); }, [measureContentOverflow, onContentOverflowChange]); useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); // The list's own layout effects have already run here, so estimated row // positions are in place. Reporting before the first paint lets a thread // open in its final composer layout instead of correcting it a frame later. // A frame scheduled with the previous inset would overwrite this read, so // it is dropped first. useLayoutEffect(() => { cancelContentOverflowFrame(); onContentOverflowChange?.(measureContentOverflow()); }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } reportContentOverflow(); if (!state || minimapItems.length === 0) { return; } const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); const itemBounds = minimapItems.map((item) => ({ top: resolveTimelineRowTop(state, item.rowIndex), height: resolveTimelineRowHeight(state, item.rowIndex), })); for (const [index, item] of minimapItems.entries()) { const strip = minimapStripMap.get(item.id); const bounds = itemBounds[index]; const rowTop = bounds?.top ?? null; const rowHeight = bounds?.height ?? null; const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; if (strip) { strip.dataset.inView = inView ? "true" : "false"; } } const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({ scrollTop, scrollBottom, itemBounds, }); setMinimapCurrentIndex((current) => current === nextCurrentIndex ? current : nextCurrentIndex, ); }, [ citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange, reportContentOverflow, ]); 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)); reportContentOverflow(); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [timelineViewportElement, rows.length, reportContentOverflow]); const sharedState = useMemo( () => ({ citationRequest: readyCitationRequest, listRef, timestampFormat, routeThreadKey, // Keep Markdown callbacks memoized during unrelated activity updates. threadRef: citationThreadRef, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, onFileDownload, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, workGroupViewState, agentPanelModel, onOpenAgents, }), [ readyCitationRequest, listRef, timestampFormat, routeThreadKey, citationThreadRef, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, onFileDownload, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, suspendEndScrollMaintenanceForDisclosure, workGroupViewState, agentPanelModel, onOpenAgents, ], ); const activityState = useMemo( () => ({ isWorking, isPreparingWorktree, isCompacting, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, }), [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], ); // 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 (
{onCiteAssistantText && citationThreadRef ? ( ) : null} ref={listRef} data={rows} extraData={rows.length} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd={citationRequest === null} // Legend needs a data refresh to mount new pins without a scroll event. {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})} {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} onLoad={onCitationListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling ? false : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition } maintainScrollAtEndThreshold={1} onScroll={handleScroll} onItemSizeChanged={reportContentOverflow} 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={timelineListFooter} /> { 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, currentIndex, items, stripMap, onSelect, }: { hasPersistentGutter: boolean; hitStripWidth: number; currentIndex: number | null; 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 resolvedCurrentIndex = currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null; const previousItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null); const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null); 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 ( ); } function TimelineMinimapNavigationButton({ direction, disabled, onClick, }: { direction: "previous" | "next"; disabled: boolean; onClick: () => void; }) { const previous = direction === "previous"; const label = previous ? "Previous turn" : "Next turn"; const Icon = previous ? ChevronUpIcon : ChevronDownIcon; return ( } > {label} ); } // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isExpandedToolGroupHeader = (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); return (
{row.kind === "work" ? ( ) : null} {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} {row.kind === "assistant-meta" ? : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} {row.kind === "thinking" ? : null}
); }); function ContextCompactionTimelineRow({ row, }: { row: Extract; }) { return (
); } function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { const ctx = use(TimelineRowCtx); const asset = useMemo( () => file.downloadable === false ? null : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file), [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name], ); const resource = asset?.resource ?? null; const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); if (asset === null && src === null) { return (
{file.name}
); } return ( ); } function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const resources = useMemo( () => selectMessageImageResources(row.message.attachments), [row.message.attachments], ); const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources); const [projectPreviews] = useState(createMessageAttachmentPreviewProjector); const messageWithPreviews = useMemo(() => { const urlsById = new Map( resources.flatMap((resource, index) => { const url = previewUrls[index]; return url ? [[resource.attachmentId, url] as const] : []; }), ); return projectPreviews(row.message, (attachment) => urlsById.get(attachment.id)); }, [previewUrls, projectPreviews, resources, row.message]); // The attachment union has an open member, so guards (not literal type // comparisons) split it. Unknown types render as inert rows below the files. const userImages = (messageWithPreviews.attachments ?? []).filter(isImageAttachment); const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); const userVideos = userFiles.filter(isVideoAttachment); const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file)); const unknownAttachments = (row.message.attachments ?? []).filter( (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment), ); 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 revertTurnCount = row.revertTurnCount; return (
{(regularImages.length > 0 || userVideos.length > 0) && (
{regularImages.map((image) => (
{image.previewUrl ? ( ) : (
{image.name}
)} {image.previewUrl && image.source?.kind === "snap-shot" ? ( ) : null}
))} {userVideos.map((file) => ( ))}
)} {previewAnnotations.map((annotation, index) => ( ))} {otherUserFiles.length > 0 || unknownAttachments.length > 0 ? (
{otherUserFiles.map((file) => { const opensInPreview = isBrowserPreviewAttachment(file); const fileIdentity = ( <> {file.name} ); if (opensInPreview && file.downloadable !== false) { return (
ctx.onFileDownload(file)} /> } > Download {file.name}
); } const content = ( <> {fileIdentity} {file.downloadable === false ? null : ( )} ); return file.previewUrl && !opensInPreview ? ( {content} ) : file.downloadable === false ? (
{content}
) : ( ); })} {unknownAttachments.map((attachment) => (
{attachment.name}
))}
) : null} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( ))}
) : null}
}> {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
{typeof revertTurnCount === "number" && ( )} {displayedUserMessage.copyText && ( )}
); } function RevertUserMessageButton({ turnCount }: { turnCount: number }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); return ( ctx.onRevertToTurnCount(turnCount)} 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 ? ( ) : null}
); } function AssistantMetaTimelineRow({ row, }: { row: Extract; }) { return (
); } function AssistantMessageMeta({ className, message, showCopyButton, copyStreaming, alwaysVisible = false, }: { className?: string; message: ChatMessage; showCopyButton: boolean; copyStreaming: boolean; alwaysVisible?: boolean; }) { const ctx = use(TimelineRowCtx); return (
{!message.streaming && ( }> {formatDayAwareTimestamp(message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat)} )}
); } function AssistantCopyButton({ message, showCopyButton, streaming, }: { message: ChatMessage; showCopyButton: boolean; streaming: boolean; }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: message.text ?? null, showCopyButton, streaming, }); if (!assistantCopyState.visible) { return null; } return ; } function ProposedPlanTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); return (
); } function WorkingTimelineRow({ row }: { row: Extract }) { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); return (
{isPreparingWorktree ? ( <> Setting up worktree… Setting up worktree… ) : isCompacting ? ( <> ) : row.createdAt ? ( <> Working for ) : ( "Working..." )}
); } function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. return (
{isPreparingWorktree || isCompacting ? null : ( )}
); } function CompactingLabel() { return ( ); } // --------------------------------------------------------------------------- // 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 elapsed time for the "Working for" 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 standalone activity or one bounded, virtualized expanded tool group. */ const WorkGroupSection = memo(function WorkGroupSection({ anchorKey, groupedEntries, isExpandedToolGroup, displayLabel, }: { anchorKey: string; groupedEntries: Extract["groupedEntries"]; isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); const onToggleStandaloneEntry = useCallback( (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), [anchorKey, onToggleWorkEntry], ); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), [groupedEntries, isExpandedToolGroup], ); if (nonEmptyEntries.length === 0) return null; if (isExpandedToolGroup) { return ( ); } return (
{nonEmptyEntries.map((workEntry) => ( ))}
); }); function ExpandedWorkGroupEntries({ anchorKey, entries, workspaceRoot, }: { anchorKey: string; entries: TimelineWorkEntry[]; workspaceRoot: string | undefined; }) { const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); const [initialScrollIndex] = useState(() => resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), ); const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); const listRef = useRef(null); const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); const [appendState, setAppendState] = useState({ entries, follow: false }); // Capture the pre-change edge once per incoming array, before new layout // metrics arrive. Edge/viewport changes never turn a status update into a follow. if (appendState.entries !== entries) { setAppendState({ entries, follow: fades.viewportHeight > 0 && shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), }); } const groupView = useMemo( () => ({ state: viewState, onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), }), [anchorKey, onToggleWorkEntry, viewState], ); const updateScrollFades = useCallback(() => { const element = listRef.current?.getScrollableNode(); if (!element) return; const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; const viewportHeight = element.clientHeight; const top = element.scrollTop > 1; const bottom = distanceFromEnd > 1; setFades((previous) => previous.top === top && previous.bottom === bottom && previous.viewportHeight === viewportHeight ? previous : { top, bottom, viewportHeight }, ); }, []); const handleScroll = useCallback(() => { const state = listRef.current?.getState(); const position = state && resolveWorkGroupScrollAnchor(state); if (position) { viewState.scrollPositions.set(anchorKey, { entryId: position.rowId, offset: position.offsetWithinRow, }); } updateScrollFades(); }, [anchorKey, updateScrollFades, viewState]); const handleLoad = useCallback(() => { const list = listRef.current; const element = list?.getScrollableNode(); if (initialScrollIndex && list && element) { // Bootstrap can report the restored target before the DOM has applied it. // Reconcile once at load, before releasing the measured anchor row. const offset = Math.max( 0, Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), ); if (Math.abs(element.scrollTop - offset) > 1) { void list.scrollToOffset({ offset, animated: false }); } } setRestoringPosition(false); }, [initialScrollIndex]); useLayoutEffect(() => { const element = listRef.current?.getScrollableNode(); if (!element) return; updateScrollFades(); const observer = new ResizeObserver(updateScrollFades); observer.observe(element); if (element.firstElementChild) observer.observe(element.firstElementChild); return () => observer.disconnect(); }, [updateScrollFades]); const renderEntry = useCallback( ({ item }: { item: TimelineWorkEntry }) => ( ), [workspaceRoot], ); return ( ); } const workEntryKey = (entry: TimelineWorkEntry) => entry.id; function ActivityShimmerOverlay({ children }: { children: ReactNode }) { return ( {children} ); } const failedToolIconClassName = "text-tool-error-icon/40"; /** Image icons and the gradient computer-use mark cannot take a currentColor * tint, so failed rows using them get a trailing x instead. */ function toolIconAcceptsTint( iconName: WorkEntryIconName, toolIcon: ToolActivityIcon | undefined, ): boolean { return toolIcon === undefined && iconName !== "computer"; } function LiveActivityRow({ label, iconName, toolIcon, failed = false, active = false, shimmer = false, }: { label: string; iconName?: WorkEntryIconName; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; active?: boolean; shimmer?: boolean; }) { const animated = active && !failed; const showShimmer = animated && shimmer; return (
{showShimmer ? ( ) : null}
); } function LiveActivityContent({ label, iconName, toolIcon, failed = false, announceFailure = false, active = false, highlighted = false, }: { label: string; iconName: WorkEntryIconName | undefined; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; announceFailure?: boolean; active?: boolean; highlighted?: boolean; }) { const showTrailingFailureMark = failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); return ( {iconName ? ( ) : null} {label} {showTrailingFailureMark ? ( ) : null} ); } function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( ); } function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { switch (kind) { case "pull-request": case "link-pr": case "unlink-pr": case "list-prs": return "pull-request"; case "read": return "eye"; case "edit": return "square-pen"; case "command": return "terminal"; case "browser": return "browser"; case "device": return "device"; case "search": return "globe"; case "code-search": return "search"; case "other": return "wrench"; case "dynamic-tool": return "hammer"; case "agent-tool": return "bot"; case "tone-tool": return "zap"; case "update": case "mixed": return "hammer"; } } function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); 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 persistedExpanded = useUiStateStore( (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const allDirectoriesExpanded = persistedExpanded ?? false; return ( setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) } 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: ChatImageAttachment | 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`; } return formatDuration(elapsedSeconds * 1_000); } function formatWorkingTimerNow(startIso: string): string { return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; } type WorkEntryIconName = | "bot" | "brain" | "browser" | "check" | "circle-alert" | "computer" | "device" | "eye" | "globe" | "hammer" | "message-circle" | "search" | "square-pen" | "terminal" | "pull-request" | "t3-code" | "wrench" | "x" | "zap"; function BrowserAppIcon({ className }: { className: string }) { return ( ); } function ComputerUseAppIcon({ className }: { className: string }) { const gradientId = `${useId().replaceAll(":", "")}-computer-use-app-gradient`; return ( ); } function ToolActivityIconView(props: { icon: ToolActivityIcon | undefined; fallbackName: WorkEntryIconName; className: string; muted: boolean; }) { const { resolvedTheme } = use(TimelineRowCtx); const fallbackClassName = cn(props.className, props.muted && "opacity-70 light:brightness-[.6]"); if (!props.icon) { return ; } if (props.icon._tag === "website") { const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32); return src ? ( ) : ( ); } if (props.icon._tag === "themed-logo") { const src = resolvedTheme === "dark" ? (props.icon.logoUrlDark ?? props.icon.logoUrl) : props.icon.logoUrl; return ( ); } return ( ); } function NativeAppToolActivityIcon(props: { app: Extract["app"]; fallbackName: WorkEntryIconName; className: string; muted: boolean; }) { const { activeThreadEnvironmentId } = use(TimelineRowCtx); const asset = useAssetUrlState(activeThreadEnvironmentId, { _tag: "native-app-icon", app: props.app, }); if (asset._tag !== "Success") { return ( ); } const cacheKey = getProjectFaviconCacheKey( activeThreadEnvironmentId, JSON.stringify(props.app), asset.url, ); return ( ); } const loadedToolActivityIconSrcs = new Map(); function ToolActivityImageIcon(props: { cacheKey: string; src: string; fallbackName: WorkEntryIconName; className: string; muted: boolean; }) { const [displayedSrc, setDisplayedSrc] = useState( () => loadedToolActivityIconSrcs.get(props.cacheKey) ?? null, ); const isLoading = displayedSrc !== props.src; const handleLoadError = (failedSrc: string) => { if (loadedToolActivityIconSrcs.get(props.cacheKey) === failedSrc) { loadedToolActivityIconSrcs.delete(props.cacheKey); } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; return ( <> {displayedSrc === null ? ( ) : null} {displayedSrc ? ( handleLoadError(displayedSrc)} /> ) : null} {isLoading ? ( { loadedToolActivityIconSrcs.set(props.cacheKey, props.src); setDisplayedSrc(props.src); }} onError={() => handleLoadError(props.src)} /> ) : null} ); } function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { case "pull-request": return ; case "bot": return ; case "brain": return ; case "browser": return ; case "computer": return ; case "device": return ; case "t3-code": return ; case "check": return ; case "circle-alert": return ; case "eye": return ; case "globe": return ; case "hammer": return ; case "message-circle": return ; case "search": 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: "brain", className: "text-foreground", }; } if (tone === "info") { return { iconName: "check", className: "text-icon-muted", }; } return { iconName: "zap", className: "text-foreground", }; } 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, visibleLabel: string, viewedImagePath: string | null, ): string | null { const blocks: string[] = []; const seen = new Set([visibleLabel.trim()]); const addBlock = (value: string | null | undefined) => { const text = value?.trim(); if (!text || seen.has(text)) return; seen.add(text); blocks.push(text); }; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { addBlock(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } const command = workEntry.command?.trim(); const raw = workEntryRawCommand(workEntry); if (command === visibleLabel.trim()) { seen.add(command); } else { addBlock(raw ?? command); } const detail = workEntry.detail?.trim(); if (detail !== viewedImagePath?.trim()) { addBlock(detail); } const viewedImagePaths = new Set( viewedImagePath ? [viewedImagePath.trim(), formatWorkspaceRelativePath(viewedImagePath, workspaceRoot)] : [], ); const changedFiles = (workEntry.changedFiles ?? []).flatMap((filePath) => { const formattedPath = formatWorkspaceRelativePath(filePath, workspaceRoot); return viewedImagePaths.has(filePath) || viewedImagePaths.has(formattedPath) || filePath.trim() === detail || formattedPath === detail ? [] : [formattedPath]; }); if (changedFiles.length > 0) { addBlock([...new Set(changedFiles)].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.toolSurface) return workEntry.toolSurface; const toolPresentation = resolveWorkEntryToolPresentation(workEntry); if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); if (action !== "other") return toolGroupSummaryIconName(action); 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; } const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); /** * Click handler for expanded row labels, which turn text selection back on. * Only a click that ends a real selection is withheld from the row toggle, so * an ordinary click on the label still bubbles and collapses the row it opened. */ const stopRowToggleWhileSelectingText = (e: MouseEvent) => { const selection = e.currentTarget.ownerDocument.getSelection(); if (selection && !selection.isCollapsed) { 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 summary = deriveAgentSpawnSummary({ agents, agentCount, coordinatorStatus: workflowGroup?.workflow.status, }); const { live, lead } = summary; // 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; const dotClass = { working: "bg-info", failed: "bg-destructive", completed: "bg-success", inactive: "bg-muted-foreground/50", }[summary.tone]; const status = live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status; return ( ); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = 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; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); const groupView = use(WorkGroupViewCtx); const [expanded, setExpanded] = useState( () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, ); const toggleExpanded = () => { const next = !expanded; if (groupView) { groupView.onToggleEntry(!next); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); } else { props.onToggleEntry?.(!next); } setExpanded(next); }; const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); const entryIconName = showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry); const entryToolIcon = showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); const previewText = workEntry.questionAnswer ? "Question answer submitted" : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { threadId: threadRef.threadId, workspaceRoot, }) : null; const canExpand = (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( workEntryRawCommand(workEntry) || workEntry.command?.trim() || workEntry.detail?.trim() || workEntry.changedFiles?.length || viewedImage, ); const expandedBody = expanded ? buildToolCallExpandedBody( workEntry, workspaceRoot, previewText, viewedImage ? viewedImagePath : null, ) : null; // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-6 shrink-0 items-center justify-center", showWarningIndicator ? "text-warning" : showDestructiveRowStyle ? "text-destructive" : showFailedIndicator ? failedToolIconClassName : workEntry.tone === "tool" ? "text-icon-muted" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" : workLogEntryIsToolLike(workEntry) ? "text-secondary-label" : "text-foreground/80"; const accessibleDisplayText = showFailedIndicator ? `${previewText}, tool call failed` : previewText; const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": accessibleDisplayText, "aria-expanded": expanded, onClick: toggleExpanded, onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpanded(); } }, } : {}; return (

{previewText}

{showFailedIndicator && !showDestructiveRowStyle && !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( ) : null}
{expanded && viewedImage && threadRef ? (
) : null} {workEntry.questionAnswer ? ( ) : null} {expanded && canExpand && expandedBody ? (
{expandedBody}
) : null}
); }); function QuestionAnswerHistory({ answer, }: { answer: import("@t3tools/contracts").UserInputAttachmentAnswerPayload; }) { const { activeThreadEnvironmentId } = use(TimelineRowCtx); const attachments = useMemo(() => Object.values(answer.attachmentsByQuestionId).flat(), [answer]); const resources = useMemo( () => attachments.map((attachment) => ({ _tag: "attachment" as const, attachmentId: attachment.id, })), [attachments], ); const urls = useAssetUrls(activeThreadEnvironmentId, resources); return (
{[ ...new Set([ ...Object.keys(answer.answers), ...Object.keys(answer.attachmentsByQuestionId), ]), ].map((questionId) => (
{answer.questionTextById?.[questionId] ? (

{answer.questionTextById[questionId]}

) : null}

{[answer.answers[questionId]] .flat() .filter((value): value is string => typeof value === "string") .join(", ")}

{(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { const url = urls[attachments.indexOf(attachment)]; return ( {attachment.type === "image" && url ? ( {attachment.name} ) : ( attachment.name )} ); })}
))}
); }