From 46d59b6f6d681ffa224ea455145fb9e0ee7356f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:43:13 -0700 Subject: [PATCH] refactor(web): timeline work group and live activity rows live in their own file Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.tsx | 640 +----------------- .../chat/TimelineWorkGroupRows.logic.ts | 20 + .../components/chat/TimelineWorkGroupRows.tsx | 640 ++++++++++++++++++ 3 files changed, 667 insertions(+), 633 deletions(-) create mode 100644 apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts create mode 100644 apps/web/src/components/chat/TimelineWorkGroupRows.tsx diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2bcd94805f26..956b82e3f716 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,16 +4,11 @@ import { shallow } from "zustand/vanilla/shallow"; import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { MarkdownFindContext, findRevealRef } from "./markdownFindContext"; import { useChatCanvas } from "./ChatCanvasContext"; -import { WorkLogBlock, WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog"; +import { WorkLogBlock } from "./WorkLog"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; -import { - getQuestionAnswerPreview, - getQuestionTextPreview, - hasQuestionAnswer, -} from "@t3tools/client-runtime/work-log/user-input"; import { deriveTimelineMinimapItems } from "./timelineMinimapItems"; import { COMPOSER_CONTEXT_KINDS, @@ -25,7 +20,6 @@ import { type ServerProviderSkill, type RunId, ThreadId, - type ToolActivityIcon, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { useAtomValue } from "@effect/atom-react"; @@ -34,7 +28,6 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes import { Link } from "@tanstack/react-router"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; -import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { @@ -49,7 +42,6 @@ const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; -import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { memo, @@ -73,7 +65,6 @@ import { providerErrorPresentation, createMessageAttachmentPreviewProjector, selectMessageImageResources, - workEntryDisplayIndicatesToolFailure, } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { @@ -125,7 +116,6 @@ import { MorphIcon } from "~/components/MorphIcon"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; -import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { buildAttachmentVideoAsset, buildAttachmentVideoPreview, @@ -161,7 +151,6 @@ import { computeStableMessagesTimelineRows, deriveMessagesTimelineRowsWithState, type MessagesTimelineRowsProjection, - liveWorkEntryLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -169,11 +158,8 @@ import { isTimelineMinimapRowVisible, resolveTimelineMinimapVisibleRange, resolveTimelineMinimapHitStripWidth, - resolveWorkGroupScrollIndex, shouldCollapseUserMessage, - shouldFollowWorkGroupAppend, timelineTurnFoldRunIdsByEntryId, - workEntryIsVisibleInGroup, worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -184,8 +170,6 @@ import { useThreadFindNavigation } from "./useThreadFindNavigation"; import { readThreadFindPosition } from "./threadFindPosition"; import type { ThreadFindPositionReader } from "./threadFind"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { Spinner } from "../ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { @@ -256,8 +240,6 @@ import { TimelineRowCtx, TimelineRowActivityCtx, type WorkGroupViewState, - WorkGroupViewCtx, - type TimelineWorkEntry, type TimelineRow, } from "./timelineRowContext"; import { @@ -267,23 +249,12 @@ import { ActivityShimmerOverlay, } from "./TimelineRowParts"; import { - failedToolIconClassName, - toolIconAcceptsTint, - type WorkEntryIconName, - ToolActivityIconView, -} from "./TimelineToolActivityIcon"; -import { - useThreadReadTarget, - threadReadLabel, - ThreadReadLabel, - ReasoningTraceContent, - SimpleWorkEntryRow, -} from "./TimelineWorkEntryRow"; -import { - remarkThoughtPreview, - toolGroupSummaryIconName, - workEntryIconName, -} from "./TimelineWorkEntryRow.logic"; + WorkGroupSection, + WorkingTimelineRow, + ThinkingTimelineRow, + LiveWorkEntryTimelineRow, + WorkGroupToggleTimelineRow, +} from "./TimelineWorkGroupRows"; function timelineListStateFor(identity: string): { readonly identity: string; @@ -2871,603 +2842,6 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ ); }); -const WorkGroupSection = memo(function WorkGroupSection({ - anchorKey, - disclosureAnchorKey = anchorKey, - groupedEntries, - isExpandedToolGroup, - displayLabel, -}: { - anchorKey: string; - disclosureAnchorKey?: string; - groupedEntries: Extract["groupedEntries"]; - isExpandedToolGroup: boolean; - displayLabel?: string | undefined; -}) { - const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); - const onToggleStandaloneEntry = (collapsed: boolean) => - onToggleWorkEntry(disclosureAnchorKey, collapsed); - const nonEmptyEntries = groupedEntries.filter((entry) => - workEntryIsVisibleInGroup(entry, isExpandedToolGroup), - ); - - if (nonEmptyEntries.length === 0) return null; - if (isExpandedToolGroup && nonEmptyEntries.every((entry) => entry.itemType === "reasoning")) { - return ; - } - if (isExpandedToolGroup) { - return ( - - ); - } - - return ( -
- {nonEmptyEntries.map((workEntry) => ( - - ))} -
- ); -}); - -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`; - } - - return formatDuration(elapsedSeconds * 1_000); -} - -function formatWorkingTimerNow(startIso: string): string { - return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; -} - -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} - - ); -} - -const compactWorkEntryHeight = 24; - -function ExpandedWorkGroupEntries({ - anchorKey, - disclosureAnchorKey, - entries, - workspaceRoot, -}: { - anchorKey: string; - disclosureAnchorKey: string; - entries: TimelineWorkEntry[]; - workspaceRoot: string | undefined; -}) { - const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); - const [initialScrollIndex] = useState(() => - resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), - ); - const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); - const listRef = useRef(null); - const [expandedContentHeight, setExpandedContentHeight] = useState(0); - const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); - const [appendState, setAppendState] = useState({ entries, follow: false }); - // Capture the pre-change edge once per incoming array, before new layout - // metrics arrive. Edge/viewport changes never turn a status update into a follow. - if (appendState.entries !== entries) { - setAppendState({ - entries, - follow: - fades.viewportHeight > 0 && - shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), - }); - } - - const groupView = { - state: viewState, - onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), - }; - const updateScrollFades = () => { - const element = listRef.current?.getScrollableNode(); - if (!element) return; - const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; - const viewportHeight = element.clientHeight; - const top = element.scrollTop > 1; - const bottom = distanceFromEnd > 1; - setFades((previous) => - previous.top === top && - previous.bottom === bottom && - previous.viewportHeight === viewportHeight - ? previous - : { top, bottom, viewportHeight }, - ); - }; - - const handleScroll = () => { - const state = listRef.current?.getState(); - const position = state && resolveWorkGroupScrollAnchor(state); - if (position) { - viewState.scrollPositions.set(anchorKey, { - entryId: position.rowId, - offset: position.offsetWithinRow, - }); - } - updateScrollFades(); - }; - - const handleLoad = () => { - const list = listRef.current; - const element = list?.getScrollableNode(); - if (initialScrollIndex && list && element) { - // Bootstrap can report the restored target before the DOM has applied it. - // Reconcile once at load, before releasing the measured anchor row. - const offset = Math.max( - 0, - Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), - ); - if (Math.abs(element.scrollTop - offset) > 1) { - void list.scrollToOffset({ offset, animated: false }); - } - } - setRestoringPosition(false); - }; - - useLayoutEffect(() => { - const element = listRef.current?.getScrollableNode(); - if (!element) return; - updateScrollFades(); - const content = element.firstElementChild; - return observeResize(content ? [element, content] : element, updateScrollFades); - }, [updateScrollFades]); - - const renderEntry = ({ item }: { item: TimelineWorkEntry }) => ( - - ); - - const updateExpandedContentHeight = () => { - const state = listRef.current?.getState(); - let height = 0; - for (const entryId of viewState.expandedEntries) { - if (state?.indexByKey(entryId) === undefined) continue; - height += Math.max( - 0, - (state.sizes.get(entryId) ?? compactWorkEntryHeight) - compactWorkEntryHeight, - ); - } - setExpandedContentHeight(height); - }; - - useLayoutEffect(updateExpandedContentHeight, [entries, updateExpandedContentHeight]); - - return ( - - - - - - ); -} - -const workEntryKey = (entry: TimelineWorkEntry) => entry.id; - -function WorkingTimelineRow({ row }: { row: Extract }) { - const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = - use(TimelineRowActivityCtx); - const shimmer = isPreparingWorktree || isCompacting; - const label = isPreparingWorktree ? ( - "Setting up worktree…" - ) : isCompacting ? ( - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - ); - return ( -
-
- - {label} - {shimmer ? {label} : null} - - {backgroundWorktreeSetup ? ( - - ) : null} -
-
- ); -} - -function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { - const ctx = use(TimelineRowCtx); - const terminalId = snapshot.setupScript?.terminalId ?? null; - const openTerminal = ctx.onOpenWorktreeSetupTerminal; - const onOpenTerminal = openTerminal && terminalId ? () => openTerminal(terminalId) : null; - const scriptName = snapshot.setupScript?.name ?? "Setup script"; - return ( - - - } - > - - {scriptName} - - - - - - ); -} - -function CompactingLabel() { - return ( - - - ); -} - -function ThinkingTimelineRow({ row }: { row: Extract }) { - const ctx = use(TimelineRowCtx); - const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); - if (isPreparingWorktree || isCompacting) return ; - const activity = ; - const { groupId } = row; - if (groupId === undefined) return activity; - return ( - - ); -} - -function LiveActivityRow({ - label, - iconName, - toolIcon, - failed = false, - active = false, - shimmer = false, -}: { - label: ReactNode; - iconName?: WorkEntryIconName; - toolIcon?: ToolActivityIcon | undefined; - failed?: boolean; - active?: boolean; - shimmer?: boolean; -}) { - const animated = active && !failed; - const showShimmer = animated && shimmer; - return ( -
- - {showShimmer ? ( - - - - ) : null} -
- ); -} - -function LiveActivityContent({ - label, - iconName, - toolIcon, - failed = false, - announceFailure = false, - active = false, - highlighted = false, -}: { - label: ReactNode; - iconName: WorkEntryIconName | undefined; - toolIcon?: ToolActivityIcon | undefined; - failed?: boolean; - announceFailure?: boolean; - active?: boolean; - highlighted?: boolean; -}) { - const showTrailingFailureMark = - failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon); - - return ( - - - - ) : null - } - label={ - - {label} - - } - trailing={ - showTrailingFailureMark ? ( - - ) : null - } - /> - ); -} - -function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { - const ctx = use(TimelineRowCtx); - const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); - const questionHeading = row.entry.questionAnswer - ? getQuestionTextPreview(row.entry.questionAnswer) - : ""; - const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); - const threadLabel = threadReadLabel(label, threadTarget); - - const failed = workEntryDisplayIndicatesToolFailure(row.entry); - const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; - const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; - - return ( - - ); -} - -function WorkGroupToggleTimelineRow({ - row, -}: { - row: Extract; -}) { - const ctx = use(TimelineRowCtx); - return ( - ctx.onToggleWorkGroup(row.groupId, row.id)} - /> - ); -} - -function WorkGroupHeader(props: { - label: string; - iconName: WorkEntryIconName; - toolIcon?: ToolActivityIcon | undefined; - failed?: boolean | undefined; - active?: boolean | undefined; - expanded: boolean; - createdAt: string; - timestampFormat: TimestampFormat; - onToggle: () => void; -}) { - return ( - - } - label={ - - {props.label} - - } - trailing={ - - } - /> - ); -} - /** 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({ diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts new file mode 100644 index 000000000000..e4a8fe4a583c --- /dev/null +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts @@ -0,0 +1,20 @@ +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; + +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`; + } + + return formatDuration(elapsedSeconds * 1_000); +} + +export function formatWorkingTimerNow(startIso: string): string { + return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; +} diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx new file mode 100644 index 000000000000..11b56a19038c --- /dev/null +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -0,0 +1,640 @@ +import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog"; +import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { + getQuestionAnswerPreview, + getQuestionTextPreview, + hasQuestionAnswer, +} from "@t3tools/client-runtime/work-log/user-input"; +import { type ToolActivityIcon } from "@t3tools/contracts"; +import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; +import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { observeResize } from "~/lib/observeResize"; +import { observeVisibleAnimation } from "../../lib/visibleAnimation"; +import { memo, use, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { workEntryDisplayIndicatesToolFailure } from "../../session-logic"; +import ChatMarkdown from "../ChatMarkdown"; +import { Minimize2Icon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; +import { + liveWorkEntryLabel, + resolveWorkGroupScrollIndex, + shouldFollowWorkGroupAppend, + workEntryIsVisibleInGroup, + type MessagesTimelineRow, +} from "./MessagesTimeline.logic"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { WorktreeSetupCard } from "./WorktreeSetupCard"; +import { cn } from "~/lib/utils"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { + TimelineRowCtx, + TimelineRowActivityCtx, + WorkGroupViewCtx, + type TimelineWorkEntry, + type TimelineRow, +} from "./timelineRowContext"; +import { TimelineRowTimestamp, ActivityShimmerOverlay } from "./TimelineRowParts"; +import { + failedToolIconClassName, + toolIconAcceptsTint, + type WorkEntryIconName, + ToolActivityIconView, +} from "./TimelineToolActivityIcon"; +import { + useThreadReadTarget, + threadReadLabel, + ThreadReadLabel, + ReasoningTraceContent, + SimpleWorkEntryRow, +} from "./TimelineWorkEntryRow"; +import { + remarkThoughtPreview, + toolGroupSummaryIconName, + workEntryIconName, +} from "./TimelineWorkEntryRow.logic"; +import { formatWorkingTimerNow } from "./TimelineWorkGroupRows.logic"; + +export const WorkGroupSection = memo(function WorkGroupSection({ + anchorKey, + disclosureAnchorKey = anchorKey, + groupedEntries, + isExpandedToolGroup, + displayLabel, +}: { + anchorKey: string; + disclosureAnchorKey?: string; + groupedEntries: Extract["groupedEntries"]; + isExpandedToolGroup: boolean; + displayLabel?: string | undefined; +}) { + const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); + const onToggleStandaloneEntry = (collapsed: boolean) => + onToggleWorkEntry(disclosureAnchorKey, collapsed); + const nonEmptyEntries = groupedEntries.filter((entry) => + workEntryIsVisibleInGroup(entry, isExpandedToolGroup), + ); + + if (nonEmptyEntries.length === 0) return null; + if (isExpandedToolGroup && nonEmptyEntries.every((entry) => entry.itemType === "reasoning")) { + return ; + } + if (isExpandedToolGroup) { + return ( + + ); + } + + return ( +
+ {nonEmptyEntries.map((workEntry) => ( + + ))} +
+ ); +}); + +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} + + ); +} + +const compactWorkEntryHeight = 24; + +function ExpandedWorkGroupEntries({ + anchorKey, + disclosureAnchorKey, + entries, + workspaceRoot, +}: { + anchorKey: string; + disclosureAnchorKey: string; + entries: TimelineWorkEntry[]; + workspaceRoot: string | undefined; +}) { + const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); + const [initialScrollIndex] = useState(() => + resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), + ); + const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); + const listRef = useRef(null); + const [expandedContentHeight, setExpandedContentHeight] = useState(0); + const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); + const [appendState, setAppendState] = useState({ entries, follow: false }); + // Capture the pre-change edge once per incoming array, before new layout + // metrics arrive. Edge/viewport changes never turn a status update into a follow. + if (appendState.entries !== entries) { + setAppendState({ + entries, + follow: + fades.viewportHeight > 0 && + shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), + }); + } + + const groupView = { + state: viewState, + onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), + }; + const updateScrollFades = () => { + const element = listRef.current?.getScrollableNode(); + if (!element) return; + const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; + const viewportHeight = element.clientHeight; + const top = element.scrollTop > 1; + const bottom = distanceFromEnd > 1; + setFades((previous) => + previous.top === top && + previous.bottom === bottom && + previous.viewportHeight === viewportHeight + ? previous + : { top, bottom, viewportHeight }, + ); + }; + + const handleScroll = () => { + const state = listRef.current?.getState(); + const position = state && resolveWorkGroupScrollAnchor(state); + if (position) { + viewState.scrollPositions.set(anchorKey, { + entryId: position.rowId, + offset: position.offsetWithinRow, + }); + } + updateScrollFades(); + }; + + const handleLoad = () => { + const list = listRef.current; + const element = list?.getScrollableNode(); + if (initialScrollIndex && list && element) { + // Bootstrap can report the restored target before the DOM has applied it. + // Reconcile once at load, before releasing the measured anchor row. + const offset = Math.max( + 0, + Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), + ); + if (Math.abs(element.scrollTop - offset) > 1) { + void list.scrollToOffset({ offset, animated: false }); + } + } + setRestoringPosition(false); + }; + + useLayoutEffect(() => { + const element = listRef.current?.getScrollableNode(); + if (!element) return; + updateScrollFades(); + const content = element.firstElementChild; + return observeResize(content ? [element, content] : element, updateScrollFades); + }, [updateScrollFades]); + + const renderEntry = ({ item }: { item: TimelineWorkEntry }) => ( + + ); + + const updateExpandedContentHeight = () => { + const state = listRef.current?.getState(); + let height = 0; + for (const entryId of viewState.expandedEntries) { + if (state?.indexByKey(entryId) === undefined) continue; + height += Math.max( + 0, + (state.sizes.get(entryId) ?? compactWorkEntryHeight) - compactWorkEntryHeight, + ); + } + setExpandedContentHeight(height); + }; + + useLayoutEffect(updateExpandedContentHeight, [entries, updateExpandedContentHeight]); + + return ( + + + + + + ); +} + +const workEntryKey = (entry: TimelineWorkEntry) => entry.id; + +export function WorkingTimelineRow({ row }: { row: Extract }) { + const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } = + use(TimelineRowActivityCtx); + const shimmer = isPreparingWorktree || isCompacting; + const label = isPreparingWorktree ? ( + "Setting up worktree…" + ) : isCompacting ? ( + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + ); + return ( +
+
+ + {label} + {shimmer ? {label} : null} + + {backgroundWorktreeSetup ? ( + + ) : null} +
+
+ ); +} + +function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) { + const ctx = use(TimelineRowCtx); + const terminalId = snapshot.setupScript?.terminalId ?? null; + const openTerminal = ctx.onOpenWorktreeSetupTerminal; + const onOpenTerminal = openTerminal && terminalId ? () => openTerminal(terminalId) : null; + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + return ( + + + } + > + + {scriptName} + + + + + + ); +} + +function CompactingLabel() { + return ( + + + ); +} + +export function ThinkingTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); + if (isPreparingWorktree || isCompacting) return ; + const activity = ; + const { groupId } = row; + if (groupId === undefined) return activity; + return ( + + ); +} + +function LiveActivityRow({ + label, + iconName, + toolIcon, + failed = false, + active = false, + shimmer = false, +}: { + label: ReactNode; + iconName?: WorkEntryIconName; + toolIcon?: ToolActivityIcon | undefined; + failed?: boolean; + active?: boolean; + shimmer?: boolean; +}) { + const animated = active && !failed; + const showShimmer = animated && shimmer; + return ( +
+ + {showShimmer ? ( + + + + ) : null} +
+ ); +} + +function LiveActivityContent({ + label, + iconName, + toolIcon, + failed = false, + announceFailure = false, + active = false, + highlighted = false, +}: { + label: ReactNode; + iconName: WorkEntryIconName | undefined; + toolIcon?: ToolActivityIcon | undefined; + failed?: boolean; + announceFailure?: boolean; + active?: boolean; + highlighted?: boolean; +}) { + const showTrailingFailureMark = + failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon); + + return ( + + + + ) : null + } + label={ + + {label} + + } + trailing={ + showTrailingFailureMark ? ( + + ) : null + } + /> + ); +} + +export function LiveWorkEntryTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); + const questionHeading = row.entry.questionAnswer + ? getQuestionTextPreview(row.entry.questionAnswer) + : ""; + const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const threadLabel = threadReadLabel(label, threadTarget); + + const failed = workEntryDisplayIndicatesToolFailure(row.entry); + const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; + const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; + + return ( + + ); +} + +export function WorkGroupToggleTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + return ( + ctx.onToggleWorkGroup(row.groupId, row.id)} + /> + ); +} + +function WorkGroupHeader(props: { + label: string; + iconName: WorkEntryIconName; + toolIcon?: ToolActivityIcon | undefined; + failed?: boolean | undefined; + active?: boolean | undefined; + expanded: boolean; + createdAt: string; + timestampFormat: TimestampFormat; + onToggle: () => void; +}) { + return ( + + } + label={ + + {props.label} + + } + trailing={ + + } + /> + ); +}