import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { MarkdownFindContext } from "./markdownFindContext"; import { useChatCanvas } from "./ChatCanvasContext"; import { WorkLogBlock } from "./WorkLog"; import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { deriveTimelineMinimapItems } from "./timelineMinimapItems"; import { type AssistantCitation, type EnvironmentId, type MessageId, type OrchestrationV2TurnItem, type RunAttemptId, type ServerProviderSkill, type RunId, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { observeResize } from "~/lib/observeResize"; import { useStableCallback } from "~/hooks/useStableCallback"; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const EMPTY_PENDING_APPROVALS: ReadonlyArray = []; const EMPTY_RESPONDING_REQUEST_IDS: TimelineRowSharedState["respondingRequestIds"] = []; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { memo, use, useContext, useLayoutEffect, useEffect, useEffectEvent, useRef, useState, type ReactNode, type SyntheticEvent, } from "react"; import { LegendList, type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; import { type PendingApproval, type TimelineEntry } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; import { ArrowRightIcon, BotIcon, CircleAlertIcon, MessagesSquareIcon, Minimize2Icon, } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { Skeleton } from "../ui/skeleton"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { CHAT_TIMELINE_ANCHOR_OFFSET, readTimelinePosition, type RememberedTimelinePosition, rememberTimelinePosition, } from "./timelineScrollAnchoring"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { type AssistantCitationRequest } from "./AssistantCitationSource"; import { useAssistantCitationTarget } from "./useAssistantCitationTarget"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { resolveCitationPhase, type CitedHistory } from "./citationProcedure"; import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, isTimelineMinimapRowVisible, resolveTimelineMinimapVisibleRange, resolveTimelineMinimapHitStripWidth, timelineTurnFoldRunIdsByEntryId, type MessagesTimelineRow, type TimelineLatestRun, } from "./MessagesTimeline.logic"; import { type ThreadFindMatch } from "./threadFind"; import { useThreadFindNavigation } from "./useThreadFindNavigation"; import { TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { type HandoffTimelineRun } from "./V2LifecycleRow"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { forwardWheelToTimeline } from "./timelineMinimapWheel"; import { TimelineMinimap } from "./TimelineMinimap"; import { type TimelineRowSharedState, type TimelineRowActivityState, TimelineRowCtx, TimelineRowActivityCtx, type WorkGroupViewState, type TimelineRow, } from "./timelineRowContext"; import { TimelineRowTimestamp, ActivityShimmerOverlay } from "./TimelineRowParts"; import { WorkGroupSection, WorkingTimelineRow, ThinkingTimelineRow, LiveWorkEntryTimelineRow, WorkGroupToggleTimelineRow, } from "./TimelineWorkGroupRows"; import { UserMessageRow } from "./TimelineUserMessageRow"; import { AssistantTimelineRow, AssistantMetaTimelineRow, ProposedPlanTimelineRow, HtmlRenderTimelineRow, McpAppTimelineRow, } from "./TimelineAssistantRows"; import { V2EventTimelineRow } from "./TimelineV2EventRow"; import { startTimelinePositionRestore } from "./useTimelinePositionRestore"; import { attachTimelineViewport, nextListOpening, type TimelineListOpening, type TimelineViewport, } from "./timelineViewport"; import { useThreadFindPositionReader } from "./useThreadFindPositionReader"; import { ThreadActivityAnnouncer } from "./ThreadActivityAnnouncer"; import { useStableHandoffRuns, useIncrementalTimelineRows, useStableRows } from "./useTimelineRows"; function timelineListStateFor(identity: string): { readonly identity: string; readonly rememberedPosition: RememberedTimelinePosition | undefined; readonly workGroupViewState: WorkGroupViewState; } { const rememberedPosition = readTimelinePosition(identity); return { identity, rememberedPosition, workGroupViewState: rememberedPosition?.disclosures?.workGroupState ?? { scrollPositions: new Map(), expandedEntries: new Set(), }, }; } const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER = (
); function TimelineListFooter({ composerInset, onGrow, children, }: { readonly composerInset: number; readonly onGrow: (() => void) | undefined; readonly children?: ReactNode; }) { const contentRef = useRef(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports growth of the footer's content, which the list does not follow useLayoutEffect(() => { const content = contentRef.current; if (!content || !onGrow) return; let height = content.offsetHeight; return observeResize(content, () => { const next = content.offsetHeight; if (next > height) onGrow(); height = next; }); }, [onGrow]); return (
{children}
); } const EMPTY_TIMELINE_SKILLS: ReadonlyArray = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { dataChange: true, footerLayout: false, itemLayout: true, layout: true, }, } as const satisfies MaintainScrollAtEndOptions; const EMPTY_RUN_IDS: ReadonlySet = new Set(); const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { ...TIMELINE_MAINTAIN_SCROLL_AT_END, animated: true, } as const satisfies MaintainScrollAtEndOptions; const CITED_HISTORY_LOADING: CitedHistory = { _tag: "loading" }; export interface MessagesTimelineHistoryControls { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; readonly onLoadEarlier: (throughEntryId?: string) => void; } interface MessagesTimelineProps { pendingApprovals?: ReadonlyArray; heldApprovalRequestIds?: ReadonlySet | undefined; respondingRequestIds?: TimelineRowSharedState["respondingRequestIds"]; canOperateThread?: boolean; onRespondToApproval?: TimelineRowSharedState["onRespondToApproval"]; citationRequest?: AssistantCitationRequest | null; citationHistory?: CitedHistory; onCiteAssistantText?: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, ) => boolean; isWorking: boolean; runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; onCancelWorktreeSetup?: () => void; retryableWorkspacePreparationRunIds?: ReadonlySet; onRetryWorkspacePreparation?: (runId: RunId) => void; onWorktreeSetupWorkLocally?: () => void; onOpenWorktreeSetupTerminal?: (terminalId: string) => void; isPreparingWorktree?: boolean; isCompacting?: boolean; awaitingUser?: "approval" | "input" | null; /** Which request is waiting, so a second one of the same kind is announced too. */ awaitingRequestId?: string | null; footer?: ReactNode; listRef: React.RefObject; timelineEntries: ReadonlyArray; latestRun: TimelineLatestRun | null; /** The run the screen reader announcer follows; see announcedRun. */ announcedRun?: { readonly runId: RunId; readonly status: TimelineLatestRun["status"] } | null; /** See isDetailBehindShell. */ detailBehindShell?: boolean; runningRunId?: RunId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; onOpenToolDiff?: (target: ToolCallDiffTarget) => void; onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; turnItemSource?: TimelineRowSharedState["turnItemSource"]; /** * The thread this one is a subagent or side chat of; `open` is absent when it cannot be * opened from here. */ parentThreadLink?: { readonly title: string; /** A side chat's inherited rows collapse into this link. */ readonly sideChat?: boolean; /** Where it runs, when that is another environment. */ readonly environmentLabel?: string; readonly open?: () => void; } | null; workflowOpening?: WorkflowOpening | null; onForkFromRun?: (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId; }) => Promise; supportsConversationRollback: boolean; threadStartCheckpointReady?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; onSendAppMessage?: (text: string) => Promise; onRunShellCommand?: (command: string) => 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; runs: ReadonlyArray; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; /** Disable while reading history to prevent LegendList re-pinning near the bottom. */ liveFollowEnabled: boolean; /** Receives the list's viewport when it mounts; the returned cleanup runs when it goes away. */ onViewportAttach?: (viewport: TimelineViewport) => () => void; /** The footer's content grew, such as a new queued turn or status line. */ onFooterGrow?: () => void; findOpen?: boolean; hideEmptyPlaceholder?: boolean; /** Composer space to center the empty state against; held steady through collapse. */ emptyPlaceholderInset: number; topFadeEnabled?: boolean; historyControls?: MessagesTimelineHistoryControls | undefined; findExpanded?: boolean; findQuery?: string; activeFindMatch?: ThreadFindMatch | null; findNavigationId?: number; } export function MessagesTimeline(props: MessagesTimelineProps) { const find = useContext(ThreadFindTimelineContext); const findOpen = find?.findOpen ?? props.findOpen; return ( ); } const ConversationTimeline = memo(function ConversationTimeline({ pendingApprovals = EMPTY_PENDING_APPROVALS, heldApprovalRequestIds, respondingRequestIds = EMPTY_RESPONDING_REQUEST_IDS, canOperateThread = false, onRespondToApproval, citationRequest = null, citationHistory = CITED_HISTORY_LOADING, onCiteAssistantText, isWorking, runlessWorkActive = false, activeTurnStartedAt = null, worktreeSetup = null, footer = null, onCancelWorktreeSetup, retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS, onRetryWorkspacePreparation, onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, isPreparingWorktree = false, isCompacting = false, awaitingUser = null, awaitingRequestId = null, listRef, timelineEntries, findOpen = false, findExpanded = false, findQuery = "", activeFindMatch = null, findNavigationId = 0, latestRun, announcedRun = null, detailBehindShell = false, runningRunId = null, turnDiffSummaries, routeThreadKey, displayThreadKey, announceTurnStatus = true, onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, turnItemSource, parentThreadLink = null, workflowOpening = null, onForkFromRun, supportsConversationRollback, threadStartCheckpointReady = false, availableRevertTurnCounts, onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, onSendAppMessage, onRunShellCommand, isRevertingCheckpoint, onImageExpand, onFileOpen = NOOP_OPEN_ATTACHMENT, onFileDownload = NOOP_OPEN_ATTACHMENT, activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, runs: runsProp, anchorMessageId, onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, liveFollowEnabled, onViewportAttach, onFooterGrow, hideEmptyPlaceholder = false, emptyPlaceholderInset, topFadeEnabled = false, historyControls, }: MessagesTimelineProps) { const listIdentityKey = displayThreadKey ?? routeThreadKey; // Snapshot restore positions on identity changes; ongoing scroll writes must not move them. // Find open at the opening is part of the snapshot: opening find later // announces its own navigation (threadFindStore's announceOpening). const [listState, setListState] = useState(() => ({ ...timelineListStateFor(listIdentityKey), findOpenAtOpening: findOpen, })); const [expandedRunIds, setExpandedRunIds] = useState>( () => listState.rememberedPosition?.disclosures?.runs ?? new Set(), ); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>( () => listState.rememberedPosition?.disclosures?.workGroups ?? new Set(), ); const [expandedAttemptIds, setExpandedAttemptIds] = useState>( () => listState.rememberedPosition?.disclosures?.attempts ?? new Set(), ); // A list that opens on a thread with find open takes the opening step below, // so the restore runs and releases follow once for find. const [positionedThreadKey, setPositionedThreadKey] = useState(() => listState.rememberedPosition?.atEnd === false || findOpen ? null : listIdentityKey, ); const restoringThreadPosition = positionedThreadKey !== listIdentityKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); const [playingAudioRows, setPlayingAudioRows] = useState>( () => new Map(), ); const [fullscreenAppRowId, setFullscreenAppRowId] = useState(null); let paintedExpandedRunIds = expandedRunIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedAttemptIds = expandedAttemptIds; const identityChanged = listState.identity !== listIdentityKey; const { rememberedPosition, workGroupViewState } = identityChanged ? timelineListStateFor(listIdentityKey) : listState; if (identityChanged) { setPlayingAudioRows(new Map()); setListState({ identity: listIdentityKey, rememberedPosition, workGroupViewState, findOpenAtOpening: findOpen, }); setPositionedThreadKey(null); setSettlingListIdentity(listIdentityKey); // A viewer pinned in another thread must not keep live follow off here, // nor when that thread comes back. setFullscreenAppRowId(null); paintedExpandedRunIds = rememberedPosition?.disclosures?.runs ?? new Set(); paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set(); paintedExpandedAttemptIds = rememberedPosition?.disclosures?.attempts ?? new Set(); setExpandedRunIds(paintedExpandedRunIds); setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); setExpandedAttemptIds(paintedExpandedAttemptIds); } const citationThreadRef = parseScopedThreadKey(routeThreadKey); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const [minimapStripMap] = useState(() => new Map()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); const normalizedFindQuery = findQuery.trim(); const findActive = findExpanded; const scheduleDisclosureSettle = () => { 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; }); }); }; const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string) => { disclosureAnchorKeyRef.current = anchorKey; setDisclosureToggleSettling(true); scheduleDisclosureSettle(); }; const shouldRestoreVisibleContentPosition = (row: MessagesTimelineRow) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || row.id === disclosureAnchorKey; }; const onToggleTurnFold = (runId: RunId) => { suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`); setExpandedRunIds((existing) => { const next = new Set(existing); if (next.has(runId)) { next.delete(runId); } else { next.add(runId); } return next; }); }; const onToggleWorkGroup = (groupId: string, anchorKey: string) => { suspendEndScrollMaintenanceForDisclosure(anchorKey); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }; const onToggleAttemptFold = (attemptId: RunAttemptId) => { suspendEndScrollMaintenanceForDisclosure(`attempt-fold:${attemptId}`); setExpandedAttemptIds((existing) => { const next = new Set(existing); if (next.has(attemptId)) { next.delete(attemptId); } else { next.add(attemptId); } return next; }); }; // An interrupted run opens its fold; a newer run closes the previous run's. const [observedLatestRun, setObservedLatestRun] = useState({ identity: listIdentityKey, runId: latestRun?.runId, status: latestRun?.status, }); if ( observedLatestRun.identity !== listIdentityKey || observedLatestRun.runId !== latestRun?.runId || observedLatestRun.status !== latestRun?.status ) { setObservedLatestRun({ identity: listIdentityKey, runId: latestRun?.runId, status: latestRun?.status, }); const previousRunId = observedLatestRun.runId; if ( observedLatestRun.identity === listIdentityKey && latestRun && previousRunId !== undefined ) { if (latestRun.runId === previousRunId) { if (observedLatestRun.status === "running" && latestRun.status === "interrupted") { setExpandedRunIds((existing) => new Set(existing).add(latestRun.runId)); } } else { setExpandedRunIds((existing) => { if (!existing.has(previousRunId)) return existing; const next = new Set(existing); next.delete(previousRunId); return next; }); } } } // Imported turns and post-steer work fold under synthetic keys, not the entries' run ids. const findFoldRunIds = findActive ? timelineTurnFoldRunIdsByEntryId({ timelineEntries, latestRun, runningRunId, isWorking, runlessWorkActive, }) : null; const activeFindRunId = findActive && activeFindMatch ? findFoldRunIds?.get(activeFindMatch.entryId) : undefined; const [expandedFindRunId, setExpandedFindRunId] = useState(); if (expandedFindRunId !== activeFindRunId) { setExpandedFindRunId(activeFindRunId); if (activeFindRunId) { setExpandedRunIds((previous) => previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId), ); } } // A cited response folded inside a turn opens its fold once per activation; // the user can fold it again. Only a citation that can still land opens one. // The marker stops the O(entries) fold lookup once the entry is found, since // the href stays in the URL after the citation settles. const [citationExpandedFor, setCitationExpandedFor] = useState(null); // Back/Forward can restore an earlier activation key, so the marker clears // between activations and a revisit opens the fold again. if (!citationRequest && citationExpandedFor !== null) setCitationExpandedFor(null); const citedEntry = citationRequest && citationExpandedFor !== citationRequest.key && citationHistory._tag !== "failed" ? timelineEntries.find( (entry) => entry.kind === "message" && entry.message.role === "assistant" && entry.message.id === citationRequest.citation.messageId, ) : undefined; if (citationRequest && citedEntry) { const citedRunId = ( findFoldRunIds ?? timelineTurnFoldRunIdsByEntryId({ timelineEntries, latestRun, runningRunId, isWorking, runlessWorkActive, }) ).get(citedEntry.id); setCitationExpandedFor(citationRequest.key); if (citedRunId) { setExpandedRunIds((previous) => previous.has(citedRunId) ? previous : new Set(previous).add(citedRunId), ); } const citedAttempt = citedEntry.attempt; if (citedAttempt?.status === "superseded") { setExpandedAttemptIds((previous) => previous.has(citedAttempt.id) ? previous : new Set(previous).add(citedAttempt.id), ); } } const visibleExpandedRunIds = !activeFindRunId || paintedExpandedRunIds.has(activeFindRunId) ? paintedExpandedRunIds : new Set(paintedExpandedRunIds).add(activeFindRunId); const activeFindAttemptId = activeFindMatch ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id : undefined; const [expandedFindAttemptId, setExpandedFindAttemptId] = useState(); if (expandedFindAttemptId !== activeFindAttemptId) { setExpandedFindAttemptId(activeFindAttemptId); if (activeFindAttemptId) { setExpandedAttemptIds((previous) => previous.has(activeFindAttemptId) ? previous : new Set(previous).add(activeFindAttemptId), ); } } const visibleExpandedAttemptIds = activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId) ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; const isVisiblePendingApproval = (approval: PendingApproval) => !heldApprovalRequestIds?.has(approval.requestId); const pendingApprovalRequestIds = new Set( pendingApprovals.filter(isVisiblePendingApproval).map((approval) => approval.requestId), ); const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { timelineEntries, pendingApprovalRequestIds, latestRun, runningRunId, expandedRunIds: visibleExpandedRunIds, expandedAttemptIds: visibleExpandedAttemptIds, expandedWorkGroupIds, isWorking, runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, threadStartCheckpointReady, availableRevertTurnCounts, worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); const matchedApprovalRequestIds = new Set(); for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) { const entries = row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; for (const entry of entries) { const item = entry.projectedItem?.item; if (item?.type === "approval_request") matchedApprovalRequestIds.add(item.requestId); } } const unmatchedPendingApprovals = pendingApprovals.filter( (approval) => isVisiblePendingApproval(approval) && !matchedApprovalRequestIds.has(approval.requestId), ); // Run status/timestamps change per stream event; keep them out of shared row context. const runs = useStableHandoffRuns(runsProp); const minimapItems = deriveTimelineMinimapItems(rows); const minimapRowIndices = minimapItems.map((item) => item.rowIndex); const restoreRowIndex = restoringThreadPosition && rememberedPosition?.atEnd === false ? rows.findIndex((row) => row.id === rememberedPosition.rowId) : -1; const restoringAlwaysRender = restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined; const showsEmptyState = rows.length === 0 && unmatchedPendingApprovals.length === 0 && !isWorking && parentThreadLink === null && historyControls === undefined && footer === null; const [listOpening, setListOpening] = useState(() => nextListOpening({ mounted: false }, !showsEmptyState, listIdentityKey), ); const nextOpening = nextListOpening(listOpening, !showsEmptyState, listIdentityKey); if (nextOpening !== listOpening) setListOpening(nextOpening); const listOpenedOn = nextOpening.mounted ? nextOpening.identity : null; const viewportRef = useRef(null); const navigateTimeline = () => viewportRef.current?.navigate({ releasesFollow: true }); const restoreReady = restoringThreadPosition && rows.length > 0; // Z: syncs LegendList's scroll position with which thread it shows. The list // is not keyed per thread and reports no "new data" event, and the restore // must land before paint, after LegendList's own layout effects applied the // new data. A citation request that arrives mid-restore takes over. const { findOpenAtOpening } = identityChanged ? { findOpenAtOpening: findOpen } : listState; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores LegendList's scroll position for the shown thread before paint, after the list's own layout useLayoutEffect(() => { const list = listRef.current; const viewport = viewportRef.current; if (!restoreReady || !list || !viewport) return; return startTimelinePositionRestore({ list, viewport, listIdentityKey, listOpenedOn, rememberedPosition, citationPending: citationRequest !== null, releaseForFind: findOpenAtOpening, onPositioned: setPositionedThreadKey, }); }, [ citationRequest, findOpenAtOpening, listIdentityKey, listOpenedOn, listRef, rememberedPosition, restoreReady, viewportRef, ]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); const { target: readyCitationRequest, positioning: citationPositioning, onListLoad: onCitationListLoad, alwaysRender: citationAlwaysRender, } = useAssistantCitationTarget({ request: citationRequest, phase: citationRequest ? resolveCitationPhase({ history: citationHistory, messageId: citationRequest.citation.messageId, entries: timelineEntries, rows, }) : null, viewport: timelineViewportElement, onManualNavigation: navigateTimeline, }); const [findListReady, setFindListReady] = useState(false); const handleListLoad = () => { onCitationListLoad(); setFindListReady(true); }; const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); // Every full-screen viewer reports false when it unmounts, so a row that leaves the list // while full screen releases its pin too. const onAppFullscreenChange = (rowId: string, fullscreen: boolean) => { setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current)); }; const alwaysRenderIndices = restoringAlwaysRender?.indices ?? []; // Screen readers can only reach headings in the DOM. Keeping the first and // last messages mounted lets heading navigation wrap to either end. const firstMessageRow = rows.find(isMessageRow); const lastMessageRow = rows.findLast(isMessageRow); const alwaysRenderKeys = [ ...(firstMessageRow ? [firstMessageRow.id] : []), ...(lastMessageRow && lastMessageRow !== firstMessageRow ? [lastMessageRow.id] : []), ...(citationAlwaysRender?.keys ?? []), ...(fullscreenAppRowId === null ? [] : [fullscreenAppRowId]), ...new Set( [...playingAudioRows].filter(([audio]) => audio.isConnected).map(([, rowId]) => rowId), ), ]; const alwaysRender = alwaysRenderIndices.length === 0 && alwaysRenderKeys.length === 0 ? undefined : { ...(alwaysRenderIndices.length === 0 ? {} : { indices: alwaysRenderIndices }), ...(alwaysRenderKeys.length === 0 ? {} : { keys: alwaysRenderKeys }), }; const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { onAnchorReady(anchorMessageId, info.anchorIndex); } }; const handleAnchorSizeChanged = (size: number) => { if (anchorMessageId !== null) { onAnchorSizeChanged(anchorMessageId, size); } }; const anchoredEndSpaceConfig = resolveChatListAnchoredEndSpace( rows, anchorMessageId, (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); const anchoredEndSpace = anchoredEndSpaceConfig ? { ...anchoredEndSpaceConfig, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged, } : undefined; const maintainVisibleContentPosition = { data: true, size: true, shouldRestorePosition: shouldRestoreVisibleContentPosition, }; const refreshMinimap = useStableCallback(() => { const state = listRef.current?.getState?.(); if (restoringThreadPosition || state?.data !== rows) return; if (!state || minimapItems.length === 0) { return; } const firstBufferedIndex = state.startBuffered; const firstBufferedRow: HTMLElement | undefined = firstBufferedIndex == null ? undefined : state.elementAtIndex(firstBufferedIndex); const firstBufferedTop = firstBufferedIndex == null ? undefined : state.positionAtIndex(firstBufferedIndex); const viewport: HTMLElement | undefined = listRef.current?.getScrollableNode(); // Cached row positions exclude the header and pending scroll adjustments. const scroll = firstBufferedRow && viewport && firstBufferedTop !== undefined ? firstBufferedTop + viewport.getBoundingClientRect().top - firstBufferedRow.getBoundingClientRect().top : state.scroll; const visibleRange = resolveTimelineMinimapVisibleRange({ ...state, scroll }); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); const inView = isTimelineMinimapRowVisible( item.rowIndex, visibleRange.start, visibleRange.end, ); const next = inView ? "true" : "false"; // Avoid dirtying style state on every scroll tick for unchanged strips. if (strip && strip.dataset.inView !== next) { strip.dataset.inView = next; } } const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({ visibleStart: visibleRange.start, visibleEnd: visibleRange.end, rowIndices: minimapRowIndices, }); setMinimapCurrentIndex((current) => current === nextCurrentIndex ? current : nextCurrentIndex, ); }); const minimapRefreshFrameRef = useRef(null); const scheduleMinimapRefresh = () => { if (minimapRefreshFrameRef.current !== null) return; // LegendList recycles row containers before React commits their positions. minimapRefreshFrameRef.current = requestAnimationFrame(() => { minimapRefreshFrameRef.current = null; refreshMinimap(); }); }; // The measurement the last remember wrote, so a follow release can rewrite // it without reading render state from a listener. const lastRememberedRef = useRef<{ readonly data: unknown; readonly threadKey: string; readonly position: Parameters[1]; } | null>(null); const rememberPosition = (followingEnd: boolean) => { const state = listRef.current?.getState?.(); if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); const position = state.data.length ? resolveWorkGroupScrollAnchor(state) : undefined; if (position && isAtEnd !== undefined) { const index = state.indexByKey(position.rowId); const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); if (row && element) { const measured = { ...position, offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, atEnd: isAtEnd, disclosures: { runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, attempts: paintedExpandedAttemptIds, workGroupState: workGroupViewState, }, }; lastRememberedRef.current = { data: rows, threadKey: listIdentityKey, position: measured }; rememberTimelinePosition(listIdentityKey, measured, followingEnd); } } return isAtEnd; }; // A follow release without a scroll after it would leave the position // remembered as following. const rememberReleasedPosition = () => { const last = lastRememberedRef.current; if (last && listRef.current?.getState().data === last.data) { rememberTimelinePosition(last.threadKey, last.position, false); } }; const handleScroll = () => { if (restoringThreadPosition || listRef.current?.getState?.().data !== rows) return; const isAtEnd = rememberPosition(liveFollowEnabled && !anchoredEndSpace); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } scheduleMinimapRefresh(); }; const handleItemSizeChanged = () => { if (disclosureAnchorKeyRef.current !== null) { scheduleDisclosureSettle(); } scheduleMinimapRefresh(); }; const measureTimelineGutter = (viewportElement: HTMLElement) => { const viewportWidth = viewportElement.getBoundingClientRect().width; const contentWidth = viewportElement.querySelector("[data-timeline-root]")?.getBoundingClientRect() .width ?? viewportWidth; const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter( viewportWidth, contentWidth, ); setMinimapHasPersistentGutter((current) => current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth)); }; // Rows added, removed or rewrapped (chat width) change the content size, // which moves the scroll position, the overflow, and the content lane width. const handleContentSizeChanged = useStableCallback((laidOutData: unknown) => { // The first laid-out content of a newly shown thread ends its settling; a // frame the previous thread scheduled laid out the previous thread's rows. if (laidOutData === rows) { setSettlingListIdentity((current) => (current === listIdentityKey ? null : current)); } handleScroll(); }); // The content lane only changes width with the viewport or the chat width // setting, never with streamed content; measuring per content frame would // force a layout on every chunk. const measuredLaneWidthRef = useRef(null); const observeLaneWidth = (lane: HTMLDivElement | null) => { if (!lane) return; return observeResize(lane, (entries) => { const width = entries.at(-1)?.contentRect.width; if (width === undefined || width === measuredLaneWidthRef.current) return; measuredLaneWidthRef.current = width; const viewportElement = lane.closest("[data-assistant-citation-viewport]"); if (viewportElement) measureTimelineGutter(viewportElement); }); }; const attachTimelineViewportElement = (viewportElement: HTMLDivElement | null) => { setTimelineViewportElement(viewportElement); if (!viewportElement) return; const stopObserving = observeResize(viewportElement, () => measureTimelineGutter(viewportElement), ); return () => { stopObserving(); setTimelineViewportElement(null); }; }; // Built from props after the last hook so the compiler can memoize the row context; a map // made before a hook would rebuild it, and re-render every row, on each streamed update. const pendingApprovalsByRequestId = new Map( pendingApprovals .filter(isVisiblePendingApproval) .map((approval) => [approval.requestId, approval]), ); const sharedState: TimelineRowSharedState = { pendingApprovals: pendingApprovalsByRequestId, respondingRequestIds, canOperateThread, onRespondToApproval, citationRequest: readyCitationRequest, listRef, timestampFormat, routeThreadKey, threadRef: citationThreadRef, markdownCwd, resolvedTheme, workspaceRoot, skills, runs, activeThreadEnvironmentId, onRevertToTurnCount, onRunShellCommand, findActive, onImageExpand, onFileOpen, onUseArtifactTemplate, onSendAppMessage, onAppFullscreenChange, onFileDownload, openPullRequest, onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, turnItemSource, workflowOpening, onForkFromRun, onToggleTurnFold, onToggleAttemptFold, onToggleWorkGroup, onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, onCancelWorktreeSetup: onCancelWorktreeSetup ?? null, retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, workGroupViewState, }; const compactionAwaitingRow = isCompacting && !rows.some( (row) => (row.kind === "context-compaction" && row.active) || (row.kind === "working" && row.compacting), ); const backgroundWorktreeSetup = worktreeSetup !== null && worktreeSetup.phase === "running" && worktreeSetupAgentStarted(worktreeSetup) && latestRun?.startedAt != null ? worktreeSetup : null; const activityState: TimelineRowActivityState = { isWorking, isCompacting: compactionAwaitingRow, isRevertingCheckpoint, backgroundWorktreeSetup, awaitingUser: awaitingUser !== null, isPreparingWorktree, latestRunId: latestRun?.runId ?? null, }; // Mounted in both the empty and filled timeline so a thread's first turn // starts from a baseline instead of a fresh, silent region. const activityAnnouncer = ( ); const listHeaderLeadingContent = parentThreadLink === null ? ( topFadeEnabled ? ( TIMELINE_LIST_FADE_HEADER ) : ( TIMELINE_LIST_HEADER ) ) : (
); const listHeader = ( <> {parentThreadLink === null ? listHeaderLeadingContent : null} {historyControls ? : null} {parentThreadLink !== null ? listHeaderLeadingContent : null} ); const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; // Stable so the list attaches once per mount, not on every render. const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; const scrollNode: HTMLElement | null = list?.getScrollableNode() ?? null; registerTimeline?.(scrollNode); const viewport = scrollNode ? attachTimelineViewport(scrollNode) : null; viewportRef.current = viewport; const detachViewport = viewport ? onViewportAttach?.(viewport) : undefined; const stopRememberingRelease = viewport?.onFollowReleased(rememberReleasedPosition); let contentSizeFrame: number | null = null; // LegendList lays rows out from layout effects; the next frame sees them settled. let scheduledData: unknown = null; const scheduleContentSizeChanged = () => { if (!list) return; const laidOutData = list.getState().data; if (contentSizeFrame !== null) { if (scheduledData === laidOutData) return; cancelAnimationFrame(contentSizeFrame); } scheduledData = laidOutData; contentSizeFrame = requestAnimationFrame(() => { contentSizeFrame = null; handleContentSizeChanged(laidOutData); }); }; const stopListeningToContentSize = list ?.getState() .listen("totalSize", () => scheduleContentSizeChanged()); scheduleContentSizeChanged(); return () => { stopListeningToContentSize?.(); stopRememberingRelease?.(); if (contentSizeFrame !== null) cancelAnimationFrame(contentSizeFrame); detachViewport?.(); viewport?.dispose(); if (viewportRef.current === viewport) viewportRef.current = null; // Refresh frames only read the list; disclosure frames still run so the // settle flag clears. if (minimapRefreshFrameRef.current !== null) { cancelAnimationFrame(minimapRefreshFrameRef.current); minimapRefreshFrameRef.current = null; } listRef.current = null; registerTimeline?.(null); }; }); useThreadFindPositionReader({ threadKey: listIdentityKey, navigate: navigateTimeline, timelineViewportElement, findFoldRunIds, timelineEntries, latestRun, runningRunId, isWorking, runlessWorkActive, rows, contentInsetEndAdjustment, }); useThreadFindNavigation({ // Find reveals its match only after the opening restore settles; both // scroll the list directly, so the restore would otherwise pull it back. listReady: findListReady && !restoringThreadPosition, historyControls, entries: timelineEntries, container: timelineViewportElement, query: normalizedFindQuery, match: activeFindMatch, navigationId: findNavigationId, rowIndex: activeFindMatch ? rows.findIndex((row) => row.id === activeFindMatch.entryId) : -1, listRef, contentInsetEndAdjustment, }); // LegendList moves rows one commit late; per-row providers prevent thread context leaks. const onAudioPlaybackChange = (rowId: string, event: SyntheticEvent) => { const audio = event.target; if (!(audio instanceof HTMLAudioElement)) return; setPlayingAudioRows((current) => { const playing = !audio.paused && !audio.ended; if (playing === current.has(audio)) return current; const next = new Map(current); if (playing) next.set(audio, rowId); else next.delete(audio); return next; }); }; const onAudioUnmount = useStableCallback((audio: HTMLAudioElement) => { setPlayingAudioRows((current) => { if (!current.has(audio)) return current; const next = new Map(current); next.delete(audio); return next; }); }); const renderItem = ({ item }: { item: MessagesTimelineRow }) => (
onAudioPlaybackChange(item.id, event)} onPauseCapture={(event) => onAudioPlaybackChange(item.id, event)} onEndedCapture={(event) => onAudioPlaybackChange(item.id, event)} >
); const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; const timelineListFooter = ( {footer} {unmatchedPendingApprovals.length > 0 ? (
{unmatchedPendingApprovals.map((approval) => ( ))}
) : null}
); const forwardMinimapWheel = (event: WheelEvent) => { const scrollNode = listRef.current?.getScrollableNode(); if (scrollNode) forwardWheelToTimeline(event, scrollNode); }; if (showsEmptyState) { if (hideEmptyPlaceholder) { return ( <> {activityAnnouncer}
); } return ( <> {activityAnnouncer}

Send a message to start the conversation.

); } return ( <> {activityAnnouncer} {onCiteAssistantText && citationThreadRef ? ( ) : null} ref={setTimelineList} data={rows} extraData={listExtraData} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd={ !findActive && citationRequest === null && rememberedPosition?.atEnd !== false } // Legend needs a data refresh to mount new pins without a scroll event. dataVersion={`${readyCitationRequest?.key ?? listIdentityKey}:${alwaysRenderKeys.join(",")}`} {...(alwaysRender ? { alwaysRender } : {})} onLoad={handleListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || !liveFollowEnabled || fullscreenAppRowId !== null || disclosureToggleSettling ? false : isWorking && !prefersReducedMotion && settlingListIdentity === null ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ findActive || citationPositioning || (restoringThreadPosition && rememberedPosition?.atEnd === false) ? false : maintainVisibleContentPosition } maintainScrollAtEndThreshold={1} onScroll={handleScroll} onItemSizeChanged={handleItemSizeChanged} className={cn( "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", topFadeEnabled && "topbar-scroll-fade", )} ListHeaderComponent={listHeader} ListFooterComponent={timelineListFooter} /> { navigateTimeline(); void listRef.current?.scrollToIndex({ index: item.rowIndex, animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, viewOffset: 24, }); }} /> ); }); function TimelineHistoryControl(props: MessagesTimelineHistoryControls) { if (!props.hasMoreHistory && props.error === null) { return null; } return (
{props.hasMoreHistory ? ( ) : null} {props.error !== null ? (

{props.error}

) : null}
); } function isMessageRow(row: MessagesTimelineRow) { return row.kind === "message" && row.message.role !== "system"; } function keyExtractor(item: MessagesTimelineRow) { return item.id; } // LegendList averages by type; length buckets prevent inflated estimates and nested-update loops. function getItemType(item: MessagesTimelineRow) { if (item.kind !== "message") return item.kind; const lengthBucket = Math.max(0, Math.floor(Math.log2(item.message.text.length / 1000))); return `message:${item.message.role}:${lengthBucket}`; } const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isSubagentGroup = row.kind === "event" && row.projectedItem.item.type === "subagent"; const isWorkLogRow = row.kind === "work" || row.kind === "work-live" || row.kind === "work-toggle" || row.kind === "thinking"; const isExpandedToolGroupHeader = (row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") && row.expanded === true; return (
{isWorkLogRow ? ( {row.kind === "work" ? ( ) : null} {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "thinking" ? : null} ) : null} {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} {row.kind === "assistant-meta" ? : null} {row.kind === "proposed-plan" ? : null} {row.kind === "html-render" ? : null} {row.kind === "mcp-app" ? : null} {row.kind === "working" ? : null} {row.kind === "worktree-setup" ? : null} {row.kind === "event" ? : null}
); }); function WorktreeSetupTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); const terminalId = row.snapshot.setupScript?.terminalId ?? null; const openTerminal = ctx.onOpenWorktreeSetupTerminal; const onOpenTerminal = openTerminal && terminalId ? () => openTerminal(terminalId) : null; return ( ); } function ContextCompactionTimelineRow({ row, }: { row: Extract; }) { const label = row.title && row.label.startsWith("Context compacted") ? row.title + row.label.slice("Context compacted".length) : row.label; const content = ( {row.failed ? ( ); return (
{content} {row.active ? {content} : null}
); } function useDeferredWorkLoading( row: { expanded: boolean; deferredWorkItemIds?: ReadonlyArray }, onToggle: () => void, ) { const ctx = use(TimelineRowCtx); const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); const ref = parseScopedThreadKey(ctx.routeThreadKey); const environmentId = ref?.environmentId; const threadId = ref?.threadId; const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; const [failed, setFailed] = useState(false); const load = () => { if (!anchorsKey || environmentId === undefined || threadId === undefined) return; void Promise.all( anchorsKey .split("\n") .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), ).then((results) => setFailed( results.some((result) => result._tag === "Failure" || result.value._tag === "error"), ), ); }; const loadOnExpand = useEffectEvent(load); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. useEffect(() => { if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); }, [row.expanded, anchorsKey, environmentId, threadId]); const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; const loading = ref !== null && row.expanded && anchorsKey !== "" && !failed; const toggle = () => { setFailed(false); if (error && row.expanded) load(); else onToggle(); }; return { loading, error, toggle }; } function WorkHistorySkeleton() { return (
{[0, 1, 2].map((index) => ( ))}
); } function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { loading, error, toggle } = useDeferredWorkLoading(row, () => ctx.onToggleTurnFold(row.runId), ); return (
{loading ? : null}
); } function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { loading, error, toggle } = useDeferredWorkLoading(row, () => ctx.onToggleAttemptFold(row.attemptId), ); return (
{loading ? : null}
); }