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 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, 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>; 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(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; // --------------------------------------------------------------------------- // Props (public API) // --------------------------------------------------------------------------- interface MessagesTimelineProps { isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; turnDiffSummaryByAssistantMessageId: Map; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; 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>; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => 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, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); 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 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?.(); if (state) { onIsAtEndChange(state.isAtEnd); } }, [listRef, onIsAtEndChange]); const sharedState = useMemo( () => ({ 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( () => ({ 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 }) => (
), [], ); if (rows.length === 0 && !isWorking) { return (

Send a message to start the conversation.

); } return ( ref={listRef} data={rows} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainVisibleContentPosition={{ data: true, size: false, }} onScroll={handleScroll} className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> ); }); function keyExtractor(item: MessagesTimelineRow) { return item.id; } function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return (
{row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null}
); }); function UserTimelineRow({ row }: { row: Extract }) { 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 elementContexts = [ ...displayedUserMessage.elementContexts, ...elementContextState.contexts, ]; const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
{regularImages.length > 0 && (
{regularImages.map((image: NonNullable[number]) => (
{image.previewUrl ? ( ) : (
{image.name}
)}
))}
)} {previewAnnotations.map((annotation, index) => ( ))} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( ))}
) : null}
}> {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
{canRevertAgentWork && } {displayedUserMessage.copyText && ( )}
); } function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); return ( ctx.onRevertUserMessage(messageId)} aria-label="Revert to this message" /> } > Revert to this message ); } function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
); } function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); return ( <>
{row.showAssistantMeta ? (
{!row.message.streaming && ( } > {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )}
) : null}
); } function AssistantCopyButton({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, streaming: row.assistantCopyStreaming, }); if (!assistantCopyState.visible) { return null; } return ; } function ProposedPlanTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); return (
); } function WorkingTimelineRow({ row }: { row: Extract }) { return (
{row.createdAt ? ( <> Working for ) : ( "Working..." )}
); } // --------------------------------------------------------------------------- // Self-ticking labels — update their own text nodes so elapsed-time display // does not create a React commit every second while a response is streaming. // --------------------------------------------------------------------------- /** Live "Working for Xs" label. */ function WorkingTimer({ createdAt }: { createdAt: string }) { const textRef = useRef(null); const initialText = formatWorkingTimerNow(createdAt); useEffect(() => { const updateText = () => { if (textRef.current) { textRef.current.textContent = formatWorkingTimerNow(createdAt); } }; updateText(); const id = setInterval(updateText, 1000); return () => clearInterval(id); }, [createdAt]); return ( {initialText} ); } // --------------------------------------------------------------------------- // Extracted row sections — own their state / store subscriptions so changes // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, }: { groupedEntries: Extract["groupedEntries"]; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), [groupedEntries], ); const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); const groupLabel = onlyToolEntries ? nonEmptyEntries.length === 1 ? "1 tool call" : `${nonEmptyEntries.length} tool calls` : "Work Log"; if (nonEmptyEntries.length === 0) return null; return (
{!onlyToolEntries && (

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( ))}
); }); function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); const labelNoun = row.onlyToolEntries ? "tool call" : "log entry"; return ( ); } /** Subscribes directly to the UI state store for expand/collapse state, * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ turnSummary, routeThreadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary | undefined; routeThreadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; return ( ); }); /** Inner component that only mounts when there are actual changed files, * so the store subscription is unconditional (no hooks after early return). */ function AssistantChangedFilesSectionInner({ turnSummary, checkpointFiles, routeThreadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary; checkpointFiles: TurnDiffSummary["files"]; routeThreadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const allDirectoriesExpanded = useUiStateStore( (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); const changedFileCountLabel = String(checkpointFiles.length); return (

Changed files ({changedFileCountLabel}) {hasNonZeroStat(summaryStat) && ( <> • )}

); } // --------------------------------------------------------------------------- // Leaf components // --------------------------------------------------------------------------- const UserMessageTerminalContextInlineLabel = memo( function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { const tooltipText = props.context.body.length > 0 ? `${props.context.header}\n${props.context.body}` : props.context.header; return ; }, ); const UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: { context: ParsedElementContextEntry; }) { const tooltipText = props.context.body ? `${props.context.header}\n${props.context.body}` : props.context.header; return ( {props.context.header} } /> {tooltipText} ); }); function UserMessagePreviewAnnotationCard(props: { annotation: ParsedPreviewAnnotation; image: NonNullable[number] | null; }) { const ctx = use(TimelineRowCtx); return (
{props.image?.previewUrl ? ( ) : null}
{props.annotation.comment ? (
{props.annotation.comment}
) : null}
{props.annotation.targetSummary ? ( {props.annotation.targetSummary} ) : null} {props.annotation.styleChanges.length > 0 ? ( {props.annotation.styleChanges.length} ) : null}
); } const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; function shouldCollapseUserMessage(text: string): boolean { if (text.trim().length === 0) { return false; } return ( text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES ); } const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; return (
{hasVisibleBody ? (
) : null} {canCollapse || props.footer ? (
{canCollapse ? ( ) : null} {props.footer ? (
{props.footer}
) : null}
) : null}
); }); const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; markdownCwd: string | undefined; }) { const ctx = use(TimelineRowCtx); const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); const trailingWhitespace = /\s+$/.exec(textWithoutLeadingWhitespace)?.[0] ?? ""; const content = textWithoutLeadingWhitespace.slice( 0, textWithoutLeadingWhitespace.length - trailingWhitespace.length, ); return ( {leadingWhitespace ? : null} {content ? ( ) : null} {trailingWhitespace ? : null} ); }; const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { return (
{reviewCommentSegments.map((segment) => segment.kind === "text" ? ( segment.text.trim().length > 0 ? (
) : null ) : ( ), )}
); } if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, props.terminalContexts, ); const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); const inlineNodes: ReactNode[] = []; if (hasEmbeddedInlineLabels) { let cursor = 0; for (const context of props.terminalContexts) { const label = formatInlineTerminalContextLabel(context.header); const matchIndex = props.text.indexOf(label, cursor); if (matchIndex === -1) { inlineNodes.length = 0; break; } if (matchIndex > cursor) { inlineNodes.push( renderInlineMarkdownSegment( props.text.slice(cursor, matchIndex), `user-terminal-context-inline-before:${context.header}:${cursor}`, ), ); } inlineNodes.push( , ); cursor = matchIndex + label.length; } if (inlineNodes.length > 0) { if (cursor < props.text.length) { inlineNodes.push( renderInlineMarkdownSegment( props.text.slice(cursor), `user-message-terminal-context-inline-rest:${cursor}`, ), ); } return (
{inlineNodes}
); } } for (const context of props.terminalContexts) { inlineNodes.push( , ); inlineNodes.push( , ); } if (props.text.length > 0) { inlineNodes.push( , ); } else if (inlinePrefix.length === 0) { return null; } return (
{inlineNodes}
); } if (props.text.length === 0) { return null; } return ( ); }); function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); const fenceLanguage = comment.fenceLanguage ?? "diff"; const renderablePatch = getRenderablePatch( buildReviewCommentRenderablePatch(comment), `review-comment:${comment.id}`, ); return (
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
{comment.sectionTitle} · {comment.rangeLabel}
{comment.text.length > 0 && (
)} {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && ( )} {renderablePatch?.kind === "files" && renderablePatch.files.map((fileDiff) => ( ))} {renderablePatch?.kind === "raw" && (
          {renderablePatch.text}
        
)}
); } // --------------------------------------------------------------------------- // Structural sharing — reuse old row references when data hasn't changed // so LegendList (and React) can skip re-rendering unchanged items. // --------------------------------------------------------------------------- /** Returns a structurally-shared copy of `rows`: for each row whose content * hasn't changed since last call, the previous object reference is reused. */ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { const prevState = useRef({ byId: new Map(), result: [], }); return useMemo(() => { const nextState = computeStableMessagesTimelineRows(rows, prevState.current); prevState.current = nextState; return nextState.result; }, [rows]); } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { return null; } const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } const hours = Math.floor(elapsedSeconds / 3600); const minutes = Math.floor((elapsedSeconds % 3600) / 60); const seconds = elapsedSeconds % 60; if (hours > 0) { return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } function formatWorkingTimerNow(startIso: string): string { return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; } type WorkEntryIconName = | "bot" | "check" | "circle-alert" | "eye" | "globe" | "hammer" | "message-circle" | "square-pen" | "terminal" | "wrench" | "x" | "zap"; function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { case "bot": return ; case "check": return ; case "circle-alert": return ; case "eye": return ; case "globe": return ; case "hammer": return ; case "message-circle": return ; case "square-pen": return ; case "terminal": return ; case "wrench": return ; case "x": return ; case "zap": return ; } } function workToneIcon(tone: TimelineWorkEntry["tone"]): { iconName: WorkEntryIconName; className: string; } { if (tone === "error") { return { iconName: "circle-alert", className: "text-foreground/92", }; } if (tone === "thinking") { return { iconName: "bot", className: "text-foreground/92", }; } if (tone === "info") { return { iconName: "check", className: "text-muted-foreground", }; } return { iconName: "zap", className: "text-foreground/92", }; } function workEntryPreview( workEntry: Pick, workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 ? displayPath : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } function workEntryRawCommand( workEntry: Pick, ): string | null { const rawCommand = workEntry.rawCommand?.trim(); if (!rawCommand || !workEntry.command) { return null; } return rawCommand === workEntry.command.trim() ? null : rawCommand; } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { const blocks: string[] = []; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { blocks.push(raw.trim()); } else if (workEntry.command?.trim()) { blocks.push(workEntry.command.trim()); } if (workEntry.detail?.trim()) { blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { blocks.push( changedFiles .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) .join("\n"), ); } return blocks.length > 0 ? blocks.join("\n\n") : null; } function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { return "message-circle"; } if (workEntry.requestKind === "command") return "terminal"; if (workEntry.requestKind === "file-read") return "eye"; if (workEntry.requestKind === "file-change") return "square-pen"; if (workEntry.itemType === "command_execution" || workEntry.command) { return "terminal"; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return "square-pen"; } if (workEntry.itemType === "web_search") return "globe"; if (workEntry.itemType === "image_view") return "eye"; switch (workEntry.itemType) { case "mcp_tool_call": return "wrench"; case "dynamic_tool_call": case "collab_agent_tool_call": return "hammer"; } return workToneIcon(workEntry.tone).iconName; } function capitalizePhrase(value: string): string { const trimmed = value.trim(); if (trimmed.length === 0) { return value; } return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = rawPreview && normalizeCompactToolLabel(rawPreview).toLowerCase() === normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( "flex size-5 shrink-0 items-center justify-center", showWarningIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator ? "text-muted-foreground/65" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" : "font-medium text-foreground/82"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = workEntryIndicatesToolSuccess(workEntry) || (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setExpanded((v) => !v); } }, } : {}; return (

{heading} {preview && ( {preview} )}

{canExpand ? ( ) : null} {showFailedIndicator ? ( } > Failed ) : showSuccessIndicator ? ( } > Completed ) : showNeutralIndicator ? ( } > Empty ) : null}
{expanded && canExpand && expandedBody ? (
            {expandedBody}
          
) : null}
); });