import { type ChatFileAttachment, type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";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 { createContext, Fragment, memo, use, useCallback, useEffect, useLayoutEffect, 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, workEntryDisplayIndicatesToolFailure, workEntrySignalsSevereFailure, workLogEntryIsToolLike,} from "../../session-logic";import { type ChatImageAttachment, isFileAttachment, isImageAttachment, isVideoAttachment, type TurnDiffSummary,} from "../../types";import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath,} from "../../lib/diffRendering";import ChatMarkdown from "../ChatMarkdown";import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, DownloadIcon, EyeIcon, FileIcon, GlobeIcon, HammerIcon, MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, PlayIcon, SearchIcon, 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 { CHAT_TIMELINE_ANCHOR_OFFSET, keepTimelineEndVisibleAfterOverlayGrowth,} from "./timelineScrollAnchoring";import { MessageCopyButton } from "./MessageCopyButton";import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryIsVisibleInGroup, 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<Pick<ServerProviderSkill, "name" | "displayName">>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; openingVideoAttachmentId: string | null; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; onOpenAgents: () => void;} interface TimelineRowActivityState { isWorking: boolean; isPreparingWorktree: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null;} const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;const TIMELINE_LIST_FADE_HEADER = ( <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />); // 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 ( <div className={fade ? "pt-[var(--workspace-titlebar-scroll-fade-height)]" : "pt-3 sm:pt-4"}> <div className="mx-auto w-full max-w-3xl pb-2"> <button type="button" onClick={onLoadEarlier} disabled={loading} className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default" > {loading ? "Loading earlier turns…" : "Load earlier turns"} </button> </div> </div> );}const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];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; isPreparingWorktree?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject<LegendListRef | null>; timelineEntries: ReturnType<typeof deriveTimelineEntries>; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map<MessageId, number>; onRevertUserMessage: (messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; openingVideoAttachmentId: string | null; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; 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, isPreparingWorktree = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, isRevertingCheckpoint, onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, openingVideoAttachmentId, 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<ReadonlySet<TurnId>>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); const disclosureAnchorKeyRef = useRef<string | null>(null); const disclosureSettleFrameRef = useRef<number | null>(null); const disclosureSettleSecondFrameRef = useRef<number | null>(null); const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); useLayoutEffect(() => { keepTimelineEndVisibleAfterOverlayGrowth({ timeline: listRef.current, previousOverlayHeight: previousContentInsetEndAdjustmentRef.current, overlayHeight: contentInsetEndAdjustment, followingEnd: liveFollowEnabled && anchorMessageId === null, }); previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment; }, [anchorMessageId, contentInsetEndAdjustment, listRef, liveFollowEnabled]); 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<HTMLDivElement | null>( 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.role === "user" ? row.message.id : null), { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); 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) {Lines 1–500 of 2521. Load more
import { type ChatFileAttachment, type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; 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 { createContext, Fragment, memo, use, useCallback, useEffect, useLayoutEffect, 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, workEntryDisplayIndicatesToolFailure, workEntrySignalsSevereFailure, workLogEntryIsToolLike, } from "../../session-logic"; import { type ChatImageAttachment, isFileAttachment, isImageAttachment, isVideoAttachment, type TurnDiffSummary, } from "../../types"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, DownloadIcon, EyeIcon, FileIcon, GlobeIcon, HammerIcon, MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, PlayIcon, SearchIcon, 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 { CHAT_TIMELINE_ANCHOR_OFFSET, keepTimelineEndVisibleAfterOverlayGrowth, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryIsVisibleInGroup, 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<Pick<ServerProviderSkill, "name" | "displayName">>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; openingVideoAttachmentId: string | null; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } interface TimelineRowActivityState { isWorking: boolean; isPreparingWorktree: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; } const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; const TIMELINE_LIST_FADE_HEADER = ( <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" /> ); // 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 ( <div className={fade ? "pt-[var(--workspace-titlebar-scroll-fade-height)]" : "pt-3 sm:pt-4"}> <div className="mx-auto w-full max-w-3xl pb-2"> <button type="button" onClick={onLoadEarlier} disabled={loading} className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default" > {loading ? "Loading earlier turns…" : "Load earlier turns"} </button> </div> </div> ); } const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; 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; isPreparingWorktree?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject<LegendListRef | null>; timelineEntries: ReturnType<typeof deriveTimelineEntries>; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map<MessageId, number>; onRevertUserMessage: (messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen?: (attachment: ChatFileAttachment) => void; openingVideoAttachmentId: string | null; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; 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, isPreparingWorktree = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, isRevertingCheckpoint, onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, openingVideoAttachmentId, 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<ReadonlySet<TurnId>>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); const disclosureAnchorKeyRef = useRef<string | null>(null); const disclosureSettleFrameRef = useRef<number | null>(null); const disclosureSettleSecondFrameRef = useRef<number | null>(null); const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); useLayoutEffect(() => { keepTimelineEndVisibleAfterOverlayGrowth({ timeline: listRef.current, previousOverlayHeight: previousContentInsetEndAdjustmentRef.current, overlayHeight: contentInsetEndAdjustment, followingEnd: liveFollowEnabled && anchorMessageId === null, }); previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment; }, [anchorMessageId, contentInsetEndAdjustment, listRef, liveFollowEnabled]); 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<HTMLDivElement | null>( 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.role === "user" ? row.message.id : null), { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); 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) {