import { type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";import { createContext, Fragment, memo, use, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode,} from "react";import { flushSync } from "react-dom";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { FileDiff } from "@pierre/diffs/react";import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike,} from "../../session-logic";import { type TurnDiffSummary } from "../../types";import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath,} from "../../lib/diffRendering";import ChatMarkdown from "../ChatMarkdown";import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, EyeIcon, GlobeIcon, HammerIcon, MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon,} from "lucide-react";import { Button } from "../ui/button";import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";import { ProposedPlanCard } from "./ProposedPlanCard";import { ChangedFilesTree } from "./ChangedFilesTree";import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";import { MessageCopyButton } from "./MessageCopyButton";import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, 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, formatShortTimestamp } 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; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;} interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean;} const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; // ---------------------------------------------------------------------------// Props (public API)// --------------------------------------------------------------------------- interface MessagesTimelineProps { isWorking: boolean; activeTurnInProgress: 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; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; 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; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void;} // ---------------------------------------------------------------------------// MessagesTimeline — list owner// --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, onManualNavigation,}: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return next; }); }, []); const onToggleWorkGroup = useCallback( (groupId: string, anchorElement?: HTMLElement) => { const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; flushSync(() => { setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }); if (anchorBottomBeforeToggle === null || !anchorElement) { return; } const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; if (Math.abs(delta) < 0.5) { return; } const list = listRef.current; const currentScroll = list?.getState?.().scroll; if (list && typeof currentScroll === "number") { list.scrollToOffset({ offset: currentScroll + delta, animated: false }); } }, [listRef], ); // 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 handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { onAnchorReady(anchorMessageId, info.anchorIndex); } }, [anchorMessageId, onAnchorReady], ); const handleAnchorSizeChanged = useCallback( (size: number) => { if (anchorMessageId !== null) { onAnchorSizeChanged(anchorMessageId, size); } }, [anchorMessageId, onAnchorSizeChanged], ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => row.kind === "message" ? row.message.id : null, ); return config ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } if (!state || minimapItems.length === 0) { return; } const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); if (!strip) { continue; } const rowTop = resolveTimelineRowTop(state, item.rowIndex); const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; strip.dataset.inView = inView ? "true" : "false"; } }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); useEffect(() => { if (!timelineViewportElement) { return; } const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [timelineViewportElement, rows.length]); const sharedState = useMemo<TimelineRowSharedState>( () => ({ timestampFormat, routeThreadKey, threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, ], ); const activityState = useMemo<TimelineRowActivityState>( () => ({ isWorking, isRevertingCheckpoint, activeTurnInProgress, }), [activeTurnInProgress, isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => ( <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-clip" data-timeline-root="true"> <TimelineRowContent row={item} /> </div> ), [], ); if (rows.length === 0 && !isWorking) { return ( <div className="flex h-full items-center justify-center"> <p className="text-sm text-muted-foreground/30"> Send a message to start the conversation. </p> </div> ); } return ( <TimelineRowCtx value={sharedState}> <TimelineRowActivityCtx value={activityState}> <div ref={setTimelineViewportElement} className="relative h-full min-h-0"> <LegendList<MessagesTimelineRow> ref={listRef} data={rows} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ anchoredEndSpace ? false : { animated: false, on: { dataChange: true, itemLayout: true, layout: true, }, } } maintainVisibleContentPosition={{ data: true, size: false, }} onScroll={handleScroll}Lines 1–500 of 2057. Load more
import { type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, Fragment, memo, use, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, CircleAlertIcon, EyeIcon, GlobeIcon, HammerIcon, MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, 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, formatShortTimestamp } 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; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; } interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; } const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; // --------------------------------------------------------------------------- // Props (public API) // --------------------------------------------------------------------------- interface MessagesTimelineProps { isWorking: boolean; activeTurnInProgress: 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; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; 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; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, listRef, timelineEntries, latestTurn, runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, onManualNavigation, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return next; }); }, []); const onToggleWorkGroup = useCallback( (groupId: string, anchorElement?: HTMLElement) => { const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; flushSync(() => { setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }); if (anchorBottomBeforeToggle === null || !anchorElement) { return; } const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; if (Math.abs(delta) < 0.5) { return; } const list = listRef.current; const currentScroll = list?.getState?.().scroll; if (list && typeof currentScroll === "number") { list.scrollToOffset({ offset: currentScroll + delta, animated: false }); } }, [listRef], ); // 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 handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { onAnchorReady(anchorMessageId, info.anchorIndex); } }, [anchorMessageId, onAnchorReady], ); const handleAnchorSizeChanged = useCallback( (size: number) => { if (anchorMessageId !== null) { onAnchorSizeChanged(anchorMessageId, size); } }, [anchorMessageId, onAnchorSizeChanged], ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => row.kind === "message" ? row.message.id : null, ); return config ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } if (!state || minimapItems.length === 0) { return; } const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); if (!strip) { continue; } const rowTop = resolveTimelineRowTop(state, item.rowIndex); const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; strip.dataset.inView = inView ? "true" : "false"; } }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); useEffect(() => { if (!timelineViewportElement) { return; } const measure = () => { const viewportWidth = timelineViewportElement.getBoundingClientRect().width; const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); }; const frame = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [timelineViewportElement, rows.length]); const sharedState = useMemo<TimelineRowSharedState>( () => ({ timestampFormat, routeThreadKey, threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, ], ); const activityState = useMemo<TimelineRowActivityState>( () => ({ isWorking, isRevertingCheckpoint, activeTurnInProgress, }), [activeTurnInProgress, isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => ( <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-clip" data-timeline-root="true"> <TimelineRowContent row={item} /> </div> ), [], ); if (rows.length === 0 && !isWorking) { return ( <div className="flex h-full items-center justify-center"> <p className="text-sm text-muted-foreground/30"> Send a message to start the conversation. </p> </div> ); } return ( <TimelineRowCtx value={sharedState}> <TimelineRowActivityCtx value={activityState}> <div ref={setTimelineViewportElement} className="relative h-full min-h-0"> <LegendList<MessagesTimelineRow> ref={listRef} data={rows} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ anchoredEndSpace ? false : { animated: false, on: { dataChange: true, itemLayout: true, layout: true, }, } } maintainVisibleContentPosition={{ data: true, size: false, }} onScroll={handleScroll}