import { type EnvironmentId, type MessageId, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; import { createContext, memo, use, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, formatElapsed } 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, CircleAlertIcon, EyeIcon, GlobeIcon, HammerIcon, type LucideIcon, SquarePenIcon, TerminalIcon, Undo2Icon, WrenchIcon, 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, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, 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; 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; } interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: 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; activeTurnId?: TurnId | null; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | 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>; onIsAtEndChange: (isAtEnd: boolean) => void; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnId, activeTurnStartedAt, listRef, timelineEntries, completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, }: MessagesTimelineProps) { const rawRows = useMemo( () => deriveMessagesTimelineRows({ timelineEntries, completionDividerBeforeEntryId, completionSummary, isWorking, activeTurnInProgress, activeTurnId: activeTurnId ?? null, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, }), [ timelineEntries, completionDividerBeforeEntryId, completionSummary, isWorking, activeTurnInProgress, activeTurnId, 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( () => ({ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, }), [ timestampFormat, routeThreadKey, markdownCwd, resolvedTheme, workspaceRoot, skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, onOpenTurnDiff, ], ); const activityState = useMemo( () => ({ isWorking, isRevertingCheckpoint, }), [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} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd maintainScrollAtEnd maintainScrollAtEndThreshold={0.1} maintainVisibleContentPosition onScroll={handleScroll} className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> ); }); function keyExtractor(item: MessagesTimelineRow) { return item.id; } // --------------------------------------------------------------------------- // 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 === "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 canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
{userImages.length > 0 && (
{userImages.map((image: NonNullable[number]) => (
{image.previewUrl ? ( ) : (
{image.name}
)}
))}
)}
{displayedUserMessage.copyText && ( )} {canRevertAgentWork && }

{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}

} />
); } function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); return ( ); } function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); return ( <> {row.showCompletionDivider && ( )}

{row.message.streaming ? ( ) : ( formatMessageMeta( row.message.createdAt, formatElapsed(row.durationStart, row.message.completedAt), ctx.timestampFormat, ) )}

); } function AssistantCompletionDivider({ completionSummary }: { completionSummary: string | null }) { return (
{completionSummary ? `Response • ${completionSummary}` : "Response"}
); } 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}; } /** Live timestamp + elapsed duration for a streaming assistant message. */ function LiveMessageMeta({ createdAt, durationStart, timestampFormat, }: { createdAt: string; durationStart: string | null | undefined; timestampFormat: TimestampFormat; }) { const textRef = useRef(null); const initialText = formatLiveMessageMetaNow(createdAt, durationStart, timestampFormat); useEffect(() => { const updateText = () => { if (textRef.current) { textRef.current.textContent = formatLiveMessageMetaNow( createdAt, durationStart, timestampFormat, ); } }; updateText(); if (!durationStart) { return; } const id = setInterval(updateText, 1000); return () => clearInterval(id); }, [createdAt, durationStart, timestampFormat]); return {initialText}; } // --------------------------------------------------------------------------- // Extracted row sections — own their state / store subscriptions so changes // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- /** Owns its own expand/collapse state so toggling re-renders only this row. * State resets on unmount which is fine — work groups start collapsed. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, }: { groupedEntries: Extract["groupedEntries"]; }) { const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const showHeader = hasOverflow || !onlyToolEntries; const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; return (
{showHeader && (

{groupLabel} ({groupedEntries.length})

{hasOverflow && ( )}
)}
{visibleEntries.map((workEntry) => ( ))}
); }); /** 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 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>; 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>; }) { 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( , ); } inlineNodes.push( , ); cursor = matchIndex + label.length; } if (inlineNodes.length > 0) { if (cursor < props.text.length) { inlineNodes.push( , ); } 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 renderablePatch = getRenderablePatch( buildReviewCommentRenderablePatch(comment), `review-comment:${comment.id}`, ); return (
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
{comment.sectionTitle} · {comment.rangeLabel}
{comment.text.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"; } function formatLiveMessageMetaNow( createdAt: string, durationStart: string | null | undefined, timestampFormat: TimestampFormat, ): string { const elapsed = durationStart ? formatElapsed(durationStart, new Date().toISOString()) : null; return formatMessageMeta(createdAt, elapsed, timestampFormat); } function formatMessageMeta( createdAt: string, duration: string | null, timestampFormat: TimestampFormat, ): string { if (!duration) return formatTimestamp(createdAt, timestampFormat); return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; } { if (tone === "error") { return { icon: CircleAlertIcon, className: "text-foreground/92", }; } if (tone === "thinking") { return { icon: BotIcon, className: "text-foreground/92", }; } if (tone === "info") { return { icon: CheckIcon, className: "text-foreground/92", }; } return { icon: ZapIcon, className: "text-foreground/92", }; } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } 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 workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; if (workEntry.requestKind === "file-change") return SquarePenIcon; if (workEntry.itemType === "command_execution" || workEntry.command) { return TerminalIcon; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return SquarePenIcon; } if (workEntry.itemType === "web_search") return GlobeIcon; if (workEntry.itemType === "image_view") return EyeIcon; switch (workEntry.itemType) { case "mcp_tool_call": return WrenchIcon; case "dynamic_tool_call": case "collab_agent_tool_call": return HammerIcon; } return workToneIcon(workEntry.tone).icon; } 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 SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = rawPreview && normalizeCompactToolLabel(rawPreview).toLowerCase() === normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; return (
{rawCommand ? (

{heading} {preview && ( {" "} - {preview} } />

{rawCommand}
)}

) : (

{heading} {preview && - {preview}}

{displayText}

)}
{hasChangedFiles && !previewIsChangedFiles && (
{workEntry.changedFiles?.slice(0, 4).map((filePath) => { const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); return ( {displayPath} ); })} {(workEntry.changedFiles?.length ?? 0) > 4 && ( +{(workEntry.changedFiles?.length ?? 0) - 4} )}
)}
); });