import { type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId,} from "@t3tools/contracts";import { parseScopedThreadKey } from "@t3tools/client-runtime";import { createContext, Fragment, memo, use, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode,} from "react";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { FileDiff } from "@pierre/diffs/react";import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike,} from "../../session-logic";import { type TurnDiffSummary } from "../../types";import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath,} from "../../lib/diffRendering";import ChatMarkdown from "../ChatMarkdown";import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, 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, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, type StableMessagesTimelineRowsState, type MessagesTimelineRow, 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;} 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; 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">>; onIsAtEndChange: (isAtEnd: boolean) => void;} // ---------------------------------------------------------------------------// MessagesTimeline — list owner// --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, listRef, timelineEntries, latestTurn, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange,}: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); // Toggling a fold inserts/removes rows between the fold row and the final // message — everything above the trigger is unchanged, so the trigger stays // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do // exactly that (pin the bottom content when row data changes while scrolled // to the end), yanking the trigger out of view. Suppress it for the frames // in which the toggle's data change and item measurements settle. const [foldToggleSettling, setFoldToggleSettling] = useState(false); const onToggleTurnFold = useCallback((turnId: TurnId) => { setFoldToggleSettling(true); setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return next; }); }, []); useEffect(() => { if (!foldToggleSettling) { return; } let secondFrameId: number | null = null; const firstFrameId = window.requestAnimationFrame(() => { secondFrameId = window.requestAnimationFrame(() => { setFoldToggleSettling(false); }); }); return () => { window.cancelAnimationFrame(firstFrameId); if (secondFrameId !== null) { window.cancelAnimationFrame(secondFrameId); } }; }, [foldToggleSettling]); // 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, expandedTurnIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, latestTurn, expandedTurnIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, ], ); const rows = useStableRows(rawRows); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); if (state) { onIsAtEndChange(state.isAtEnd); } }, [listRef, onIsAtEndChange]); const previousRowCountRef = useRef(rows.length); useEffect(() => { const previousRowCount = previousRowCountRef.current; previousRowCountRef.current = rows.length; if (previousRowCount > 0 || rows.length === 0) { return; } onIsAtEndChange(true); const frameId = window.requestAnimationFrame(() => { void listRef.current?.scrollToEnd?.({ animated: false }); }); return () => { window.cancelAnimationFrame(frameId); }; }, [listRef, onIsAtEndChange, rows.length]); const sharedState = useMemo<TimelineRowSharedState>( () => ({ timestampFormat, routeThreadKey, threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, ], ); 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}> <LegendList<MessagesTimelineRow> ref={listRef} data={rows} keyExtractor={keyExtractor} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd maintainScrollAtEnd={!foldToggleSettling} maintainScrollAtEndThreshold={0.1} maintainVisibleContentPosition onScroll={handleScroll} className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> </TimelineRowActivityCtx> </TimelineRowCtx> );}); function keyExtractor(item: MessagesTimelineRow) { return item.id;} // ---------------------------------------------------------------------------// TimelineRowContent — the actual row component// --------------------------------------------------------------------------- type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return ( <div className={cn( // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, )} data-timeline-row-id={row.id} data-timeline-row-kind={row.kind} data-message-id={row.kind === "message" ? row.message.id : undefined} data-message-role={row.kind === "message" ? row.message.role : undefined} > {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} {row.kind === "message" && row.message.role === "assistant" ? ( <AssistantTimelineRow row={row} /> ) : null} {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} </div> );}); function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; let visibleText = displayedUserMessage.visibleText; while (true) { const extracted = extractTrailingPreviewAnnotation(visibleText); if (!extracted.annotation) break; previewAnnotations.unshift(extracted.annotation); visibleText = extracted.promptText; } const elementContextState = extractTrailingElementContexts(visibleText); const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return ( <div className="group flex flex-col items-end gap-1"> <div className="relative max-w-[80%] rounded-2xl border border-border bg-secondary p-3"> {regularImages.length > 0 && ( <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( <div key={image.id} className="overflow-hidden rounded-lg border border-border/80 bg-background/70" > {image.previewUrl ? ( <button type="button" className="h-full w-full cursor-zoom-in" aria-label={`Preview ${image.name}`} onClick={() => { const preview = buildExpandedImagePreview(regularImages, image.id); if (!preview) return; ctx.onImageExpand(preview); }} > <img src={image.previewUrl} alt={image.name} className="block h-auto max-h-[220px] w-full object-cover" /> </button> ) : ( <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> {image.name} </div> )} </div> ))} </div> )} {previewAnnotations.map((annotation, index) => ( <UserMessagePreviewAnnotationCard key={annotation.id} annotation={annotation} image={previewImages[index] ?? null} /> ))} {elementContextState.contexts.length > 0 ? ( <div className="mb-2 flex flex-wrap gap-1.5"> {elementContextState.contexts.map((context) => (Lines 1–500 of 1705. Load more
import { type EnvironmentId, type MessageId, type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime"; import { createContext, Fragment, memo, use, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, 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, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, type StableMessagesTimelineRowsState, type MessagesTimelineRow, 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; } 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; 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">>; onIsAtEndChange: (isAtEnd: boolean) => void; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, listRef, timelineEntries, latestTurn, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); // Toggling a fold inserts/removes rows between the fold row and the final // message — everything above the trigger is unchanged, so the trigger stays // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do // exactly that (pin the bottom content when row data changes while scrolled // to the end), yanking the trigger out of view. Suppress it for the frames // in which the toggle's data change and item measurements settle. const [foldToggleSettling, setFoldToggleSettling] = useState(false); const onToggleTurnFold = useCallback((turnId: TurnId) => { setFoldToggleSettling(true); setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { next.delete(turnId); } else { next.add(turnId); } return next; }); }, []); useEffect(() => { if (!foldToggleSettling) { return; } let secondFrameId: number | null = null; const firstFrameId = window.requestAnimationFrame(() => { secondFrameId = window.requestAnimationFrame(() => { setFoldToggleSettling(false); }); }); return () => { window.cancelAnimationFrame(firstFrameId); if (secondFrameId !== null) { window.cancelAnimationFrame(secondFrameId); } }; }, [foldToggleSettling]); // 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, expandedTurnIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, latestTurn, expandedTurnIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, ], ); const rows = useStableRows(rawRows); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); if (state) { onIsAtEndChange(state.isAtEnd); } }, [listRef, onIsAtEndChange]); const previousRowCountRef = useRef(rows.length); useEffect(() => { const previousRowCount = previousRowCountRef.current; previousRowCountRef.current = rows.length; if (previousRowCount > 0 || rows.length === 0) { return; } onIsAtEndChange(true); const frameId = window.requestAnimationFrame(() => { void listRef.current?.scrollToEnd?.({ animated: false }); }); return () => { window.cancelAnimationFrame(frameId); }; }, [listRef, onIsAtEndChange, rows.length]); const sharedState = useMemo<TimelineRowSharedState>( () => ({ timestampFormat, routeThreadKey, threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, ], ); 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}> <LegendList<MessagesTimelineRow> ref={listRef} data={rows} keyExtractor={keyExtractor} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd maintainScrollAtEnd={!foldToggleSettling} maintainScrollAtEndThreshold={0.1} maintainVisibleContentPosition onScroll={handleScroll} className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> </TimelineRowActivityCtx> </TimelineRowCtx> ); }); function keyExtractor(item: MessagesTimelineRow) { return item.id; } // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return ( <div className={cn( // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, )} data-timeline-row-id={row.id} data-timeline-row-kind={row.kind} data-message-id={row.kind === "message" ? row.message.id : undefined} data-message-role={row.kind === "message" ? row.message.role : undefined} > {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} {row.kind === "message" && row.message.role === "assistant" ? ( <AssistantTimelineRow row={row} /> ) : null} {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} </div> ); }); function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; let visibleText = displayedUserMessage.visibleText; while (true) { const extracted = extractTrailingPreviewAnnotation(visibleText); if (!extracted.annotation) break; previewAnnotations.unshift(extracted.annotation); visibleText = extracted.promptText; } const elementContextState = extractTrailingElementContexts(visibleText); const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return ( <div className="group flex flex-col items-end gap-1"> <div className="relative max-w-[80%] rounded-2xl border border-border bg-secondary p-3"> {regularImages.length > 0 && ( <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> {regularImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( <div key={image.id} className="overflow-hidden rounded-lg border border-border/80 bg-background/70" > {image.previewUrl ? ( <button type="button" className="h-full w-full cursor-zoom-in" aria-label={`Preview ${image.name}`} onClick={() => { const preview = buildExpandedImagePreview(regularImages, image.id); if (!preview) return; ctx.onImageExpand(preview); }} > <img src={image.previewUrl} alt={image.name} className="block h-auto max-h-[220px] w-full object-cover" /> </button> ) : ( <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> {image.name} </div> )} </div> ))} </div> )} {previewAnnotations.map((annotation, index) => ( <UserMessagePreviewAnnotationCard key={annotation.id} annotation={annotation} image={previewImages[index] ?? null} /> ))} {elementContextState.contexts.length > 0 ? ( <div className="mb-2 flex flex-wrap gap-1.5"> {elementContextState.contexts.map((context) => (