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<LegendListRef | null>; timestampFormat: TimestampFormat; routeThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; 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<TimelineRowSharedState>(null!);const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); interface WorkGroupViewState { scrollPositions: Map<string, WorkGroupScrollAnchor>; expandedEntries: Set<string>;} const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; onToggleEntry: (collapsed: boolean) => void;} | null>(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> );}function TimelineListFooter({ composerInset }: { readonly composerInset: number }) { return ( <div aria-hidden> <div style={{ height: composerInset }} /> <div className="h-3 sm:h-4" /> </div> );}const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];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<LegendListRef | null>; timelineEntries: ReturnType<typeof deriveTimelineEntries>; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; 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<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; /** * 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<ReadonlySet<TurnId>>(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<ReadonlySet<string>>(new Set()); // Scroll/disclosure state outlives virtualized rows, but never the current thread. const workGroupViewState = useMemo<WorkGroupViewState>( () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), [routeThreadKey], ); 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); 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 {Lines 1–500 of 3551. Load more
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<LegendListRef | null>; timestampFormat: TimestampFormat; routeThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; 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<TimelineRowSharedState>(null!); const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); interface WorkGroupViewState { scrollPositions: Map<string, WorkGroupScrollAnchor>; expandedEntries: Set<string>; } const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; onToggleEntry: (collapsed: boolean) => void; } | null>(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> ); } function TimelineListFooter({ composerInset }: { readonly composerInset: number }) { return ( <div aria-hidden> <div style={{ height: composerInset }} /> <div className="h-3 sm:h-4" /> </div> ); } const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; 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<LegendListRef | null>; timelineEntries: ReturnType<typeof deriveTimelineEntries>; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; 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<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; /** * 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<ReadonlySet<TurnId>>(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<ReadonlySet<string>>(new Set()); // Scroll/disclosure state outlives virtualized rows, but never the current thread. const workGroupViewState = useMemo<WorkGroupViewState>( () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), [routeThreadKey], ); 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); 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 {