1import { ThreadFindTimelineContext } from "./ThreadFindProvider";
2import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
3import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";
4import { useChatCanvas } from "./ChatCanvasContext";
5import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
6import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
7import type { WorktreeSetupSnapshot } from "@t3tools/contracts";
8import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
9import { useRightPanelStore } from "~/rightPanelStore";
10import {
11 getQuestionAnswerPreview,
12 getQuestionAnswerText,
13 getQuestionTextPreview,
14 hasQuestionAnswer,
15} from "@t3tools/client-runtime/work-log/user-input";
16import {
17 deriveTimelineMinimapItems,
18 resolveTimelineMinimapPreview,
19 type TimelineMinimapItem,
20} from "./timelineMinimapItems";
21import {
22 COMPOSER_CONTEXT_KINDS,
23 ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE,
24 type AssistantCitation,
25 type EnvironmentId,
26 type MessageId,
27 type OrchestrationV2TurnItem,
28 type RunAttemptId,
29 type ScopedThreadRef,
30 type ServerProvider,
31 type ServerProviderSkill,
32 type RunId,
33 ThreadId,
34 type ToolActivityIcon,
35} from "@t3tools/contracts";
36import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
37import { useAtomValue } from "@effect/atom-react";
38import { environmentThreadDetails } from "../../state/threads";
39import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
40import { Link } from "@tanstack/react-router";
41import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
42import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
43import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
44import {
45 liveThoughtLine,
46 resolveWorkEntryToolPresentation,
47 resolveViewedImageAsset,
48 workEntryViewedImagePath,
49} from "@t3tools/client-runtime/work-log/presentation";
50import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
51import {
52 turnItemHasDetail,
53 turnItemNeedsDetailFetch,
54} from "@t3tools/client-runtime/work-log/item-detail";
55import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
56import {
57 subagentGroupSummary,
58 summarizeSubagentStatuses,
59} from "@t3tools/client-runtime/state/subagent-display";
60import { observeResize } from "~/lib/observeResize";
61
62const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
63const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
64
65import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
66import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
67import { formatDuration } from "@t3tools/shared/orchestrationTiming";
68import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
69import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
70import { observeVisibleAnimation } from "../../lib/visibleAnimation";
71import {
72 createContext,
73 memo,
74 use,
75 useCallback,
76 useContext,
77 useEffect,
78 useLayoutEffect,
79 useMemo,
80 useRef,
81 useState,
82 type KeyboardEvent,
83 type MouseEvent,
84 type ReactNode,
85} from "react";
86import {
87 LegendList,
88 type LegendListRef,
89 type MaintainScrollAtEndOptions,
90} from "@legendapp/list/react";
91import { FileDiff } from "@pierre/diffs/react";
92import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
93import {
94 type TimelineEntry,
95 providerErrorPresentation,
96 createMessageAttachmentPreviewProjector,
97 selectMessageImageResources,
98 workEntryDisplayIndicatesToolFailure,
99 workEntrySignalsSevereFailure,
100 workLogEntryIsToolLike,
101} from "../../session-logic";
102import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
103import {
104 type ChatMessage,
105 type ChatFileAttachment,
106 type ChatImageAttachment,
107 isFileAttachment,
108 isImageAttachment,
109 isVideoAttachment,
110 type TurnDiffSummary,
111} from "../../types";
112import {
113 getRenderablePatch,
114 resolveDiffThemeName,
115 resolveFileDiffPath,
116} from "../../lib/diffRendering";
117import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
118import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
119import ReactMarkdown from "react-markdown";
120import remarkGfm from "remark-gfm";
121import type { Root, RootContent } from "mdast";
122import { KnifeMark } from "../KnifeMark";
123import { ThreadContextChip } from "../ThreadContextChip";
124import {
125 BotIcon,
126 BrainIcon,
127 CheckIcon,
128 ChevronDownIcon,
129 ChevronRightIcon,
130 ChevronUpIcon,
131 CircleAlertIcon,
132 DownloadIcon,
133 EyeIcon,
134 GitForkIcon,
135 GlobeIcon,
136 type LucideIcon,
137 MessageCircleIcon,
138 MousePointerClickIcon,
139 PaintbrushIcon,
140 MinusIcon,
141 Redo2Icon,
142 Minimize2Icon,
143 SearchIcon,
144 SmartphoneIcon,
145 SquarePenIcon,
146 TerminalIcon,
147 Undo2Icon,
148 HammerIcon,
149 WrenchIcon,
150 Code2Icon,
151 XIcon,
152 ZapIcon,
153 RotateCcwIcon,
154} from "lucide-react";
155import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide";
156import type {
157 ComposerContextId,
158 ComposerContextRecord,
159 KnownComposerContextRecord,
160} from "@t3tools/contracts";
161import { Button, InlineButton } from "../ui/button";
162import { MorphIcon } from "~/components/MorphIcon";
163import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
164import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
165import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
166import {
167 buildAttachmentVideoAsset,
168 buildAttachmentVideoPreview,
169 buildExpandedImagePreview,
170 ExpandedImagePreview,
171} from "./ExpandedImagePreview";
172import {
173 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
174 SnapShotAttachmentDetails,
175} from "./SnapShotAttachmentDetails";
176import { ProposedPlanCard } from "./ProposedPlanCard";
177import { HtmlRenderFrame } from "./HtmlRenderFrame";
178import { McpAppFrame } from "./McpAppFrame";
179import { ChangedFilesCard } from "./ChangedFilesTree";
180import { useFileContextMenuHandler } from "../../fileContextMenu";
181import { useProject, useThreadShell } from "../../state/entities";
182import {
183 CHAT_TIMELINE_ANCHOR_OFFSET,
184 readTimelinePosition,
185 rememberTimelinePosition,
186 timelineContentOverflowsViewport,
187} from "./timelineScrollAnchoring";
188import { MessageCopyButton } from "./MessageCopyButton";
189import { promptLinkFor } from "../promptLinks/copyPromptLink";
190import { PierreEntryIcon } from "./PierreEntryIcon";
191import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
192import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
193import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
194import {
195 AssistantCitationSource,
196 type AssistantCitationRequest,
197 type AssistantCitationTarget,
198} from "./AssistantCitationSource";
199import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
200import {
201 computeStableMessagesTimelineRows,
202 deriveMessagesTimelineRowsWithState,
203 type MessagesTimelineRowsProjection,
204 liveWorkEntryLabel,
205 resolveAssistantMessageCopyState,
206 resolveTimelineIsAtEnd,
207 resolveTimelineMinimapHasPersistentGutter,
208 resolveTimelineMinimapCurrentIndex,
209 resolveTimelineMinimapHeightStyle,
210 resolveTimelineMinimapHitStripWidth,
211 resolveTimelineMinimapIndexFromPointer,
212 resolveTimelineMinimapInteractiveWidth,
213 resolveTimelineMinimapNavigationInteractive,
214 resolveTimelineMinimapTopPercent,
215 resolveWorkGroupScrollIndex,
216 shouldCollapseUserMessage,
217 shouldFollowWorkGroupAppend,
218 timelineTurnFoldRunIdsByEntryId,
219 threadReadLabelPrefix,
220 threadReadTargetId,
221 threadReadTargetTitle,
222 toolGroupAction,
223 workEntryDisplayLabel,
224 workEntryReadOutput,
225 workEntryIsVisibleInGroup,
226 worktreeSetupAgentStarted,
227 type StableMessagesTimelineRowsState,
228 type MessagesTimelineRow,
229 TIMELINE_MINIMAP_MIN_ITEMS,
230 type TimelineLatestRun,
231 type WorkGroupScrollAnchor,
232} from "./MessagesTimeline.logic";
233import { type ThreadFindMatch } from "./threadFind";
234import { useThreadFindNavigation } from "./useThreadFindNavigation";
235import { readThreadFindPosition } from "./threadFindPosition";
236import type { ThreadFindPositionReader } from "./threadFind";
237import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
238import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
239import { Spinner } from "../ui/spinner";
240import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip";
241import { WorktreeSetupCard } from "./WorktreeSetupCard";
242import {
243 ContextChipPopover as UserMessageContextPopover,
244 ContextChipShell,
245 FileChip,
246 ImageChipButton,
247 PULL_REQUEST_CHIP_KINDS,
248 PullRequestChip,
249 UnresolvedChip,
250} from "../contextChipParts";
251import {
252 asKnownContextRecord,
253 isPullRequestSummaryContext,
254 pullRequestContextDisplayState,
255 pullRequestContextKindLabel,
256 resolveUserMessageContext,
257 reviewCommentContextLabel,
258 selectedMessageContextFragment,
259} from "~/lib/composerContextRecords";
260import {
261 collectComposerContextReferences,
262 formatComposerContextReference,
263} from "@t3tools/shared/composerContextReferences";
264import {
265 COMPOSER_CONTEXT_CLIPBOARD_MIME,
266 encodeComposerContextClipboardHtml,
267 encodeComposerContextFragment,
268} from "@t3tools/shared/composerContextClipboard";
269import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
270import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
271import { createContextPresentationRegistry } from "../contextPresentationRegistry";
272import { useOpenPrLink } from "~/lib/openPullRequestLink";
273import { useClientSettings } from "~/hooks/useSettings";
274import type { ChatMarkdownContextReference } from "../ChatMarkdown";
275import { useMediaQuery } from "~/hooks/useMediaQuery";
276import { cn } from "~/lib/utils";
277import { useUiStateStore } from "~/uiStateStore";
278import { type TimestampFormat } from "@t3tools/contracts/settings";
279import {
280 formatChatTimestampTooltip,
281 formatDayAwareTimestamp,
282 formatUpcomingTimestamp,
283} from "../../timestampFormat";
284import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector";
285import { useV2ItemSupport } from "../../state/v2ItemSupport";
286import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible";
287import {
288 isV2LifecycleItem,
289 SubagentAvatar,
290 SubagentElapsed,
291 SubagentNotificationLink,
292 V2LifecycleRow,
293 type HandoffTimelineRun,
294} from "./V2LifecycleRow";
295import { SecretRequestCard } from "./SecretRequestCard";
296import { TimelineSystemDivider } from "./TimelineSystemDivider";
297
298import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
299import * as DateTime from "effect/DateTime";
300import { formatWorkspaceRelativePath } from "../../filePathDisplay";
301import { forwardWheelToTimeline } from "./timelineMinimapWheel";
302import {
303 buildReviewCommentRenderablePatch,
304 formatReviewCommentFence,
305 type ReviewCommentContext,
306} from "../../reviewCommentContext";
307
308// ---------------------------------------------------------------------------
309// Context — shared state consumed by every row component via Context.
310// Propagates through LegendList's memo boundaries for shared callbacks and
311// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
312// components (LiveElapsed) handle it.
313// ---------------------------------------------------------------------------
314
315interface TimelineRowSharedState {
316 citationRequest: AssistantCitationTarget | null;
317 listRef: React.RefObject<LegendListRef | null>;
318 timestampFormat: TimestampFormat;
319 routeThreadKey: string;
320 threadRef: ScopedThreadRef | null;
321 markdownCwd: string | undefined;
322 resolvedTheme: "light" | "dark";
323 workspaceRoot: string | undefined;
324 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
325 /** Provider snapshots for resolving handoff endpoints to icons + model names. */
326 providerStatuses: ReadonlyArray<ServerProvider>;
327 /** Projection runs, for recovering handoff models on legacy items. */
328 runs: ReadonlyArray<HandoffTimelineRun>;
329 activeThreadEnvironmentId: EnvironmentId;
330 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
331 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
332 /** Sends text an MCP App asked to post, after the user approved it. */
333 onSendAppMessage: ((text: string) => Promise<void>) | undefined;
334 /**
335 * An MCP App row entering or leaving full screen. The row stays rendered
336 * and the list stops following new output meanwhile, so the app is not
337 * virtualized away while the reader is using it.
338 */
339 onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void;
340 onRunShellCommand: ((command: string) => void) | undefined;
341 findActive: boolean;
342 onImageExpand: (preview: ExpandedImagePreview) => void;
343 displayThreadKey?: string;
344 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
345 onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void;
346 onForkFromRun: (input: {
347 readonly sourceThreadId: ThreadId;
348 readonly runId: RunId;
349 }) => Promise<void>;
350 onRollbackCheckpoint: (input: {
351 readonly checkpointId: string;
352 readonly scopeId: string;
353 }) => void;
354 onToggleTurnFold: (runId: RunId) => void;
355 onToggleAttemptFold: (attemptId: RunAttemptId) => void;
356 onFileOpen: (attachment: ChatFileAttachment) => void;
357 onFileDownload: (attachment: ChatFileAttachment) => void;
358 openPullRequest: (event: MouseEvent<HTMLElement>, url: string) => void;
359 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
360 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
361 onCancelWorktreeSetup: (() => void) | null;
362 retryableWorkspacePreparationRunIds: ReadonlySet<RunId>;
363 onRetryWorkspacePreparation: ((runId: RunId) => void) | null;
364 onWorktreeSetupWorkLocally: (() => void) | null;
365 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
366 workGroupViewState: WorkGroupViewState;
367}
368
369interface TimelineRowActivityState {
370 isWorking: boolean;
371 isCompacting: boolean;
372 isRevertingCheckpoint: boolean;
373 activeTurnInProgress: boolean;
374 /** The agent waits on an approval or an answer from the user. */
375 awaitingUser: boolean;
376 isPreparingWorktree: boolean;
377 latestRunId: RunId | null;
378 /**
379 * A worktree setup whose script is still running after the agent took
380 * over. The working header shows it as a chip with a popover; the stage
381 * list itself has already left the timeline.
382 */
383 backgroundWorktreeSetup: WorktreeSetupSnapshot | null;
384}
385
386const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
387const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
388
389interface WorkGroupViewState {
390 scrollPositions: Map<string, WorkGroupScrollAnchor>;
391 expandedEntries: Set<string>;
392}
393
394const WorkGroupViewCtx = createContext<{
395 state: WorkGroupViewState;
396 onToggleEntry: (collapsed: boolean) => void;
397} | null>(null);
398const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
399const TIMELINE_LIST_FADE_HEADER = (
400 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
401);
402function TimelineListFooter({
403 composerInset,
404 children,
405}: {
406 readonly composerInset: number;
407 readonly children?: ReactNode;
408}) {
409 return (
410 <div>
411 {children}
412 <div aria-hidden style={{ height: composerInset }} />
413 <div aria-hidden className="h-3 sm:h-4" />
414 </div>
415 );
416}
417
418const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
419const TIMELINE_MAINTAIN_SCROLL_AT_END = {
420 animated: false,
421 on: {
422 dataChange: true,
423 // Composer inset changes must not move already-visible messages. New
424 // rows and row growth still keep live-follow pinned through the other
425 // triggers below.
426 footerLayout: false,
427 itemLayout: true,
428 layout: true,
429 },
430} as const satisfies MaintainScrollAtEndOptions;
431const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = [];
432const EMPTY_RUN_IDS: ReadonlySet<RunId> = new Set();
433// Streamed text lands a paragraph at a time. A smooth scroll to the end
434// turns each landing into a short glide instead of a jump. Thread switches
435// and layout settles keep the instant variant so nothing visibly travels.
436const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = {
437 ...TIMELINE_MAINTAIN_SCROLL_AT_END,
438 animated: true,
439} as const satisfies MaintainScrollAtEndOptions;
440
441// ---------------------------------------------------------------------------
442// Props (public API)
443// ---------------------------------------------------------------------------
444
445export interface MessagesTimelineHistoryControls {
446 readonly hasMoreHistory: boolean;
447 readonly loading: boolean;
448 readonly error: string | null;
449 readonly onLoadEarlier: (throughEntryId?: string) => void;
450}
451
452interface MessagesTimelineProps {
453 citationRequest?: AssistantCitationRequest | null;
454 citationHistoryLoading?: boolean;
455 onCiteAssistantText?: (
456 citation: AssistantCitation,
457 sourceAnchor: AssistantCitationSourceAnchor,
458 ) => boolean;
459 isWorking: boolean;
460 /** The live work belongs to a runless root turn (a provider-native subagent). */
461 runlessWorkActive?: boolean;
462 activeTurnInProgress: boolean;
463 activeTurnStartedAt?: string | null;
464 worktreeSetup?: WorktreeSetupSnapshot | null;
465 onCancelWorktreeSetup?: () => void;
466 /** Runs whose failed workspace preparation can be retried, keyed by run id. */
467 retryableWorkspacePreparationRunIds?: ReadonlySet<RunId>;
468 onRetryWorkspacePreparation?: (runId: RunId) => void;
469 onWorktreeSetupWorkLocally?: () => void;
470 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
471 isPreparingWorktree?: boolean;
472 isCompacting?: boolean;
473 /** The agent waits on an approval or an answer from the user. */
474 awaitingUser?: boolean;
475 /** Thread state shown after the last message, such as a settled or snoozed line. */
476 footer?: ReactNode;
477
478 listRef: React.RefObject<LegendListRef | null>;
479 timelineEntries: ReadonlyArray<TimelineEntry>;
480 latestRun: TimelineLatestRun | null;
481 runningRunId?: RunId | null;
482 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
483 routeThreadKey: string;
484 displayThreadKey?: string;
485 /** The rows are a held snapshot of the previous thread while the next one loads. */
486 paintOnly?: boolean;
487 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
488 onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void;
489 parentThreadLink?: {
490 readonly threadId: ThreadId;
491 readonly title: string;
492 } | null;
493 onForkFromRun: (input: {
494 readonly sourceThreadId: ThreadId;
495 readonly runId: RunId;
496 }) => Promise<void>;
497 onRollbackCheckpoint: (input: {
498 readonly checkpointId: string;
499 readonly scopeId: string;
500 }) => void;
501 supportsConversationRollback: boolean;
502 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
503 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
504 onSendAppMessage?: (text: string) => Promise<void>;
505 onRunShellCommand?: (command: string) => void;
506 isRevertingCheckpoint: boolean;
507 onImageExpand: (preview: ExpandedImagePreview) => void;
508 onFileOpen?: (attachment: ChatFileAttachment) => void;
509 onFileDownload?: (attachment: ChatFileAttachment) => void;
510 activeThreadEnvironmentId: EnvironmentId;
511 markdownCwd: string | undefined;
512 resolvedTheme: "light" | "dark";
513 timestampFormat: TimestampFormat;
514 workspaceRoot: string | undefined;
515 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
516 providerStatuses: ReadonlyArray<ServerProvider>;
517 runs: ReadonlyArray<HandoffTimelineRun>;
518 anchorMessageId: MessageId | null;
519 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
520 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
521 contentInsetEndAdjustment: number;
522 onIsAtEndChange: (isAtEnd: boolean) => void;
523 /**
524 * Whether the timeline should keep pinning to the live edge as content
525 * grows. Off while the user is reading history; LegendList's own
526 * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's
527 * scroll-mode refs whenever the user drifts near the bottom.
528 */
529 liveFollowEnabled: boolean;
530 /**
531 * Whether the real rows extend past the viewport above the composer.
532 * Reported after scrolls, row size changes, and viewport resizes.
533 */
534 onContentOverflowChange?: (overflows: boolean) => void;
535 onToolOutputCollapsedAtEnd?: () => void;
536 onManualNavigation: () => void;
537 findOpen?: boolean;
538 cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined;
539 hideEmptyPlaceholder?: boolean;
540 topFadeEnabled?: boolean;
541 historyControls?: MessagesTimelineHistoryControls | undefined;
542 /** Non-null when older turns exist beyond the loaded window. */
543 loadEarlier?: CitationHistoryPage | null;
544 findPositionReaderRef?: React.RefObject<ThreadFindPositionReader | null>;
545 findExpanded?: boolean;
546 findQuery?: string;
547 activeFindMatch?: ThreadFindMatch | null;
548 findNavigationId?: number;
549}
550
551// ---------------------------------------------------------------------------
552// MessagesTimeline — list owner
553// ---------------------------------------------------------------------------
554
555export function MessagesTimeline(props: MessagesTimelineProps) {
556 const find = useContext(ThreadFindTimelineContext);
557 const findOpen = find?.findOpen ?? props.findOpen;
558 const { onManualNavigation } = props;
559 useEffect(() => {
560 if (findOpen) onManualNavigation();
561 }, [findOpen, onManualNavigation]);
562 return (
563 <ConversationTimeline
564 {...props}
565 {...find}
566 liveFollowEnabled={!findOpen && props.liveFollowEnabled}
567 />
568 );
569}
570
571const ConversationTimeline = memo(function ConversationTimeline({
572 citationRequest = null,
573 citationHistoryLoading = false,
574 onCiteAssistantText,
575 isWorking,
576 runlessWorkActive = false,
577 activeTurnInProgress,
578 activeTurnStartedAt = null,
579 worktreeSetup = null,
580 footer = null,
581 onCancelWorktreeSetup,
582 retryableWorkspacePreparationRunIds = EMPTY_RUN_IDS,
583 onRetryWorkspacePreparation,
584 onWorktreeSetupWorkLocally,
585 onOpenWorktreeSetupTerminal,
586 isPreparingWorktree = false,
587 isCompacting = false,
588 awaitingUser = false,
589 listRef,
590 timelineEntries,
591 findExpanded = false,
592 findQuery = "",
593 activeFindMatch = null,
594 findNavigationId = 0,
595 findPositionReaderRef,
596 latestRun,
597 runningRunId = null,
598 turnDiffSummaries,
599 routeThreadKey,
600 displayThreadKey,
601 paintOnly = false,
602 onOpenTurnDiff,
603 onOpenThread,
604 parentThreadLink = null,
605 onForkFromRun,
606 onRollbackCheckpoint,
607 supportsConversationRollback,
608 onRevertToTurnCount,
609 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
610 onSendAppMessage,
611 onRunShellCommand,
612 isRevertingCheckpoint,
613 onImageExpand,
614 onFileOpen = NOOP_OPEN_ATTACHMENT,
615 onFileDownload = NOOP_OPEN_ATTACHMENT,
616 activeThreadEnvironmentId,
617 markdownCwd,
618 resolvedTheme,
619 timestampFormat,
620 workspaceRoot,
621 skills = EMPTY_TIMELINE_SKILLS,
622 providerStatuses,
623 runs: runsProp,
624 anchorMessageId,
625 onAnchorReady,
626 onAnchorSizeChanged,
627 contentInsetEndAdjustment,
628 onIsAtEndChange,
629 onContentOverflowChange,
630 liveFollowEnabled,
631 onToolOutputCollapsedAtEnd,
632 onManualNavigation,
633 cancelPositionRestoreRef,
634 hideEmptyPlaceholder = false,
635 topFadeEnabled = false,
636 historyControls,
637 loadEarlier = null,
638}: MessagesTimelineProps) {
639 const listIdentityKey = displayThreadKey ?? routeThreadKey;
640 const rememberedPosition = useMemo(
641 () => readTimelinePosition(listIdentityKey),
642 [listIdentityKey],
643 );
644 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(
645 () => rememberedPosition?.disclosures?.runs ?? new Set(),
646 );
647 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(
648 () => rememberedPosition?.disclosures?.workGroups ?? new Set(),
649 );
650 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(
651 () => rememberedPosition?.disclosures?.attempts ?? new Set(),
652 );
653 const [positionedThreadKey, setPositionedThreadKey] = useState<string | null>(() =>
654 rememberedPosition?.atEnd === false ? null : listIdentityKey,
655 );
656 const restoringThreadPosition = positionedThreadKey !== listIdentityKey;
657 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
658 const listIdentityRef = useRef(listIdentityKey);
659 const previousLatestRunRef = useRef(latestRun);
660 // The list stays mounted across thread switches. Its first end pins on the
661 // new thread must snap, not glide, even if that thread is mid-turn.
662 const [settlingListIdentity, setSettlingListIdentity] = useState<string | null>(listIdentityKey);
663 let paintedExpandedRunIds = expandedRunIds;
664 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
665 let paintedExpandedAttemptIds = expandedAttemptIds;
666 if (listIdentityRef.current !== listIdentityKey) {
667 listIdentityRef.current = listIdentityKey;
668 setPositionedThreadKey(null);
669 previousLatestRunRef.current = latestRun;
670 setSettlingListIdentity(listIdentityKey);
671 paintedExpandedRunIds = rememberedPosition?.disclosures?.runs ?? new Set();
672 paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set();
673 paintedExpandedAttemptIds = rememberedPosition?.disclosures?.attempts ?? new Set();
674 setExpandedRunIds(paintedExpandedRunIds);
675 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
676 setExpandedAttemptIds(paintedExpandedAttemptIds);
677 }
678 const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
679 const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined);
680 const expandCitedRun = useCallback((runId: RunId) => {
681 setExpandedRunIds((current) => (current.has(runId) ? current : new Set([...current, runId])));
682 }, []);
683 // Nested tool state shares the bounded thread-position cache.
684 const workGroupViewState = useMemo<WorkGroupViewState>(
685 () =>
686 rememberedPosition?.disclosures?.workGroupState ?? {
687 scrollPositions: new Map(),
688 expandedEntries: new Set(),
689 },
690 [listIdentityKey, rememberedPosition],
691 );
692 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
693 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
694 const disclosureAnchorKeyRef = useRef<string | null>(null);
695 const disclosureSettleFrameRef = useRef<number | null>(null);
696 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
697 const normalizedFindQuery = findQuery.trim();
698 const findActive = findExpanded;
699
700 useEffect(() => {
701 return () => {
702 if (disclosureSettleFrameRef.current !== null) {
703 cancelAnimationFrame(disclosureSettleFrameRef.current);
704 }
705 if (disclosureSettleSecondFrameRef.current !== null) {
706 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
707 }
708 };
709 }, []);
710
711 useEffect(() => {
712 if (settlingListIdentity === null) return;
713 // Two frames covers the fresh-data layout pass and the initial end pin.
714 let second: number | null = null;
715 const first = requestAnimationFrame(() => {
716 second = requestAnimationFrame(() => {
717 setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current));
718 });
719 });
720 return () => {
721 cancelAnimationFrame(first);
722 if (second !== null) cancelAnimationFrame(second);
723 };
724 }, [settlingListIdentity]);
725
726 // A toggle made at the end keeps the end in view while rows re-measure.
727 // Anywhere else, the toggled row holds its place.
728 const disclosurePinsEndRef = useRef(false);
729 const disclosureCollapsedRef = useRef(false);
730 const pinDisclosureToEnd = useCallback(() => {
731 if (!disclosurePinsEndRef.current) return;
732 const element = listRef.current?.getScrollableNode();
733 if (element) element.scrollTop = element.scrollHeight;
734 }, [listRef]);
735
736 // Settles two frames after the last toggle, or after the last row resize
737 // while pinned to the end, since tool output can render a few frames late.
738 const scheduleDisclosureSettle = useCallback(() => {
739 if (disclosureSettleFrameRef.current !== null) {
740 cancelAnimationFrame(disclosureSettleFrameRef.current);
741 }
742 if (disclosureSettleSecondFrameRef.current !== null) {
743 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
744 }
745 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
746 pinDisclosureToEnd();
747 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
748 pinDisclosureToEnd();
749 disclosurePinsEndRef.current = false;
750 disclosureAnchorKeyRef.current = null;
751 setDisclosureToggleSettling(false);
752 disclosureSettleFrameRef.current = null;
753 disclosureSettleSecondFrameRef.current = null;
754 // Wait for row measurement and the disclosure click's blur check.
755 // Closing output can reveal the end without a scroll event.
756 if (
757 disclosureCollapsedRef.current &&
758 resolveTimelineIsAtEnd(listRef.current?.getState()) === true
759 ) {
760 onToolOutputCollapsedAtEnd?.();
761 }
762 });
763 });
764 }, [listRef, onToolOutputCollapsedAtEnd, pinDisclosureToEnd]);
765
766 const suspendEndScrollMaintenanceForDisclosure = useCallback(
767 (anchorKey: string, collapsed = false) => {
768 disclosureAnchorKeyRef.current = anchorKey;
769 disclosureCollapsedRef.current = collapsed;
770 disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true;
771 setDisclosureToggleSettling(true);
772 scheduleDisclosureSettle();
773 },
774 [listRef, scheduleDisclosureSettle],
775 );
776
777 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
778 if (disclosurePinsEndRef.current) return false;
779 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
780 return disclosureAnchorKey === null || row.id === disclosureAnchorKey;
781 }, []);
782
783 const onToggleTurnFold = useCallback(
784 (runId: RunId) => {
785 suspendEndScrollMaintenanceForDisclosure(`turn-fold:${runId}`);
786 setExpandedRunIds((existing) => {
787 const next = new Set(existing);
788 if (next.has(runId)) {
789 next.delete(runId);
790 } else {
791 next.add(runId);
792 }
793 return next;
794 });
795 },
796 [suspendEndScrollMaintenanceForDisclosure],
797 );
798 const onToggleWorkGroup = useCallback(
799 (groupId: string, anchorKey: string) => {
800 suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId));
801 setExpandedWorkGroupIds((existing) => {
802 const next = new Set(existing);
803 if (next.has(groupId)) {
804 next.delete(groupId);
805 } else {
806 next.add(groupId);
807 }
808 return next;
809 });
810 },
811 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
812 );
813 const onToggleAttemptFold = useCallback(
814 (attemptId: RunAttemptId) => {
815 suspendEndScrollMaintenanceForDisclosure(`attempt-fold:${attemptId}`);
816 setExpandedAttemptIds((existing) => {
817 const next = new Set(existing);
818 if (next.has(attemptId)) {
819 next.delete(attemptId);
820 } else {
821 next.add(attemptId);
822 }
823 return next;
824 });
825 },
826 [suspendEndScrollMaintenanceForDisclosure],
827 );
828
829 // An in-session interrupt leaves its turn expanded so the user keeps their
830 // place; the next turn (or a reload, since this is local state) folds it.
831
832 useEffect(() => {
833 const previous = previousLatestRunRef.current;
834 previousLatestRunRef.current = latestRun;
835 if (!latestRun || previous?.runId === undefined) {
836 return;
837 }
838 if (latestRun.runId === previous.runId) {
839 if (previous.status === "running" && latestRun.status === "interrupted") {
840 setExpandedRunIds((existing) => {
841 const next = new Set(existing);
842 next.add(latestRun.runId);
843 return next;
844 });
845 }
846 return;
847 }
848 setExpandedRunIds((existing) => {
849 if (!existing.has(previous.runId)) {
850 return existing;
851 }
852 const next = new Set(existing);
853 next.delete(previous.runId);
854 return next;
855 });
856 }, [latestRun]);
857
858 // Find the fold that hides the match: imported turns have no run id but still
859 // fold under a synthetic key, so the match's own run id cannot open them.
860 const findFoldRunIds = useMemo(
861 () =>
862 findActive
863 ? timelineTurnFoldRunIdsByEntryId({
864 timelineEntries,
865 latestRun,
866 runningRunId,
867 isWorking,
868 runlessWorkActive,
869 })
870 : null,
871 [findActive, timelineEntries, latestRun, runningRunId, isWorking, runlessWorkActive],
872 );
873 const activeFindRunId = activeFindMatch
874 ? findFoldRunIds?.get(activeFindMatch.entryId)
875 : undefined;
876 useEffect(() => {
877 if (!activeFindRunId) return;
878 setExpandedRunIds((previous) =>
879 previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId),
880 );
881 }, [activeFindRunId]);
882 const visibleExpandedRunIds = useMemo(() => {
883 if (!activeFindRunId || paintedExpandedRunIds.has(activeFindRunId)) {
884 return paintedExpandedRunIds;
885 }
886 return new Set(paintedExpandedRunIds).add(activeFindRunId);
887 }, [activeFindRunId, paintedExpandedRunIds]);
888
889 const activeFindAttemptId = activeFindMatch
890 ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id
891 : undefined;
892 useEffect(() => {
893 if (!activeFindAttemptId) return;
894 setExpandedAttemptIds((previous) =>
895 previous.has(activeFindAttemptId) ? previous : new Set(previous).add(activeFindAttemptId),
896 );
897 }, [activeFindAttemptId]);
898 const visibleExpandedAttemptIds = useMemo(
899 () =>
900 activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId)
901 ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId)
902 : paintedExpandedAttemptIds,
903 [activeFindAttemptId, paintedExpandedAttemptIds],
904 );
905
906 const rowsProjectionRef = useRef<{
907 readonly threadKey: string;
908 readonly workspaceRoot: string | undefined;
909 readonly projection: MessagesTimelineRowsProjection;
910 } | null>(null);
911 const rawRows = useMemo(() => {
912 const previous = rowsProjectionRef.current;
913 const projection = deriveMessagesTimelineRowsWithState(
914 {
915 timelineEntries,
916 latestRun,
917 runningRunId,
918 expandedRunIds: visibleExpandedRunIds,
919 expandedAttemptIds: visibleExpandedAttemptIds,
920 expandedWorkGroupIds,
921 isWorking,
922 runlessWorkActive,
923 activeTurnStartedAt,
924 turnDiffSummaries,
925 supportsConversationRollback,
926 worktreeSetup,
927 },
928 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
929 ? previous.projection
930 : null,
931 );
932 rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection };
933 return projection.rows;
934 }, [
935 rowsProjectionRef,
936 listIdentityKey,
937 workspaceRoot,
938 timelineEntries,
939 latestRun,
940 runningRunId,
941 visibleExpandedRunIds,
942 visibleExpandedAttemptIds,
943 expandedWorkGroupIds,
944 isWorking,
945 runlessWorkActive,
946 activeTurnStartedAt,
947 turnDiffSummaries,
948 supportsConversationRollback,
949 worktreeSetup,
950 ]);
951 const rows = useStableRows(rawRows, listIdentityKey);
952 // Run status/timestamps churn on every stream event; the shared row context
953 // must not change with them or every timeline row re-renders per event.
954 const runs = useStableHandoffRuns(runsProp);
955 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
956 const restoreRowIndex =
957 restoringThreadPosition && rememberedPosition?.atEnd === false
958 ? rows.findIndex((row) => row.id === rememberedPosition.rowId)
959 : -1;
960 const restoringAlwaysRender = useMemo(
961 () =>
962 restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined,
963 [restoreRowIndex, restoringThreadPosition],
964 );
965 useLayoutEffect(() => {
966 if (!restoringThreadPosition || rows.length === 0) return;
967 const list = listRef.current;
968 if (!list) return;
969 if (citationRequest !== null) {
970 setPositionedThreadKey(listIdentityKey);
971 return;
972 }
973 let cancelled = false;
974 let settleFrame: number | null = null;
975 const viewport: HTMLElement | null = list.getScrollableNode();
976 const cancelRestoration = () => {
977 if (cancelled) return;
978 cancelled = true;
979 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
980 // Supersede any pending estimated-index scroll before the browser applies the gesture.
981 if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false });
982 setPositionedThreadKey(listIdentityKey);
983 };
984 const cancelForNavigation = () => {
985 cancelRestoration();
986 onManualNavigation();
987 };
988 const onScrollKey = (event: globalThis.KeyboardEvent) => {
989 if (
990 ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) &&
991 !(
992 event.target instanceof Element &&
993 event.target.closest("input, textarea, [contenteditable=true]")
994 )
995 )
996 cancelForNavigation();
997 };
998 viewport?.addEventListener("wheel", cancelForNavigation, { passive: true });
999 viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true });
1000 viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true });
1001 viewport?.ownerDocument.addEventListener("keydown", onScrollKey);
1002 const position = rememberedPosition;
1003 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
1004 if (position?.atEnd === false) onManualNavigation();
1005 if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration;
1006 const cleanup = () => {
1007 cancelled = true;
1008 if (cancelPositionRestoreRef?.current === cancelRestoration) {
1009 cancelPositionRestoreRef.current = null;
1010 }
1011 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
1012 viewport?.removeEventListener("wheel", cancelForNavigation);
1013 viewport?.removeEventListener("touchmove", cancelForNavigation);
1014 viewport?.removeEventListener("pointerdown", cancelForNavigation);
1015 viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
1016 };
1017 if (
1018 position?.atEnd === false &&
1019 index < 0 &&
1020 historyControls?.hasMoreHistory &&
1021 !historyControls.error
1022 ) {
1023 if (!historyControls.loading) historyControls.onLoadEarlier(position.rowId);
1024 return cleanup;
1025 }
1026 const scrolling =
1027 position?.atEnd === false
1028 ? index >= 0
1029 ? list.scrollToIndex({
1030 index,
1031 animated: false,
1032 viewPosition: 0,
1033 viewOffset: -position.offsetWithinRow,
1034 })
1035 : list.scrollToOffset({ offset: position.scrollOffset, animated: false })
1036 : list.scrollToEnd({ animated: false });
1037 void Promise.resolve(scrolling).then(() => {
1038 if (cancelled) return;
1039 if (position?.atEnd !== false || index < 0) {
1040 setPositionedThreadKey(listIdentityKey);
1041 return;
1042 }
1043 // Index scrolling starts from estimates. Keep the saved row mounted
1044 // until its measured position and the DOM agree for two layout frames.
1045 let stableFrames = 0;
1046 const reconcile = () => {
1047 if (cancelled) return;
1048 const state = list.getState();
1049 const rowIndex = state.indexByKey(position.rowId);
1050 const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex);
1051 const element = list.getScrollableNode();
1052 if (!row || !element) return;
1053 const offset = Math.max(
1054 0,
1055 Math.min(
1056 element.scrollTop +
1057 row.getBoundingClientRect().top -
1058 element.getBoundingClientRect().top +
1059 position.offsetWithinRow,
1060 element.scrollHeight - element.clientHeight,
1061 ),
1062 );
1063 if (Math.abs(element.scrollTop - offset) > 1) {
1064 stableFrames = 0;
1065 void list.scrollToOffset({ offset, animated: false }).then(() => {
1066 if (!cancelled) settleFrame = requestAnimationFrame(reconcile);
1067 });
1068 return;
1069 }
1070 if (++stableFrames >= 2) {
1071 setPositionedThreadKey(listIdentityKey);
1072 } else {
1073 settleFrame = requestAnimationFrame(reconcile);
1074 }
1075 };
1076 settleFrame = requestAnimationFrame(reconcile);
1077 });
1078 return cleanup;
1079 }, [
1080 citationRequest,
1081 cancelPositionRestoreRef,
1082 listIdentityKey,
1083 listRef,
1084 onManualNavigation,
1085 historyControls,
1086 rememberedPosition,
1087 restoringThreadPosition,
1088 rows,
1089 ]);
1090
1091 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
1092 null,
1093 );
1094 // Re-measure the minimap gutter when the chat column changes width without a viewport resize.
1095 const chatWidth = useClientSettings((settings) => settings.chatWidth);
1096 const {
1097 target: readyCitationRequest,
1098 positioning: citationPositioning,
1099 onListLoad: onCitationListLoad,
1100 alwaysRender: citationAlwaysRender,
1101 } = useAssistantCitationTarget({
1102 request: citationRequest,
1103 entries: timelineEntries,
1104 rows,
1105 listRef,
1106 viewport: timelineViewportElement,
1107 historyLoading: citationHistoryLoading,
1108 loadEarlier,
1109 onExpandTurn: expandCitedRun,
1110 onManualNavigation,
1111 });
1112 const [findListReady, setFindListReady] = useState(false);
1113 const handleListLoad = useCallback(() => {
1114 onCitationListLoad();
1115 setFindListReady(true);
1116 }, [onCitationListLoad]);
1117 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
1118 const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null);
1119 // Only the row that holds the pin can release it.
1120 const onAppFullscreenChange = useCallback((rowId: string, fullscreen: boolean) => {
1121 setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current));
1122 }, []);
1123 // Every pin holds at once, so navigating to a citation or restoring a
1124 // position never drops a full-screen app's row. The app is pinned by key,
1125 // which stays right as earlier rows load in.
1126 const alwaysRender = useMemo(() => {
1127 const indices = restoringAlwaysRender?.indices ?? [];
1128 const keys = [
1129 ...(citationAlwaysRender?.keys ?? []),
1130 ...(fullscreenAppRowId === null ? [] : [fullscreenAppRowId]),
1131 ];
1132 if (indices.length === 0 && keys.length === 0) return undefined;
1133 return {
1134 ...(indices.length === 0 ? {} : { indices }),
1135 ...(keys.length === 0 ? {} : { keys }),
1136 };
1137 }, [citationAlwaysRender, restoringAlwaysRender, fullscreenAppRowId]);
1138 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
1139 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
1140 const handleAnchorReady = useCallback(
1141 (info: { anchorIndex: number | undefined }) => {
1142 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
1143 onAnchorReady(anchorMessageId, info.anchorIndex);
1144 }
1145 },
1146 [anchorMessageId, onAnchorReady],
1147 );
1148 const handleAnchorSizeChanged = useCallback(
1149 (size: number) => {
1150 if (anchorMessageId !== null) {
1151 onAnchorSizeChanged(anchorMessageId, size);
1152 }
1153 },
1154 [anchorMessageId, onAnchorSizeChanged],
1155 );
1156 const anchoredEndSpace = useMemo(() => {
1157 const config = resolveChatListAnchoredEndSpace(
1158 rows,
1159 anchorMessageId,
1160 (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
1161 { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
1162 );
1163 return config
1164 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }
1165 : undefined;
1166 }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]);
1167 const maintainVisibleContentPosition = useMemo(
1168 () => ({
1169 data: true,
1170 size: true,
1171 shouldRestorePosition: shouldRestoreVisibleContentPosition,
1172 }),
1173 [shouldRestoreVisibleContentPosition],
1174 );
1175 const timelineListFooter = useMemo(
1176 () => (
1177 <TimelineListFooter composerInset={anchoredEndSpace ? 0 : contentInsetEndAdjustment}>
1178 {footer}
1179 </TimelineListFooter>
1180 ),
1181 [anchoredEndSpace, contentInsetEndAdjustment, footer],
1182 );
1183
1184 const measureContentOverflow = useCallback(
1185 () =>
1186 timelineContentOverflowsViewport(listRef.current?.getState?.(), {
1187 composerInset: contentInsetEndAdjustment,
1188 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
1189 }),
1190 [contentInsetEndAdjustment, listRef],
1191 );
1192 // LegendList lays rows out from layout effects, so a read on the next frame
1193 // sees the settled positions. One frame is shared across bursts of size
1194 // changes.
1195 const contentOverflowFrameRef = useRef<number | null>(null);
1196 const cancelContentOverflowFrame = useCallback(() => {
1197 if (contentOverflowFrameRef.current !== null) {
1198 cancelAnimationFrame(contentOverflowFrameRef.current);
1199 contentOverflowFrameRef.current = null;
1200 }
1201 }, []);
1202 const reportContentOverflow = useCallback(() => {
1203 if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return;
1204 contentOverflowFrameRef.current = requestAnimationFrame(() => {
1205 contentOverflowFrameRef.current = null;
1206 onContentOverflowChange(measureContentOverflow());
1207 });
1208 }, [measureContentOverflow, onContentOverflowChange]);
1209 const handleItemSizeChanged = useCallback(() => {
1210 if (disclosurePinsEndRef.current) {
1211 queueMicrotask(pinDisclosureToEnd);
1212 scheduleDisclosureSettle();
1213 }
1214 reportContentOverflow();
1215 }, [pinDisclosureToEnd, reportContentOverflow, scheduleDisclosureSettle]);
1216 useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]);
1217 // The list's own layout effects have already run here, so estimated row
1218 // positions are in place. Reporting before the first paint lets a thread
1219 // open in its final composer layout instead of correcting it a frame later.
1220 // A frame scheduled with the previous inset would overwrite this read, so
1221 // it is dropped first.
1222 useLayoutEffect(() => {
1223 cancelContentOverflowFrame();
1224 onContentOverflowChange?.(measureContentOverflow());
1225 }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]);
1226
1227 const handleScroll = useCallback(() => {
1228 const state = listRef.current?.getState?.();
1229 if (restoringThreadPosition || state?.data !== rows) return;
1230 const isAtEnd = resolveTimelineIsAtEnd(state);
1231 const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined;
1232 if (position && state && isAtEnd !== undefined && !paintOnly) {
1233 const index = state.indexByKey(position.rowId);
1234 const row = index === undefined ? undefined : state.elementAtIndex(index);
1235 const element = listRef.current?.getScrollableNode();
1236 if (row && element) {
1237 rememberTimelinePosition(
1238 listIdentityKey,
1239 {
1240 ...position,
1241 // DOM geometry includes the header and the virtualizer's layout adjustment.
1242 offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top,
1243 scrollOffset: element.scrollTop,
1244 atEnd: isAtEnd,
1245 disclosures: {
1246 runs: paintedExpandedRunIds,
1247 workGroups: paintedExpandedWorkGroupIds,
1248 attempts: paintedExpandedAttemptIds,
1249 workGroupState: workGroupViewState,
1250 },
1251 },
1252 liveFollowEnabled && !anchoredEndSpace,
1253 );
1254 }
1255 }
1256 if (isAtEnd !== undefined && !citationPositioning) {
1257 onIsAtEndChange(isAtEnd);
1258 }
1259 reportContentOverflow();
1260 if (!state || minimapItems.length === 0) {
1261 return;
1262 }
1263
1264 const scrollTop = state.scroll ?? 0;
1265 const scrollBottom = scrollTop + (state.scrollLength ?? 0);
1266
1267 const itemBounds = minimapItems.map((item) => ({
1268 top: resolveTimelineRowTop(state, item.rowIndex),
1269 height: resolveTimelineRowHeight(state, item.rowIndex),
1270 }));
1271
1272 for (const [index, item] of minimapItems.entries()) {
1273 const strip = minimapStripMap.get(item.id);
1274 const bounds = itemBounds[index];
1275 const rowTop = bounds?.top ?? null;
1276 const rowHeight = bounds?.height ?? null;
1277 const inView =
1278 rowTop !== null &&
1279 rowTop < scrollBottom &&
1280 rowTop + Math.max(1, rowHeight ?? 1) > scrollTop;
1281
1282 // Skip no-op attribute writes: this runs for every strip on every scroll
1283 // tick, and rewriting an unchanged attribute still dirties style state.
1284 const next = inView ? "true" : "false";
1285 if (strip && strip.dataset.inView !== next) {
1286 strip.dataset.inView = next;
1287 }
1288 }
1289 const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({
1290 scrollTop,
1291 scrollBottom,
1292 itemBounds,
1293 });
1294 setMinimapCurrentIndex((current) =>
1295 current === nextCurrentIndex ? current : nextCurrentIndex,
1296 );
1297 }, [
1298 citationPositioning,
1299 paintedExpandedRunIds,
1300 paintedExpandedWorkGroupIds,
1301 paintedExpandedAttemptIds,
1302 workGroupViewState,
1303 rows,
1304 listIdentityKey,
1305 liveFollowEnabled,
1306 anchoredEndSpace,
1307 paintOnly,
1308 restoringThreadPosition,
1309 listRef,
1310 minimapItems,
1311 minimapStripMap,
1312 onIsAtEndChange,
1313 reportContentOverflow,
1314 ]);
1315
1316 useEffect(() => {
1317 const frame = requestAnimationFrame(handleScroll);
1318 return () => cancelAnimationFrame(frame);
1319 }, [handleScroll, rows.length]);
1320
1321 const savedLiveFollowEnabledRef = useRef(liveFollowEnabled);
1322 useLayoutEffect(() => {
1323 const wasFollowing = savedLiveFollowEnabledRef.current;
1324 savedLiveFollowEnabledRef.current = liveFollowEnabled;
1325 if (wasFollowing && !liveFollowEnabled) handleScroll();
1326 }, [handleScroll, liveFollowEnabled]);
1327
1328 useEffect(() => {
1329 if (!timelineViewportElement) {
1330 return;
1331 }
1332
1333 const measure = () => {
1334 const viewportWidth = timelineViewportElement.getBoundingClientRect().width;
1335 // Without a mounted row, treat the column as full width so the strip stays inert.
1336 const contentWidth =
1337 timelineViewportElement
1338 .querySelector<HTMLElement>("[data-timeline-root]")
1339 ?.getBoundingClientRect().width ?? viewportWidth;
1340 const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
1341 viewportWidth,
1342 contentWidth,
1343 );
1344 setMinimapHasPersistentGutter((current) =>
1345 current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
1346 );
1347 setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
1348 reportContentOverflow();
1349 };
1350
1351 const frame = requestAnimationFrame(measure);
1352
1353 const stopObserving = observeResize(timelineViewportElement, measure);
1354
1355 return () => {
1356 cancelAnimationFrame(frame);
1357 stopObserving();
1358 };
1359 }, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);
1360
1361 const sharedState = useMemo<TimelineRowSharedState>(
1362 () => ({
1363 citationRequest: readyCitationRequest,
1364 listRef,
1365 timestampFormat,
1366 routeThreadKey,
1367 // Keep Markdown callbacks memoized during unrelated activity updates.
1368 threadRef: citationThreadRef,
1369 markdownCwd,
1370 resolvedTheme,
1371 workspaceRoot,
1372 skills,
1373 providerStatuses,
1374 runs,
1375 activeThreadEnvironmentId,
1376 onRevertToTurnCount,
1377 onRunShellCommand,
1378 findActive,
1379 onImageExpand,
1380 onFileOpen,
1381 onUseArtifactTemplate,
1382 onSendAppMessage,
1383 onAppFullscreenChange,
1384 onFileDownload,
1385 openPullRequest,
1386 onOpenTurnDiff,
1387 onOpenThread,
1388 onForkFromRun,
1389 onRollbackCheckpoint,
1390 onToggleTurnFold,
1391 onToggleAttemptFold,
1392 onToggleWorkGroup,
1393 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
1394 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
1395 retryableWorkspacePreparationRunIds,
1396 onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null,
1397 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
1398 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
1399 workGroupViewState,
1400 }),
1401 [
1402 readyCitationRequest,
1403 listRef,
1404 timestampFormat,
1405 routeThreadKey,
1406 citationThreadRef,
1407 markdownCwd,
1408 resolvedTheme,
1409 workspaceRoot,
1410 skills,
1411 providerStatuses,
1412 runs,
1413 activeThreadEnvironmentId,
1414 onRevertToTurnCount,
1415 onRunShellCommand,
1416 findActive,
1417 onImageExpand,
1418 onFileOpen,
1419 onUseArtifactTemplate,
1420 onSendAppMessage,
1421 onAppFullscreenChange,
1422 onFileDownload,
1423 openPullRequest,
1424 onOpenTurnDiff,
1425 onOpenThread,
1426 onForkFromRun,
1427 onRollbackCheckpoint,
1428 onToggleTurnFold,
1429 onToggleAttemptFold,
1430 onToggleWorkGroup,
1431 suspendEndScrollMaintenanceForDisclosure,
1432 onCancelWorktreeSetup,
1433 retryableWorkspacePreparationRunIds,
1434 onRetryWorkspacePreparation,
1435 onWorktreeSetupWorkLocally,
1436 onOpenWorktreeSetupTerminal,
1437 workGroupViewState,
1438 ],
1439 );
1440 const compactionAwaitingRow =
1441 isCompacting &&
1442 !rows.some(
1443 (row) =>
1444 (row.kind === "context-compaction" && row.active) ||
1445 (row.kind === "working" && row.compaction !== undefined),
1446 );
1447 const backgroundWorktreeSetup =
1448 worktreeSetup !== null &&
1449 worktreeSetup.phase === "running" &&
1450 worktreeSetupAgentStarted(worktreeSetup) &&
1451 latestRun?.startedAt != null
1452 ? worktreeSetup
1453 : null;
1454 const activityState = useMemo<TimelineRowActivityState>(
1455 () => ({
1456 isWorking,
1457 isCompacting: compactionAwaitingRow,
1458 isRevertingCheckpoint,
1459 backgroundWorktreeSetup,
1460 activeTurnInProgress,
1461 awaitingUser,
1462 isPreparingWorktree,
1463 latestRunId: latestRun?.runId ?? null,
1464 }),
1465 [
1466 awaitingUser,
1467 compactionAwaitingRow,
1468 backgroundWorktreeSetup,
1469 activeTurnInProgress,
1470 isPreparingWorktree,
1471 isRevertingCheckpoint,
1472 isWorking,
1473 latestRun?.runId,
1474 ],
1475 );
1476 const listHeader = useMemo(() => {
1477 const leadingContent =
1478 parentThreadLink === null ? (
1479 topFadeEnabled ? (
1480 TIMELINE_LIST_FADE_HEADER
1481 ) : (
1482 TIMELINE_LIST_HEADER
1483 )
1484 ) : (
1485 <div className="messages-timeline-row-frame">
1486 <div className="chat-content-lane pt-1 sm:pt-2">
1487 <TimelineSystemDivider
1488 label="Subagent of"
1489 detail={parentThreadLink.title}
1490 icon={BotIcon}
1491 actionLabel="Open parent thread"
1492 onAction={() => onOpenThread(parentThreadLink.threadId)}
1493 />
1494 </div>
1495 </div>
1496 );
1497 return (
1498 <>
1499 {parentThreadLink === null ? leadingContent : null}
1500 {historyControls ? <TimelineHistoryControl {...historyControls} /> : null}
1501 {parentThreadLink !== null ? leadingContent : null}
1502 </>
1503 );
1504 }, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]);
1505
1506 const canvas = useChatCanvas();
1507 const registerTimeline = canvas?.registerTimeline;
1508 const setTimelineList = useCallback(
1509 (list: LegendListRef | null) => {
1510 listRef.current = list;
1511 registerTimeline?.(list?.getScrollableNode() ?? null);
1512 },
1513 [listRef, registerTimeline],
1514 );
1515
1516 useLayoutEffect(() => {
1517 if (!findPositionReaderRef || !timelineViewportElement) return;
1518 const read: ThreadFindPositionReader = (query) => {
1519 // Imported turns fold under a synthetic key, so match folds by key, not run id.
1520 const foldRunIds =
1521 findFoldRunIds ??
1522 timelineTurnFoldRunIdsByEntryId({
1523 timelineEntries,
1524 latestRun,
1525 runningRunId,
1526 isWorking,
1527 runlessWorkActive,
1528 });
1529 const entryById = new Map(timelineEntries.map((entry) => [entry.id, entry]));
1530 return readThreadFindPosition(
1531 timelineViewportElement,
1532 query,
1533 (rowId) => {
1534 const rowIndex = rows.findIndex((row) => row.id === rowId);
1535 for (const row of rows.slice(Math.max(0, rowIndex))) {
1536 // Ordinary rows map to one entry by id; only folds need a scan.
1537 if (row.kind !== "turn-fold" && row.kind !== "attempt-fold") {
1538 const entry = entryById.get(row.kind === "assistant-meta" ? row.message.id : row.id);
1539 if (entry && (entry.kind === "message" || entry.kind === "proposed-plan"))
1540 return entry.id;
1541 continue;
1542 }
1543 const entry = timelineEntries.find((entry) => {
1544 const runId =
1545 row.kind === "turn-fold"
1546 ? foldRunIds.get(entry.id)
1547 : entry.kind === "message"
1548 ? entry.message.runId
1549 : entry.kind === "proposed-plan"
1550 ? entry.proposedPlan.runId
1551 : null;
1552 return (
1553 runId === row.runId &&
1554 (entry.kind !== "message" || entry.message.role === "assistant") &&
1555 (row.kind !== "attempt-fold" || entry.attempt?.id === row.attemptId)
1556 );
1557 });
1558 if (entry && (entry.kind === "message" || entry.kind === "proposed-plan"))
1559 return entry.id;
1560 }
1561 return undefined;
1562 },
1563 contentInsetEndAdjustment,
1564 );
1565 };
1566 findPositionReaderRef.current = read;
1567 return () => {
1568 if (findPositionReaderRef.current === read) findPositionReaderRef.current = null;
1569 };
1570 }, [
1571 contentInsetEndAdjustment,
1572 findFoldRunIds,
1573 findPositionReaderRef,
1574 isWorking,
1575 latestRun,
1576 rows,
1577 runlessWorkActive,
1578 runningRunId,
1579 timelineEntries,
1580 timelineViewportElement,
1581 ]);
1582
1583 useThreadFindNavigation({
1584 listReady: findListReady,
1585 historyControls,
1586 entries: timelineEntries,
1587 container: timelineViewportElement,
1588 query: normalizedFindQuery,
1589 match: activeFindMatch,
1590 navigationId: findNavigationId,
1591 rowIndex: activeFindMatch ? rows.findIndex((row) => row.id === activeFindMatch.entryId) : -1,
1592 listRef,
1593 contentInsetEndAdjustment,
1594 });
1595
1596 // Stable renderItem — no closure deps. Row components read shared state
1597 // from TimelineRowCtx, which propagates through LegendList's memo.
1598 const renderItem = useCallback(
1599 ({ item }: { item: MessagesTimelineRow }) => (
1600 <div className="messages-timeline-row-frame">
1601 <div className="chat-content-lane" data-timeline-root="true">
1602 <TimelineRowContent row={item} />
1603 </div>
1604 </div>
1605 ),
1606 [],
1607 );
1608
1609 const forwardMinimapWheel = useCallback(
1610 (event: WheelEvent) => {
1611 const scrollNode = listRef.current?.getScrollableNode();
1612 if (scrollNode) forwardWheelToTimeline(event, scrollNode);
1613 },
1614 [listRef],
1615 );
1616
1617 if (
1618 rows.length === 0 &&
1619 !isWorking &&
1620 parentThreadLink === null &&
1621 historyControls === undefined &&
1622 // A status line (settled, snoozed) still needs the list, whose footer renders it.
1623 footer === null
1624 ) {
1625 if (hideEmptyPlaceholder) {
1626 // Occupy the pane with the theme surface so a thread switch cannot
1627 // punch a hole through to the window chrome (white in light mode).
1628 return <div className="h-full min-h-0 bg-background" data-timeline-loading="true" />;
1629 }
1630 return (
1631 <div
1632 className="flex h-full items-center justify-center ps-(--chat-lane-inset-start) pe-(--chat-lane-inset-end)"
1633 style={{ paddingBottom: contentInsetEndAdjustment }}
1634 >
1635 <p className="text-sm text-muted-foreground/30">
1636 Send a message to start the conversation.
1637 </p>
1638 </div>
1639 );
1640 }
1641
1642 return (
1643 <MarkdownFindContext value={findActive}>
1644 <TimelineRowCtx value={sharedState}>
1645 <TimelineRowActivityCtx value={activityState}>
1646 <TooltipScrollDismissArea
1647 ref={setTimelineViewportElement}
1648 className="relative h-full min-h-0"
1649 data-assistant-citation-viewport="true"
1650 >
1651 {onCiteAssistantText && citationThreadRef ? (
1652 <AssistantSelectionToolbar
1653 viewport={timelineViewportElement}
1654 threadRef={citationThreadRef}
1655 onCite={onCiteAssistantText}
1656 />
1657 ) : null}
1658 <LegendList<MessagesTimelineRow>
1659 ref={setTimelineList}
1660 data={rows}
1661 extraData={`${listIdentityKey}:${rows.length}`}
1662 keyExtractor={keyExtractor}
1663 getItemType={getItemType}
1664 renderItem={renderItem}
1665 estimatedItemSize={90}
1666 initialScrollAtEnd={
1667 !findActive && citationRequest === null && rememberedPosition?.atEnd !== false
1668 }
1669 // Legend needs a data refresh to mount new pins without a scroll event.
1670 dataVersion={readyCitationRequest?.key ?? listIdentityKey}
1671 {...(alwaysRender ? { alwaysRender } : {})}
1672 onLoad={handleListLoad}
1673 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1674 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
1675 maintainScrollAtEnd={
1676 citationPositioning ||
1677 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
1678 anchoredEndSpace ||
1679 !liveFollowEnabled ||
1680 fullscreenAppRowId !== null ||
1681 disclosureToggleSettling
1682 ? false
1683 : isWorking && !prefersReducedMotion && settlingListIdentity === null
1684 ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH
1685 : TIMELINE_MAINTAIN_SCROLL_AT_END
1686 }
1687 maintainVisibleContentPosition={
1688 findActive ||
1689 citationPositioning ||
1690 (restoringThreadPosition && rememberedPosition?.atEnd === false)
1691 ? false
1692 : maintainVisibleContentPosition
1693 }
1694 maintainScrollAtEndThreshold={1}
1695 onScroll={handleScroll}
1696 onItemSizeChanged={handleItemSizeChanged}
1697 className={cn(
1698 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
1699 topFadeEnabled && "topbar-scroll-fade",
1700 )}
1701 ListHeaderComponent={listHeader}
1702 ListFooterComponent={timelineListFooter}
1703 />
1704 <TimelineMinimap
1705 items={minimapItems}
1706 hasPersistentGutter={minimapHasPersistentGutter}
1707 hitStripWidth={minimapHitStripWidth}
1708 currentIndex={minimapCurrentIndex}
1709 stripMap={minimapStripMap}
1710 onWheel={forwardMinimapWheel}
1711 onSelect={(item) => {
1712 onManualNavigation();
1713 void listRef.current?.scrollToIndex({
1714 index: item.rowIndex,
1715 animated: true,
1716 viewOffset: 24,
1717 });
1718 }}
1719 />
1720 </TooltipScrollDismissArea>
1721 </TimelineRowActivityCtx>
1722 </TimelineRowCtx>
1723 </MarkdownFindContext>
1724 );
1725});
1726
1727function TimelineHistoryControl(props: MessagesTimelineHistoryControls) {
1728 if (!props.hasMoreHistory && props.error === null) {
1729 return null;
1730 }
1731 return (
1732 <div className="messages-timeline-row-frame">
1733 <div className="chat-content-lane flex flex-col gap-1.5 pb-2">
1734 {props.hasMoreHistory ? (
1735 <button
1736 type="button"
1737 disabled={props.loading}
1738 aria-label="Load earlier turns"
1739 onClick={() => props.onLoadEarlier()}
1740 className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default"
1741 >
1742 {props.loading ? "Loading earlier turns…" : "Load earlier turns"}
1743 </button>
1744 ) : null}
1745 {props.error !== null ? (
1746 <p role="status" className="text-center text-muted-foreground text-xs">
1747 {props.error}
1748 </p>
1749 ) : null}
1750 </div>
1751 </div>
1752 );
1753}
1754
1755function keyExtractor(item: MessagesTimelineRow) {
1756 return item.id;
1757}
1758
1759function getItemType(item: MessagesTimelineRow) {
1760 return item.kind === "message" ? `message:${item.message.role}` : item.kind;
1761}
1762
1763interface TimelinePositionState {
1764 readonly contentLength?: number;
1765 readonly scroll?: number;
1766 readonly scrollLength?: number;
1767 readonly positionAtIndex?: (index: number) => number | undefined;
1768 readonly sizeAtIndex?: (index: number) => number | undefined;
1769}
1770
1771function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) {
1772 const top = state.positionAtIndex?.(rowIndex);
1773 return typeof top === "number" && Number.isFinite(top) ? top : null;
1774}
1775
1776function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) {
1777 const height = state.sizeAtIndex?.(rowIndex);
1778 return typeof height === "number" && Number.isFinite(height) ? height : null;
1779}
1780
1781function timelineMinimapEventTargetsPreview(target: EventTarget): boolean {
1782 return target instanceof Element && target.closest("[data-minimap-preview]") !== null;
1783}
1784
1785function TimelineMinimap({
1786 hasPersistentGutter,
1787 hitStripWidth,
1788 currentIndex,
1789 items,
1790 stripMap,
1791 onWheel,
1792 onSelect,
1793}: {
1794 hasPersistentGutter: boolean;
1795 hitStripWidth: number;
1796 currentIndex: number | null;
1797 items: ReadonlyArray<TimelineMinimapItem>;
1798 stripMap: Map<string, HTMLSpanElement>;
1799 onWheel: (event: WheelEvent) => void;
1800 onSelect: (item: TimelineMinimapItem) => void;
1801}) {
1802 const [activeIndex, setActiveIndex] = useState<number | null>(null);
1803 const wheelTargetRef = useCallback(
1804 (node: HTMLDivElement | null) => {
1805 if (!node) return;
1806 node.addEventListener("wheel", onWheel, { passive: false });
1807 return () => node.removeEventListener("wheel", onWheel);
1808 },
1809 [onWheel],
1810 );
1811
1812 const resolvedActiveIndex =
1813 activeIndex !== null && activeIndex < items.length ? activeIndex : null;
1814 const activeItem = useMemo(
1815 () =>
1816 resolveTimelineMinimapPreview(
1817 resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null),
1818 ),
1819 [items, resolvedActiveIndex],
1820 );
1821 const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
1822 const activeTopPercent =
1823 resolvedActiveIndex === null
1824 ? 0
1825 : resolveTimelineMinimapTopPercent(resolvedActiveIndex, items.length);
1826 const activeTooltipTranslate =
1827 resolvedActiveIndex === null
1828 ? "-50%"
1829 : resolvedActiveIndex === 0
1830 ? "0%"
1831 : resolvedActiveIndex === items.length - 1
1832 ? "-100%"
1833 : "-50%";
1834 const resolvedCurrentIndex =
1835 currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null;
1836 const previousItem =
1837 resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null);
1838 const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null);
1839
1840 const resolveActiveIndexFromPointer = useCallback(
1841 (event: MouseEvent<HTMLElement>) => {
1842 const rect = event.currentTarget.getBoundingClientRect();
1843 return resolveTimelineMinimapIndexFromPointer({
1844 itemCount: items.length,
1845 railTop: rect.top,
1846 railHeight: rect.height,
1847 pointerY: event.clientY,
1848 });
1849 },
1850 [items.length],
1851 );
1852
1853 const updateActiveIndexFromPointer = useCallback(
1854 (event: MouseEvent<HTMLElement>) => {
1855 const nextIndex = resolveActiveIndexFromPointer(event);
1856 setActiveIndex(nextIndex);
1857 },
1858 [resolveActiveIndexFromPointer],
1859 );
1860
1861 const moveActiveIndex = useCallback(
1862 (delta: number) => {
1863 setActiveIndex((current) => {
1864 const base = current ?? 0;
1865 return Math.max(0, Math.min(items.length - 1, base + delta));
1866 });
1867 },
1868 [items.length],
1869 );
1870
1871 if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) {
1872 return null;
1873 }
1874
1875 return (
1876 <div
1877 className={cn(
1878 "group/minimap pointer-events-none absolute inset-y-0 left-0 z-40 hidden w-18 [@media(pointer:fine)]:block",
1879 hasPersistentGutter
1880 ? "opacity-100"
1881 : "opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
1882 )}
1883 data-testid="timeline-minimap"
1884 data-persistent-gutter={hasPersistentGutter ? "true" : "false"}
1885 ref={wheelTargetRef}
1886 >
1887 <div className="relative h-full w-full select-none">
1888 <div
1889 className={cn(
1890 "absolute top-1/2 left-3 -translate-y-1/2",
1891 // The strip is width-capped to the side gutter so it never overlays
1892 // the centered content column; with no usable gutter it goes inert.
1893 hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
1894 )}
1895 style={{
1896 height: resolveTimelineMinimapHeightStyle(items.length),
1897 width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null),
1898 }}
1899 >
1900 <TimelineMinimapNavigationButton
1901 direction="previous"
1902 disabled={previousItem === null}
1903 interactive={navigationInteractive}
1904 onClick={() => {
1905 if (previousItem) onSelect(previousItem);
1906 }}
1907 />
1908 <button
1909 aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
1910 className="absolute inset-y-0 left-0 w-full cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1911 onBlur={() => setActiveIndex(null)}
1912 onClick={(event) => {
1913 if (timelineMinimapEventTargetsPreview(event.target)) {
1914 return;
1915 }
1916 const nextIndex = resolveActiveIndexFromPointer(event);
1917 const selectedItem = nextIndex === null ? null : (items[nextIndex] ?? null);
1918 if (selectedItem) {
1919 onSelect(selectedItem);
1920 }
1921 event.currentTarget.blur();
1922 }}
1923 onFocus={() => setActiveIndex((current) => current ?? resolvedCurrentIndex ?? 0)}
1924 onKeyDown={(event) => {
1925 if (event.key === "ArrowDown") {
1926 event.preventDefault();
1927 moveActiveIndex(1);
1928 } else if (event.key === "ArrowUp") {
1929 event.preventDefault();
1930 moveActiveIndex(-1);
1931 } else if (event.key === "Home") {
1932 event.preventDefault();
1933 setActiveIndex(0);
1934 } else if (event.key === "End") {
1935 event.preventDefault();
1936 setActiveIndex(items.length - 1);
1937 } else if (event.key === "Enter" || event.key === " ") {
1938 event.preventDefault();
1939 if (activeItem) {
1940 onSelect(activeItem);
1941 }
1942 }
1943 }}
1944 onMouseLeave={() => setActiveIndex(null)}
1945 onMouseMove={updateActiveIndexFromPointer}
1946 onMouseDown={(event) => {
1947 if (timelineMinimapEventTargetsPreview(event.target)) {
1948 return;
1949 }
1950 event.preventDefault();
1951 }}
1952 type="button"
1953 >
1954 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
1955 {items.map((item, index) => {
1956 const top = `${resolveTimelineMinimapTopPercent(index, items.length)}%`;
1957 const activeDistance =
1958 resolvedActiveIndex === null ? null : Math.abs(index - resolvedActiveIndex);
1959 return (
1960 // Compositor-friendly on purpose: in-view state and the hover
1961 // fisheye animate constantly (every scroll tick and streaming
1962 // update flips a band of strips), so the strip animates only
1963 // transform and opacity. Width tiers are a scale-x on a fixed
1964 // w-6 box, and the in-view highlight is an opacity-faded bright
1965 // overlay — never background-color or width, which would force
1966 // main-thread style/layout/paint at 60fps for each transition.
1967 <span
1968 aria-hidden="true"
1969 className={cn(
1970 "group/strip pointer-events-none absolute left-0 h-0.5 w-6 origin-left -translate-y-1/2 rounded-full transition-transform duration-150",
1971 activeDistance === 0 ? "bg-muted-foreground/75" : "bg-muted-foreground/35",
1972 activeDistance === 0
1973 ? "scale-x-100"
1974 : activeDistance === 1
1975 ? "scale-x-[0.667]"
1976 : activeDistance === 2
1977 ? "scale-x-[0.417]"
1978 : "scale-x-[0.333]",
1979 )}
1980 data-in-view="false"
1981 data-minimap-strip
1982 key={item.id}
1983 ref={(node) => {
1984 if (node) {
1985 stripMap.set(item.id, node);
1986 } else {
1987 stripMap.delete(item.id);
1988 }
1989 }}
1990 style={{ top }}
1991 >
1992 <span className="absolute inset-0 rounded-full bg-foreground/90 opacity-0 transition-opacity duration-150 group-data-[in-view=true]/strip:opacity-100" />
1993 </span>
1994 );
1995 })}
1996 {activeItem ? (
1997 <span
1998 className="pointer-events-auto absolute left-8 w-80 cursor-text select-text"
1999 data-minimap-preview
2000 onMouseMove={(event) => event.stopPropagation()}
2001 style={{
2002 top: `${activeTopPercent}%`,
2003 transform: `translateY(${activeTooltipTranslate})`,
2004 }}
2005 >
2006 <span className="dropdown-glass block rounded-xl p-3 text-left text-popover-foreground shadow-xl shadow-black/25">
2007 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
2008 {activeItem.userText ?? "User message"}
2009 </span>
2010 {activeItem.assistantText ? (
2011 <span
2012 className="mt-1 max-h-[3.75rem] overflow-hidden text-muted-foreground text-sm leading-5"
2013 style={{
2014 display: "-webkit-box",
2015 WebkitBoxOrient: "vertical",
2016 WebkitLineClamp: 3,
2017 }}
2018 >
2019 {activeItem.assistantText}
2020 </span>
2021 ) : null}
2022 </span>
2023 </span>
2024 ) : null}
2025 </button>
2026 <TimelineMinimapNavigationButton
2027 direction="next"
2028 disabled={nextItem === null}
2029 interactive={navigationInteractive}
2030 onClick={() => {
2031 if (nextItem) onSelect(nextItem);
2032 }}
2033 />
2034 </div>
2035 </div>
2036 </div>
2037 );
2038}
2039
2040function TimelineMinimapNavigationButton({
2041 direction,
2042 disabled,
2043 interactive,
2044 onClick,
2045}: {
2046 direction: "previous" | "next";
2047 disabled: boolean;
2048 interactive: boolean;
2049 onClick: () => void;
2050}) {
2051 const previous = direction === "previous";
2052 const label = previous ? "Previous turn" : "Next turn";
2053 const Icon = previous ? ChevronUpIcon : ChevronDownIcon;
2054
2055 return (
2056 <Tooltip>
2057 <TooltipTrigger
2058 render={
2059 <span
2060 className={cn(
2061 "absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
2062 interactive ? "pointer-events-auto" : "pointer-events-none",
2063 previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",
2064 )}
2065 />
2066 }
2067 >
2068 <Button
2069 aria-label={label}
2070 disabled={disabled}
2071 onClick={onClick}
2072 size="icon-micro"
2073 type="button"
2074 variant="ghost-muted"
2075 >
2076 <Icon className="size-4 text-foreground/90" />
2077 </Button>
2078 </TooltipTrigger>
2079 <TooltipPopup side={previous ? "top" : "bottom"}>{label}</TooltipPopup>
2080 </Tooltip>
2081 );
2082}
2083
2084// ---------------------------------------------------------------------------
2085// TimelineRowContent — the actual row component
2086// ---------------------------------------------------------------------------
2087
2088type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
2089type TimelineRow = MessagesTimelineRow;
2090
2091const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
2092 const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup;
2093 const isSubagentGroup = row.kind === "event" && row.projectedItem.item.type === "subagent";
2094 const isWorkLogRow =
2095 row.kind === "work" ||
2096 row.kind === "work-live" ||
2097 row.kind === "work-toggle" ||
2098 row.kind === "thinking";
2099 const isExpandedToolGroupHeader =
2100 (row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") &&
2101 row.expanded === true;
2102
2103 return (
2104 <div
2105 className={cn(
2106 // Commentary (non-terminal assistant) rows carry no metadata row, so
2107 // they sit closer to the work that follows them.
2108 isWorkLogRow || isSubagentGroup
2109 ? undefined
2110 : row.kind === "turn-fold" || row.kind === "working"
2111 ? "pb-1.5"
2112 : (row.kind === "message" &&
2113 row.message.role === "assistant" &&
2114 !row.showAssistantMeta) ||
2115 row.kind === "worktree-setup" ||
2116 row.kind === "event" ||
2117 row.kind === "attempt-fold" ||
2118 row.kind === "html-render" ||
2119 row.kind === "mcp-app"
2120 ? "pb-2"
2121 : "pb-4",
2122 (row.kind === "message" && row.message.role === "assistant") ||
2123 row.kind === "assistant-meta"
2124 ? "group/assistant"
2125 : null,
2126 )}
2127 data-timeline-row-id={row.id}
2128 data-timeline-row-kind={row.kind}
2129 data-message-id={
2130 row.kind === "message" || row.kind === "assistant-meta" ? row.message.id : undefined
2131 }
2132 data-message-role={row.kind === "message" ? row.message.role : undefined}
2133 >
2134 {isWorkLogRow ? (
2135 <WorkLogBlock
2136 continues={row.continuesWorkLog}
2137 layout={
2138 isExpandedToolGroup
2139 ? "group-content"
2140 : isExpandedToolGroupHeader
2141 ? "group-header"
2142 : "standalone"
2143 }
2144 >
2145 {row.kind === "work" ? (
2146 <WorkGroupSection
2147 anchorKey={row.id}
2148 groupedEntries={row.groupedEntries}
2149 isExpandedToolGroup={row.isExpandedToolGroup}
2150 displayLabel={row.displayLabel}
2151 />
2152 ) : null}
2153 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
2154 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
2155 {row.kind === "thinking" ? <ThinkingTimelineRow row={row} /> : null}
2156 </WorkLogBlock>
2157 ) : null}
2158 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
2159 {row.kind === "attempt-fold" ? <AttemptFoldTimelineRow row={row} /> : null}
2160 {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null}
2161 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
2162 {row.kind === "message" && row.message.role === "assistant" ? (
2163 <AssistantTimelineRow row={row} />
2164 ) : null}
2165 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
2166 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
2167 {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null}
2168 {row.kind === "mcp-app" ? <McpAppTimelineRow row={row} /> : null}
2169 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
2170 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
2171 {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
2172 </div>
2173 );
2174});
2175
2176function WorktreeSetupTimelineRow({
2177 row,
2178}: {
2179 row: Extract<TimelineRow, { kind: "worktree-setup" }>;
2180}) {
2181 const ctx = use(TimelineRowCtx);
2182 const terminalId = row.snapshot.setupScript?.terminalId ?? null;
2183 const openTerminal = ctx.onOpenWorktreeSetupTerminal;
2184 const onOpenTerminal = useMemo(
2185 () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null),
2186 [openTerminal, terminalId],
2187 );
2188 return (
2189 <WorktreeSetupCard
2190 snapshot={row.snapshot}
2191 embedded={row.embedded}
2192 onCancel={row.embedded ? null : ctx.onCancelWorktreeSetup}
2193 onWorkLocally={
2194 !row.embedded && row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null
2195 }
2196 onOpenTerminal={onOpenTerminal}
2197 />
2198 );
2199}
2200
2201function ContextCompactionTimelineRow({
2202 row,
2203}: {
2204 row: Extract<TimelineRow, { kind: "context-compaction" }>;
2205}) {
2206 return (
2207 <div
2208 role="separator"
2209 aria-label={row.label}
2210 className="mx-auto flex w-full max-w-(--chat-content-max-width) items-center gap-3 py-1 text-muted-foreground text-xs"
2211 >
2212 <span className="h-px flex-1 bg-border/70" />
2213 <span
2214 ref={row.active ? observeVisibleAnimation : undefined}
2215 className="relative shrink-0 overflow-hidden"
2216 >
2217 <span className="flex items-center gap-1.5">
2218 <Minimize2Icon aria-hidden="true" className="size-3" />
2219 {row.label}
2220 </span>
2221 {row.active ? (
2222 <ActivityShimmerOverlay>
2223 <span className="flex items-center gap-1.5">
2224 <Minimize2Icon aria-hidden="true" className="size-3" />
2225 {row.label}
2226 </span>
2227 </ActivityShimmerOverlay>
2228 ) : null}
2229 </span>
2230 <span className="h-px flex-1 bg-border/70" />
2231 </div>
2232 );
2233}
2234
2235function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
2236 const ctx = use(TimelineRowCtx);
2237 const asset = useMemo(
2238 () =>
2239 file.downloadable === false
2240 ? null
2241 : buildAttachmentVideoAsset(ctx.activeThreadEnvironmentId, file),
2242 [ctx.activeThreadEnvironmentId, file.downloadable, file.id, file.mimeType, file.name],
2243 );
2244 const resource = asset?.resource ?? null;
2245 const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource);
2246 const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource);
2247 const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null);
2248
2249 if (asset === null && src === null) {
2250 return (
2251 <div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg border border-border/80 bg-black px-2 py-3 text-center text-2xs text-white/70">
2252 {file.name}
2253 </div>
2254 );
2255 }
2256
2257 return (
2258 <MediaVideoPlayer
2259 src={src}
2260 sourceFailed={
2261 file.previewUrl === undefined && resource !== null && assetUrl._tag === "Failure"
2262 }
2263 label={file.name}
2264 preload="visible"
2265 onOpen={() => {
2266 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
2267 if (preview) ctx.onImageExpand(preview);
2268 }}
2269 className="block aspect-[4/3] w-full"
2270 videoClassName="aspect-auto size-full rounded-lg border border-border/80"
2271 stateClassName="aspect-auto min-h-full rounded-lg border border-border/80 bg-black text-white"
2272 onRetry={asset ? refreshAssetUrl : undefined}
2273 actionsSource={asset ? { kind: "video", name: file.name, src, asset } : undefined}
2274 />
2275 );
2276}
2277
2278// Screen readers skim a transcript by heading, so every message announces its
2279// author as one. The thread title in ChatHeader is an <h2>; headings written
2280// inside a message are exposed below this level. Visually hidden and excluded
2281// from selection so sighted users and copied text are unaffected.
2282const MESSAGE_HEADING_LEVEL = 3;
2283
2284function MessageAuthorHeading({ children }: { children: string }) {
2285 return <h3 className="sr-only select-none">{children}</h3>;
2286}
2287
2288function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
2289 const ctx = use(TimelineRowCtx);
2290 const { onImageExpand, onFileOpen } = ctx;
2291 const senderThreadId = row.message.senderThreadId;
2292 const resources = useMemo(
2293 () => selectMessageImageResources(row.message.attachments),
2294 [row.message.attachments],
2295 );
2296 const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources);
2297 const [projectPreviews] = useState(createMessageAttachmentPreviewProjector);
2298 const messageWithPreviews = useMemo(() => {
2299 const urlsById = new Map(
2300 resources.flatMap((resource, index) => {
2301 const url = previewUrls[index];
2302 return url ? [[resource.attachmentId, url] as const] : [];
2303 }),
2304 );
2305 return projectPreviews(row.message, (attachment) => urlsById.get(attachment.id));
2306 }, [previewUrls, projectPreviews, resources, row.message]);
2307 // The attachment union has an open member, so guards (not literal type
2308 // comparisons) split it. Unknown types render as inert rows below the files.
2309 const userImages = useMemo(
2310 () => (messageWithPreviews.attachments ?? []).filter(isImageAttachment),
2311 [messageWithPreviews.attachments],
2312 );
2313 const userFiles = useMemo(
2314 () => (row.message.attachments ?? []).filter(isFileAttachment),
2315 [row.message.attachments],
2316 );
2317 const userVideos = userFiles.filter(isVideoAttachment);
2318 const otherUserFiles = userFiles.filter((file) => !isVideoAttachment(file));
2319 const unknownAttachments = (row.message.attachments ?? []).filter(
2320 (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
2321 );
2322 const userMessage = resolveUserMessagePresentation(row.message);
2323 const resolvedContext = useMemo(() => resolveUserMessageContext(row.message), [row.message]);
2324 const previewImages = useMemo(
2325 () => userImages.filter((image) => image.name.startsWith("preview-annotation-")),
2326 [userImages],
2327 );
2328 const revertTurnCount = row.revertTurnCount;
2329 // A file with a chip in the prose needs no standalone row. Media is the exception: the
2330 // thumbnail is the only way to actually see it, so it shows whether or not it has a chip.
2331 const chippedAttachmentIds = new Set(
2332 collectComposerContextReferences(resolvedContext.text).flatMap((occurrence) => {
2333 const record = asKnownContextRecord(resolvedContext.recordsById.get(occurrence.contextId));
2334 return record?.kind === "file" || record?.kind === "image" ? [record.attachmentId] : [];
2335 }),
2336 );
2337 const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));
2338 const hasMediaGrid = regularImages.length + userVideos.length > 2;
2339 const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id));
2340 const annotationRecordIds = useMemo(
2341 () =>
2342 resolvedContext.records
2343 .filter((record) => record.kind === "preview-annotation")
2344 .map((record) => record.contextId),
2345 [resolvedContext.records],
2346 );
2347 // Chips read as their labels outside a draft, so a shared link carries the prose alone.
2348 const plainText = useMemo(
2349 () => replaceComposerContextReferences(resolvedContext.text, (reference) => reference.label),
2350 [resolvedContext.text],
2351 );
2352 const promptLink = useMemo(() => promptLinkFor(plainText), [plainText]);
2353 const contextClipboardFragment =
2354 resolvedContext.records.length === 0
2355 ? null
2356 : encodeComposerContextFragment({
2357 version: 1,
2358 source: {
2359 environmentId: ctx.activeThreadEnvironmentId,
2360 ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}),
2361 messageId: row.message.id,
2362 },
2363 records: resolvedContext.records,
2364 });
2365 // Chips inside the selection copy as their links (data-markdown-copy); the structured
2366 // fragment rides beside so a paste into a draft brings the payloads along. Only records
2367 // for chips that are actually inside the selection travel, so copying prose next to an
2368 // image never starts importing that image somewhere else.
2369 const onBodyCopyCapture = (event: React.ClipboardEvent<HTMLDivElement>) => {
2370 if (resolvedContext.records.length === 0 || !event.clipboardData) return;
2371 const selection = window.getSelection();
2372 if (!selection || selection.isCollapsed) return;
2373 const copiedMarkdown: string[] = [];
2374 for (let index = 0; index < selection.rangeCount; index += 1) {
2375 const container = document.createElement("div");
2376 container.appendChild(selection.getRangeAt(index).cloneContents());
2377 for (const element of container.querySelectorAll("[data-markdown-copy]")) {
2378 copiedMarkdown.push(element.getAttribute("data-markdown-copy") ?? "");
2379 }
2380 }
2381 const fragment = selectedMessageContextFragment({
2382 markdown: copiedMarkdown.join("\n"),
2383 records: resolvedContext.records,
2384 environmentId: ctx.activeThreadEnvironmentId,
2385 ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}),
2386 messageId: row.message.id,
2387 });
2388 if (!fragment) return;
2389 // Claim the copy: without preventDefault the browser default overwrites the
2390 // custom MIME type. The default content must then be written back explicitly.
2391 const payload = chatMarkdownClipboardPayload(selection);
2392 event.preventDefault();
2393 event.clipboardData.setData("text/plain", payload?.text ?? selection.toString());
2394 if (payload) {
2395 event.clipboardData.setData(
2396 "text/html",
2397 encodeComposerContextClipboardHtml(payload.text, fragment, payload.html),
2398 );
2399 }
2400 event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
2401 };
2402 const renderContextReference = useCallback(
2403 (reference: ChatMarkdownContextReference) => {
2404 const record = asKnownContextRecord(resolvedContext.recordsById.get(reference.contextId));
2405 // Structured annotations point at the image record, which in turn points at the persisted
2406 // attachment. Filename and order are compatibility fallbacks for legacy messages only.
2407 const annotationImage =
2408 record?.kind === "preview-annotation"
2409 ? resolvePreviewAnnotationImage({
2410 record,
2411 recordsById: resolvedContext.recordsById,
2412 userImages,
2413 previewImages,
2414 annotationRecordIds,
2415 })
2416 : null;
2417 const attachment =
2418 record?.kind === "image"
2419 ? (userImages.find((image) => image.id === record.attachmentId) ?? null)
2420 : record?.kind === "file"
2421 ? (userFiles.find((file) => file.id === record.attachmentId) ?? null)
2422 : null;
2423 return (
2424 <span data-thread-find-ignore>
2425 <UserMessageContextReferenceChip
2426 reference={reference}
2427 record={record}
2428 annotationImage={annotationImage}
2429 attachment={attachment}
2430 onExpandImage={(image) => {
2431 const preview = buildExpandedImagePreview(userImages, image.id);
2432 if (preview) onImageExpand(preview);
2433 }}
2434 onOpenFile={onFileOpen}
2435 onExpandVideo={(file) => {
2436 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
2437 if (preview) onImageExpand(preview);
2438 }}
2439 />
2440 </span>
2441 );
2442 },
2443 [
2444 resolvedContext.recordsById,
2445 userImages,
2446 userFiles,
2447 previewImages,
2448 annotationRecordIds,
2449 onImageExpand,
2450 onFileOpen,
2451 ctx.activeThreadEnvironmentId,
2452 ],
2453 );
2454
2455 return (
2456 <div className="group flex flex-col items-end gap-1">
2457 {userMessage.attribution === "automation" ? (
2458 <p
2459 className="me-1 text-2xs text-muted-foreground/70"
2460 data-user-message-attribution="automation"
2461 >
2462 {userMessage.scheduledTaskId ? (
2463 <Link
2464 to="/settings/scheduled-tasks"
2465 search={{
2466 environmentId: ctx.activeThreadEnvironmentId,
2467 taskId: userMessage.scheduledTaskId,
2468 }}
2469 className="rounded-sm hover:text-muted-foreground hover:underline focus-visible:outline-2 focus-visible:outline-ring"
2470 >
2471 Sent by automation
2472 </Link>
2473 ) : (
2474 "Sent by automation"
2475 )}
2476 </p>
2477 ) : userMessage.attribution === "t3code" ? (
2478 <p
2479 className="me-1 text-2xs text-muted-foreground/70"
2480 data-user-message-attribution="t3code"
2481 >
2482 Sent by The Knife
2483 </p>
2484 ) : userMessage.attribution === "agent" ? (
2485 <p className="me-1 text-2xs text-muted-foreground/70" data-user-message-attribution="agent">
2486 {senderThreadId ? (
2487 <InlineButton
2488 onClick={() => ctx.onOpenThread(senderThreadId)}
2489 tone="muted"
2490 aria-label="Open sending thread"
2491 >
2492 Sent by another agent
2493 </InlineButton>
2494 ) : (
2495 "Sent by another agent"
2496 )}
2497 </p>
2498 ) : null}
2499 {row.message.inputIntent && row.message.inputIntent !== "turn_start" ? (
2500 <UserMessageIntentMarker intent={row.message.inputIntent} />
2501 ) : null}
2502 <div
2503 className={cn(
2504 "relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground",
2505 hasMediaGrid && "w-[80%]",
2506 )}
2507 >
2508 <MessageAuthorHeading>You</MessageAuthorHeading>
2509 {(regularImages.length > 0 || userVideos.length > 0) && (
2510 <div
2511 className={cn(
2512 "mb-2 grid gap-2",
2513 hasMediaGrid
2514 ? "grid-cols-[repeat(auto-fit,minmax(min(100%,100px),1fr))]"
2515 : "max-w-[210px] grid-cols-2",
2516 )}
2517 >
2518 {regularImages.map((image) => (
2519 <div
2520 key={image.id}
2521 className={cn(
2522 "bg-background/70",
2523 image.source?.kind === "snap-shot" && image.previewUrl
2524 ? cn(SNAP_SHOT_ATTACHMENT_FRAME_CLASS, "col-span-2")
2525 : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80",
2526 )}
2527 >
2528 {image.previewUrl ? (
2529 <button
2530 type="button"
2531 className="block h-full w-full cursor-zoom-in"
2532 aria-label={`Preview ${image.name}`}
2533 onClick={() => {
2534 const preview = buildExpandedImagePreview(regularImages, image.id);
2535 if (!preview) return;
2536 ctx.onImageExpand(preview);
2537 }}
2538 >
2539 <img
2540 src={image.previewUrl}
2541 alt={image.name}
2542 className="block size-full object-cover"
2543 />
2544 </button>
2545 ) : (
2546 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-2xs text-muted-foreground/70">
2547 {image.name}
2548 </div>
2549 )}
2550 {image.previewUrl && image.source?.kind === "snap-shot" ? (
2551 <SnapShotAttachmentDetails source={image.source} />
2552 ) : null}
2553 </div>
2554 ))}
2555 {userVideos.map((file) => (
2556 <UserVideoAttachment key={file.id} file={file} />
2557 ))}
2558 </div>
2559 )}
2560 {unchippedFiles.length > 0 || unknownAttachments.length > 0 ? (
2561 <div className="mb-2 flex flex-col gap-1">
2562 {unchippedFiles.map((file) => {
2563 const fileIdentity = (
2564 <>
2565 <PierreEntryIcon pathValue={file.name} kind="file" theme={ctx.resolvedTheme} />
2566 <span className="min-w-0 flex-1 truncate">{file.name}</span>
2567 </>
2568 );
2569 if (file.downloadable !== false) {
2570 return (
2571 <div key={file.id} className="flex min-w-0 items-center gap-1">
2572 <button
2573 type="button"
2574 aria-label={`Preview ${file.name}`}
2575 onClick={() => ctx.onFileOpen(file)}
2576 className="focus-visible:ring-ring/70 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none"
2577 >
2578 {fileIdentity}
2579 <EyeIcon className="size-4 shrink-0" />
2580 </button>
2581 <Tooltip>
2582 <TooltipTrigger
2583 render={
2584 <Button
2585 size="icon-xs"
2586 variant="ghost-muted"
2587 aria-label={`Download ${file.name}`}
2588 onClick={() => ctx.onFileDownload(file)}
2589 />
2590 }
2591 >
2592 <DownloadIcon />
2593 </TooltipTrigger>
2594 <TooltipPopup side="top">Download {file.name}</TooltipPopup>
2595 </Tooltip>
2596 </div>
2597 );
2598 }
2599
2600 return (
2601 <div key={file.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
2602 {fileIdentity}
2603 </div>
2604 );
2605 })}
2606 {unknownAttachments.map((attachment) => (
2607 <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
2608 <PierreEntryIcon
2609 pathValue={attachment.name}
2610 kind="file"
2611 theme={ctx.resolvedTheme}
2612 />
2613 <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
2614 </div>
2615 ))}
2616 </div>
2617 ) : null}
2618 <div onCopyCapture={onBodyCopyCapture}>
2619 <CollapsibleUserMessageBody
2620 text={resolvedContext.text}
2621 renderContextReference={renderContextReference}
2622 skills={ctx.skills}
2623 markdownCwd={ctx.markdownCwd}
2624 />
2625 </div>
2626 </div>
2627 {row.projectedItem &&
2628 row.projectedItem.item.status !== "completed" &&
2629 row.projectedItem.item.status !== "pending" &&
2630 row.projectedItem.item.status !== "waiting" ? (
2631 <div className="me-1 flex items-center gap-1.5">
2632 <span className="rounded-full border border-destructive/25 bg-destructive/8 px-1.5 py-0.5 text-3xs font-medium text-destructive">
2633 {row.projectedItem.item.status}
2634 </span>
2635 </div>
2636 ) : null}
2637 <div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100">
2638 <div className="flex shrink-0 items-center gap-2">
2639 <Tooltip>
2640 <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
2641 {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
2642 </TooltipTrigger>
2643 <TooltipPopup>
2644 {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
2645 </TooltipPopup>
2646 </Tooltip>
2647 <div className="flex items-center gap-0.5">
2648 {typeof revertTurnCount === "number" && (
2649 <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
2650 )}
2651 {resolvedContext.text && (
2652 <MessageCopyButton
2653 // Structured paste needs the canonical links to retain their positions.
2654 text={contextClipboardFragment ? resolvedContext.text : plainText}
2655 {...(contextClipboardFragment
2656 ? {
2657 extraFlavors: { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment },
2658 }
2659 : {})}
2660 variant="ghost"
2661 />
2662 )}
2663 {promptLink !== null && (
2664 <MessageCopyButton
2665 text={promptLink}
2666 label="Copy prompt link"
2667 icon={LinkIconNode}
2668 variant="ghost"
2669 />
2670 )}
2671 </div>
2672 </div>
2673 </div>
2674 </div>
2675 );
2676}
2677
2678function UserMessageIntentMarker({
2679 intent,
2680}: {
2681 readonly intent: NonNullable<ChatMessage["inputIntent"]>;
2682}) {
2683 const presentation =
2684 intent === "queued_turn"
2685 ? {
2686 label: "Queued",
2687 icon: null,
2688 }
2689 : intent === "promoted_queued_to_steer"
2690 ? {
2691 label: "Steer",
2692 icon: Redo2Icon,
2693 }
2694 : {
2695 label: "Steer",
2696 icon: Redo2Icon,
2697 };
2698 const IntentIcon = presentation.icon;
2699 return (
2700 <Tooltip>
2701 <TooltipTrigger
2702 render={
2703 <div
2704 className="me-1 flex items-center justify-end gap-1 text-xs leading-none text-muted-foreground"
2705 data-user-message-intent={intent}
2706 />
2707 }
2708 >
2709 {IntentIcon ? <IntentIcon aria-hidden="true" className="size-3" /> : null}
2710 {presentation.label}
2711 </TooltipTrigger>
2712 <TooltipPopup side="top">
2713 {intent === "queued_turn"
2714 ? "Queued behind the active turn"
2715 : intent === "promoted_queued_to_steer"
2716 ? "Originally queued, then promoted to steer the active turn"
2717 : "Steered the active turn"}
2718 </TooltipPopup>
2719 </Tooltip>
2720 );
2721}
2722
2723export function resolvePreviewAnnotationImage(input: {
2724 record: Extract<KnownComposerContextRecord, { kind: "preview-annotation" }>;
2725 recordsById: ReadonlyMap<string, ComposerContextRecord>;
2726 userImages: ReadonlyArray<ChatImageAttachment>;
2727 previewImages: ReadonlyArray<ChatImageAttachment>;
2728 annotationRecordIds: ReadonlyArray<string>;
2729}): ChatImageAttachment | null {
2730 const screenshotRecord = input.record.screenshotContextId
2731 ? asKnownContextRecord(input.recordsById.get(input.record.screenshotContextId))
2732 : undefined;
2733 return (
2734 (screenshotRecord?.kind === "image"
2735 ? input.userImages.find((image) => image.id === screenshotRecord.attachmentId)
2736 : undefined) ??
2737 input.previewImages.find(
2738 (image) => image.name === `preview-annotation-${input.record.annotationId}.png`,
2739 ) ??
2740 input.previewImages[input.annotationRecordIds.indexOf(input.record.contextId)] ??
2741 null
2742 );
2743}
2744
2745function RevertUserMessageButton({
2746 turnCount,
2747 messageId,
2748}: {
2749 turnCount: number;
2750 messageId: MessageId;
2751}) {
2752 const ctx = use(TimelineRowCtx);
2753 const activity = use(TimelineRowActivityCtx);
2754
2755 return (
2756 <Tooltip>
2757 <TooltipTrigger
2758 render={
2759 <Button
2760 type="button"
2761 size="xs"
2762 variant="ghost"
2763 disabled={activity.isRevertingCheckpoint || activity.isWorking}
2764 onClick={() => ctx.onRevertToTurnCount(turnCount, messageId)}
2765 aria-label="Edit from here"
2766 />
2767 }
2768 >
2769 <Undo2Icon className="size-3" />
2770 </TooltipTrigger>
2771 <TooltipPopup side="top">Edit from here</TooltipPopup>
2772 </Tooltip>
2773 );
2774}
2775
2776/**
2777 * Hover-revealed wall-clock time with a full-date tooltip — the same metadata
2778 * presentation as message rows, for work entries and turn folds. The parent
2779 * carries `group/timeline-row`; hover or focus on an existing control reveals
2780 * the time without adding a tab stop. Hidden timestamps stay outside the row
2781 * layout. Visibility changes immediately so leaving flow cannot overlap text
2782 * during a fade-out. Place it before any trailing disclosure control so
2783 * revealing the time does not move the chevron.
2784 */
2785function TimelineRowTimestamp({
2786 createdAt,
2787 timestampFormat,
2788 className,
2789 alwaysVisible = false,
2790}: {
2791 createdAt: string;
2792 timestampFormat: TimestampFormat;
2793 className?: string;
2794 alwaysVisible?: boolean;
2795}) {
2796 return (
2797 <Tooltip>
2798 <TooltipTrigger
2799 render={
2800 <span
2801 className={cn(
2802 "pointer-events-none absolute me-1 shrink-0 whitespace-nowrap rounded-md text-muted-foreground text-xs tabular-nums opacity-0 group-hover/timeline-row:pointer-events-auto group-hover/timeline-row:static group-hover/timeline-row:opacity-100 group-focus-within/timeline-row:pointer-events-auto group-focus-within/timeline-row:static group-focus-within/timeline-row:opacity-100",
2803 alwaysVisible && "pointer-events-auto static opacity-100",
2804 className,
2805 )}
2806 />
2807 }
2808 >
2809 {formatDayAwareTimestamp(createdAt, timestampFormat)}
2810 </TooltipTrigger>
2811 <TooltipPopup>{formatChatTimestampTooltip(createdAt, timestampFormat)}</TooltipPopup>
2812 </Tooltip>
2813 );
2814}
2815
2816function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
2817 const ctx = use(TimelineRowCtx);
2818
2819 return (
2820 <div className="group/timeline-row relative flex items-center gap-1 border-b border-border/60 pb-2 pe-0.5 pt-1">
2821 {row.summaryOnly ? (
2822 <span className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
2823 {row.label}
2824 </span>
2825 ) : (
2826 <button
2827 type="button"
2828 aria-expanded={row.expanded}
2829 data-scroll-anchor-ignore
2830 onClick={() => ctx.onToggleTurnFold(row.runId)}
2831 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2832 >
2833 <span>{row.label}</span>
2834 <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} />
2835 </button>
2836 )}
2837 <TimelineRowTimestamp
2838 createdAt={row.createdAt}
2839 timestampFormat={ctx.timestampFormat}
2840 className="ms-auto"
2841 />
2842 </div>
2843 );
2844}
2845
2846function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "attempt-fold" }> }) {
2847 const ctx = use(TimelineRowCtx);
2848
2849 return (
2850 <button
2851 type="button"
2852 aria-expanded={row.expanded}
2853 data-scroll-anchor-ignore
2854 data-superseded-attempt-id={row.attemptId}
2855 onClick={() => ctx.onToggleAttemptFold(row.attemptId)}
2856 className="flex w-full cursor-pointer select-none items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-2.5 py-2 text-left transition-colors hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2857 >
2858 <MorphIcon
2859 className="size-3.5 shrink-0 text-muted-foreground"
2860 icon={row.expanded ? ChevronDown : ChevronRight}
2861 />
2862 <span className="text-xs font-medium text-foreground/80">{row.label}</span>
2863 <span className="text-2xs text-muted-foreground">Cut off by a steer</span>
2864 </button>
2865 );
2866}
2867
2868function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
2869 const ctx = use(TimelineRowCtx);
2870 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
2871
2872 return (
2873 <>
2874 <div className="chat-transcript-blocks relative min-w-0 px-1 py-0.5">
2875 <MessageAuthorHeading>The Knife</MessageAuthorHeading>
2876 <div data-thread-find-text="true">
2877 <AssistantCitationSource
2878 messageId={row.message.id}
2879 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
2880 itemKey={row.id}
2881 request={ctx.citationRequest}
2882 listRef={ctx.listRef}
2883 >
2884 <ChatMarkdown
2885 text={messageText}
2886 cwd={ctx.markdownCwd}
2887 threadRef={ctx.threadRef ?? undefined}
2888 isStreaming={Boolean(row.message.streaming)}
2889 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
2890 skills={ctx.skills}
2891 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2892 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2893 onRunShellCommand={ctx.onRunShellCommand}
2894 onImageExpand={ctx.onImageExpand}
2895 />
2896 </AssistantCitationSource>
2897 </div>
2898 <AssistantChangedFilesSection
2899 turnSummary={row.assistantTurnDiffSummary}
2900 routeThreadKey={ctx.routeThreadKey}
2901 resolvedTheme={ctx.resolvedTheme}
2902 onOpenTurnDiff={ctx.onOpenTurnDiff}
2903 />
2904 {row.showAssistantMeta ? (
2905 <AssistantMessageMeta
2906 className="mt-1.5"
2907 projectedItem={row.projectedItem}
2908 message={row.message}
2909 showCopyButton={row.showAssistantCopyButton}
2910 copyStreaming={row.assistantCopyStreaming}
2911 />
2912 ) : null}
2913 </div>
2914 </>
2915 );
2916}
2917
2918function AssistantForkButton({
2919 projectedItem,
2920}: {
2921 readonly projectedItem: NonNullable<Extract<TimelineRow, { kind: "message" }>["projectedItem"]>;
2922}) {
2923 const ctx = use(TimelineRowCtx);
2924 const [busy, setBusy] = useState(false);
2925 const support = useV2ItemSupport({
2926 environmentId: ctx.activeThreadEnvironmentId,
2927 sourceThreadId: projectedItem.sourceThreadId,
2928 sourceItemId: projectedItem.sourceItemId,
2929 });
2930 const canFork = canForkProjectedAssistantItem({
2931 projectedItem,
2932 capabilities: support.providerSession?.capabilities,
2933 });
2934
2935 if (!canFork || projectedItem.item.runId === null) return null;
2936 const runId = projectedItem.item.runId;
2937
2938 return (
2939 <Tooltip>
2940 <TooltipTrigger
2941 render={
2942 <Button
2943 type="button"
2944 size="xs"
2945 variant="ghost"
2946 disabled={busy}
2947 onClick={() => {
2948 setBusy(true);
2949 void ctx
2950 .onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId })
2951 .finally(() => setBusy(false));
2952 }}
2953 aria-label="Fork from this response"
2954 />
2955 }
2956 >
2957 <GitForkIcon className={cn("size-3", busy && "animate-pulse")} />
2958 </TooltipTrigger>
2959 <TooltipPopup side="top">Fork from this response</TooltipPopup>
2960 </Tooltip>
2961 );
2962}
2963
2964function AssistantMetaTimelineRow({
2965 row,
2966}: {
2967 row: Extract<TimelineRow, { kind: "assistant-meta" }>;
2968}) {
2969 return (
2970 <div className="px-1">
2971 <AssistantMessageMeta
2972 className="mt-0.5"
2973 projectedItem={row.projectedItem}
2974 message={row.message}
2975 showCopyButton={row.showAssistantCopyButton}
2976 copyStreaming={row.assistantCopyStreaming}
2977 alwaysVisible
2978 />
2979 </div>
2980 );
2981}
2982
2983function AssistantMessageMeta({
2984 className,
2985 projectedItem,
2986 message,
2987 showCopyButton,
2988 copyStreaming,
2989 alwaysVisible = false,
2990}: {
2991 className?: string;
2992 projectedItem?: Extract<TimelineRow, { kind: "message" }>["projectedItem"];
2993 message: ChatMessage;
2994 showCopyButton: boolean;
2995 copyStreaming: boolean;
2996 alwaysVisible?: boolean;
2997}) {
2998 const ctx = use(TimelineRowCtx);
2999
3000 return (
3001 <div
3002 className={cn(
3003 "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200",
3004 alwaysVisible
3005 ? "opacity-100"
3006 : "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100",
3007 className,
3008 )}
3009 >
3010 {projectedItem && projectedItem.item.status !== "completed" ? (
3011 <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-3xs text-muted-foreground">
3012 {projectedItem.item.status}
3013 </span>
3014 ) : null}
3015 <AssistantCopyButton
3016 message={message}
3017 showCopyButton={showCopyButton}
3018 streaming={copyStreaming}
3019 />
3020 {projectedItem?.item.type === "assistant_message" ? (
3021 <AssistantForkButton projectedItem={projectedItem} />
3022 ) : null}
3023 {!message.streaming && (
3024 <Tooltip>
3025 <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
3026 {formatDayAwareTimestamp(message.updatedAt, ctx.timestampFormat)}
3027 </TooltipTrigger>
3028 <TooltipPopup>
3029 {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat)}
3030 </TooltipPopup>
3031 </Tooltip>
3032 )}
3033 </div>
3034 );
3035}
3036
3037function AssistantCopyButton({
3038 message,
3039 showCopyButton,
3040 streaming,
3041}: {
3042 message: ChatMessage;
3043 showCopyButton: boolean;
3044 streaming: boolean;
3045}) {
3046 const assistantCopyState = resolveAssistantMessageCopyState({
3047 text: message.text ?? null,
3048 showCopyButton,
3049 streaming,
3050 });
3051
3052 if (!assistantCopyState.visible) {
3053 return null;
3054 }
3055
3056 return <MessageCopyButton text={assistantCopyState.text ?? ""} variant="ghost" />;
3057}
3058
3059function ProposedPlanTimelineRow({
3060 row,
3061}: {
3062 row: Extract<TimelineRow, { kind: "proposed-plan" }>;
3063}) {
3064 const ctx = use(TimelineRowCtx);
3065
3066 return (
3067 <div className="min-w-0 px-1 py-0.5">
3068 <ProposedPlanCard
3069 planMarkdown={row.proposedPlan.planMarkdown}
3070 environmentId={ctx.activeThreadEnvironmentId}
3071 threadRef={ctx.threadRef ?? undefined}
3072 cwd={ctx.markdownCwd}
3073 workspaceRoot={ctx.workspaceRoot}
3074 findActive={ctx.findActive}
3075 />
3076 </div>
3077 );
3078}
3079
3080function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "html-render" }> }) {
3081 const ctx = use(TimelineRowCtx);
3082 const { onAppFullscreenChange } = ctx;
3083 const onFullscreenChange = useCallback(
3084 (fullscreen: boolean) => onAppFullscreenChange(row.id, fullscreen),
3085 [onAppFullscreenChange, row.id],
3086 );
3087
3088 return (
3089 <div className="chat-transcript-figure min-w-0">
3090 <HtmlRenderFrame
3091 // A recycled row must not keep another page's frozen frame.
3092 key={row.htmlRender.attachmentId}
3093 environmentId={ctx.activeThreadEnvironmentId}
3094 htmlRender={row.htmlRender}
3095 threadRef={ctx.threadRef ?? undefined}
3096 onFullscreenChange={onFullscreenChange}
3097 />
3098 </div>
3099 );
3100}
3101
3102function McpAppTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mcp-app" }> }) {
3103 const ctx = use(TimelineRowCtx);
3104 const { awaitingUser } = use(TimelineRowActivityCtx);
3105
3106 return (
3107 <div className="min-w-0 px-1">
3108 <McpAppFrame
3109 // A recycled row must not keep another app's live document.
3110 key={row.mcpApp.attachmentId}
3111 environmentId={ctx.activeThreadEnvironmentId}
3112 threadId={row.sourceThreadId}
3113 conversationThreadId={ctx.threadRef?.threadId ?? row.sourceThreadId}
3114 itemId={row.itemId}
3115 revision={row.revision}
3116 app={row.mcpApp}
3117 onSendMessage={ctx.onSendAppMessage}
3118 awaitingUser={awaitingUser}
3119 onFullscreenChange={(fullscreen) => ctx.onAppFullscreenChange(row.id, fullscreen)}
3120 />
3121 </div>
3122 );
3123}
3124
3125type V2EventTone = "muted" | "warning" | "danger" | "success";
3126
3127function v2EventPresentation(item: OrchestrationV2TurnItem): {
3128 readonly label: string;
3129 readonly detail: string | null;
3130 readonly tone: V2EventTone;
3131 readonly icon: LucideIcon;
3132} {
3133 switch (item.type) {
3134 case "error": {
3135 const presentation = providerErrorPresentation(item);
3136 return {
3137 ...presentation,
3138 tone:
3139 item.status === "completed"
3140 ? "success"
3141 : item.status === "running" || item.failure.class === "usage_limit"
3142 ? "warning"
3143 : "danger",
3144 icon: CircleAlertIcon,
3145 };
3146 }
3147 case "run_interrupt_request":
3148 return {
3149 label: "Interrupt requested",
3150 detail: item.message,
3151 tone: "warning",
3152 icon: CircleAlertIcon,
3153 };
3154 case "run_interrupt_result":
3155 return {
3156 label: "Run interrupted",
3157 detail: item.message,
3158 tone: "danger",
3159 icon: XIcon,
3160 };
3161 case "handoff":
3162 return {
3163 label: "Context handoff",
3164 detail:
3165 item.summary ??
3166 `${item.fromProviderInstanceIds.join(", ")} → ${item.toProviderInstanceId}`,
3167 tone: item.status === "failed" ? "danger" : "muted",
3168 icon: ZapIcon,
3169 };
3170 case "fork":
3171 return {
3172 label: "Conversation fork",
3173 detail: `Continues in ${item.targetThreadId}`,
3174 tone: "muted",
3175 icon: GitForkIcon,
3176 };
3177 case "compaction": {
3178 const tokenSummary =
3179 item.beforeTokenCount === undefined && item.afterTokenCount === undefined
3180 ? null
3181 : `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"} tokens`;
3182 return {
3183 label: "Context compacted",
3184 detail: item.summary ?? tokenSummary,
3185 tone: item.status === "failed" ? "danger" : "muted",
3186 icon: MinusIcon,
3187 };
3188 }
3189 case "todo_list": {
3190 const steps = item.steps.map((step) => `${step.status}: ${step.text}`).join("\n");
3191 return {
3192 label: "Plan updated",
3193 detail: [item.explanation, steps].filter(Boolean).join("\n\n") || null,
3194 tone: item.status === "failed" ? "danger" : "success",
3195 icon: CheckIcon,
3196 };
3197 }
3198 default:
3199 return {
3200 label: item.title?.trim() || item.type.replaceAll("_", " "),
3201 detail: null,
3202 tone: item.status === "failed" ? "danger" : "muted",
3203 icon: WrenchIcon,
3204 };
3205 }
3206}
3207
3208function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" }> }) {
3209 const ctx = use(TimelineRowCtx);
3210 const { item, visibility, sourceThreadId } = row.projectedItem;
3211 if (item.type === "subagent" && (row.subagents?.length ?? 1) > 1) {
3212 return <V2SubagentGroup key={row.id} row={row} />;
3213 }
3214 if (item.type === "secret_request") {
3215 return (
3216 <SecretRequestCard
3217 environmentId={ctx.activeThreadEnvironmentId}
3218 item={item}
3219 visibility={visibility}
3220 />
3221 );
3222 }
3223 if (isV2LifecycleItem(item)) {
3224 return (
3225 <V2LifecycleRow
3226 environmentId={ctx.activeThreadEnvironmentId}
3227 item={item}
3228 resourceSummary={row.resourceSummary}
3229 createdAt={row.createdAt}
3230 timestampFormat={ctx.timestampFormat}
3231 providerStatuses={ctx.providerStatuses}
3232 runs={ctx.runs}
3233 onOpenThread={ctx.onOpenThread}
3234 />
3235 );
3236 }
3237 const presentation = v2EventPresentation(item);
3238 const Icon = presentation.icon;
3239 if (item.type === "error") {
3240 return (
3241 <details
3242 className={cn(
3243 "group rounded-md border",
3244 presentation.tone === "warning" && "border-warning/25 bg-warning/5",
3245 presentation.tone === "danger" && "border-destructive/25 bg-destructive/5",
3246 presentation.tone === "success" && "border-success/20 bg-success/5",
3247 )}
3248 data-v2-item-type={item.type}
3249 data-v2-item-visibility={visibility}
3250 data-v2-event-disclosure="true"
3251 >
3252 <summary className="flex min-w-0 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-xs [&::-webkit-details-marker]:hidden">
3253 <Icon
3254 className={cn(
3255 "size-3.5 shrink-0",
3256 presentation.tone === "warning" && "text-warning",
3257 presentation.tone === "danger" && "text-destructive",
3258 presentation.tone === "success" && "text-success",
3259 )}
3260 />
3261 <span className="shrink-0 font-medium text-foreground/90">{presentation.label}</span>
3262 {item.status !== "completed" ? (
3263 <span
3264 className={cn(
3265 "shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-3xs",
3266 item.status === "failed"
3267 ? "border-destructive/40 text-destructive"
3268 : "border-border/70 text-muted-foreground",
3269 )}
3270 >
3271 {item.status}
3272 </span>
3273 ) : null}
3274 {presentation.detail ? (
3275 <span className="min-w-0 flex-1 truncate text-muted-foreground/65">
3276 {presentation.detail}
3277 </span>
3278 ) : null}
3279 {visibility !== "local" ? (
3280 <span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-3xs text-muted-foreground">
3281 {visibility === "inherited" ? "Inherited" : "Synthetic"}
3282 </span>
3283 ) : null}
3284 <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/60 transition-transform group-open:rotate-180" />
3285 </summary>
3286 <div className="border-t border-border/45 px-3 py-2 ps-8">
3287 {presentation.detail ? (
3288 <div className="text-xs leading-relaxed text-muted-foreground">
3289 <ChatMarkdown
3290 text={presentation.detail}
3291 cwd={ctx.markdownCwd}
3292 threadRef={ctx.threadRef ?? undefined}
3293 skills={ctx.skills}
3294 lineBreaks
3295 />
3296 </div>
3297 ) : null}
3298 {visibility === "inherited" ? (
3299 <p className="mt-1 font-mono text-3xs text-muted-foreground/65">
3300 From {sourceThreadId}
3301 </p>
3302 ) : null}
3303 <div className={presentation.detail ? "mt-2" : undefined}>
3304 <V2ItemInspector
3305 projectedItem={row.projectedItem}
3306 environmentId={ctx.activeThreadEnvironmentId}
3307 cwd={ctx.markdownCwd}
3308 workspaceRoot={ctx.workspaceRoot}
3309 onOpenThread={ctx.onOpenThread}
3310 onOpenTurnDiff={ctx.onOpenTurnDiff}
3311 onRollbackCheckpoint={ctx.onRollbackCheckpoint}
3312 onImageExpand={ctx.onImageExpand}
3313 />
3314 </div>
3315 </div>
3316 </details>
3317 );
3318 }
3319 return (
3320 <section
3321 className={cn(
3322 "rounded-lg border px-3 py-2",
3323 presentation.tone === "warning" && "border-warning/25 bg-warning/5",
3324 presentation.tone === "danger" && "border-destructive/25 bg-destructive/5",
3325 presentation.tone === "success" && "border-success/20 bg-success/5",
3326 presentation.tone === "muted" && "border-border/60 bg-card/30",
3327 )}
3328 data-v2-item-type={item.type}
3329 data-v2-item-visibility={visibility}
3330 >
3331 <div className="flex items-start gap-2.5">
3332 <Icon
3333 className={cn(
3334 "mt-0.5 size-3.5 shrink-0",
3335 presentation.tone === "warning" && "text-warning",
3336 presentation.tone === "danger" && "text-destructive",
3337 presentation.tone === "success" && "text-success",
3338 presentation.tone === "muted" && "text-muted-foreground",
3339 )}
3340 />
3341 <div className="min-w-0 flex-1">
3342 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
3343 <span className="text-xs font-medium text-foreground/90">{presentation.label}</span>
3344 {item.status !== "completed" ? (
3345 <span
3346 className={cn(
3347 "rounded-full border px-1.5 py-0.5 font-mono text-3xs",
3348 item.status === "failed"
3349 ? "border-destructive/40 text-destructive"
3350 : "border-border/70 text-muted-foreground",
3351 )}
3352 >
3353 {item.status}
3354 </span>
3355 ) : null}
3356 {visibility !== "local" ? (
3357 <span className="rounded-full bg-muted px-1.5 py-0.5 text-3xs text-muted-foreground">
3358 {visibility === "inherited" ? "Inherited" : "Synthetic"}
3359 </span>
3360 ) : null}
3361 </div>
3362 {presentation.detail ? (
3363 <div className="mt-1 text-xs leading-relaxed text-muted-foreground">
3364 <ChatMarkdown
3365 text={presentation.detail}
3366 cwd={ctx.markdownCwd}
3367 threadRef={ctx.threadRef ?? undefined}
3368 skills={ctx.skills}
3369 lineBreaks
3370 />
3371 </div>
3372 ) : null}
3373 {visibility === "inherited" ? (
3374 <p className="mt-1 font-mono text-3xs text-muted-foreground/65">
3375 From {sourceThreadId}
3376 </p>
3377 ) : null}
3378 <div className="mt-2">
3379 <V2ItemInspector
3380 projectedItem={row.projectedItem}
3381 environmentId={ctx.activeThreadEnvironmentId}
3382 cwd={ctx.markdownCwd}
3383 workspaceRoot={ctx.workspaceRoot}
3384 onOpenThread={ctx.onOpenThread}
3385 onOpenTurnDiff={ctx.onOpenTurnDiff}
3386 onRollbackCheckpoint={ctx.onRollbackCheckpoint}
3387 onImageExpand={ctx.onImageExpand}
3388 />
3389 </div>
3390 </div>
3391 </div>
3392 </section>
3393 );
3394}
3395
3396/**
3397 * One elapsed span for the whole group: first launch to last settle, ticking
3398 * while any member works. A settled member without a completion time leaves
3399 * the end unknown, so the span is withheld rather than cut short.
3400 */
3401function subagentGroupTiming(
3402 agents: ReadonlyArray<{
3403 status: OrchestrationV2TurnItem["status"];
3404 startedAt: DateTime.Utc | null;
3405 completedAt: DateTime.Utc | null;
3406 }>,
3407) {
3408 let startMs: number | null = null;
3409 let endMs: number | null = null;
3410 let endUnknown = false;
3411 for (const agent of agents) {
3412 if (agent.startedAt) {
3413 const ms = DateTime.toEpochMillis(agent.startedAt);
3414 startMs = startMs === null ? ms : Math.min(startMs, ms);
3415 }
3416 if (agent.completedAt) {
3417 const ms = DateTime.toEpochMillis(agent.completedAt);
3418 endMs = endMs === null ? ms : Math.max(endMs, ms);
3419 } else {
3420 endUnknown = true;
3421 }
3422 }
3423 const live = agents.some(
3424 ({ status }) => status === "pending" || status === "running" || status === "waiting",
3425 );
3426 return {
3427 status: live ? ("running" as const) : ("completed" as const),
3428 startedAt: startMs === null ? null : new Date(startMs).toISOString(),
3429 completedAt: live || endUnknown || endMs === null ? null : new Date(endMs).toISOString(),
3430 };
3431}
3432
3433const V2SubagentGroup = memo(function V2SubagentGroup({
3434 row,
3435}: {
3436 row: Extract<TimelineRow, { kind: "event" }>;
3437}) {
3438 const ctx = use(TimelineRowCtx);
3439 const groupId = `subagent-group:${row.id}`;
3440 const [expanded, setExpanded] = useState(() =>
3441 ctx.workGroupViewState.expandedEntries.has(groupId),
3442 );
3443 const members = (row.subagents ?? [row.projectedItem]).flatMap(({ item }) =>
3444 item.type === "subagent" ? [item] : [],
3445 );
3446 const liveAgents = useAtomValue(
3447 environmentThreadDetails.threadAtom(
3448 scopeThreadRef(ctx.activeThreadEnvironmentId, row.projectedItem.item.threadId),
3449 ),
3450 (thread) => thread?.projection.subagents,
3451 );
3452 const agents = members.map((item) => {
3453 const live = liveAgents?.find((agent) => agent.id === item.subagentId);
3454 return {
3455 item,
3456 status: live?.status ?? item.status,
3457 startedAt: live?.startedAt ?? item.startedAt,
3458 completedAt: live?.completedAt ?? item.completedAt,
3459 };
3460 });
3461 const summary = subagentGroupSummary(agents);
3462 const label = `${members.length} ${members.length === 1 ? "subagent" : "subagents"}`;
3463 const statusSummary = summarizeSubagentStatuses(agents.map(({ status }) => status));
3464 const toggleExpanded = (open: boolean) => {
3465 ctx.onToggleWorkEntry(row.id, expanded);
3466 if (open) ctx.workGroupViewState.expandedEntries.add(groupId);
3467 else ctx.workGroupViewState.expandedEntries.delete(groupId);
3468 setExpanded(open);
3469 };
3470 return (
3471 <WorkLogBlock continues={row.continuesWorkLog}>
3472 <Collapsible open={expanded} onOpenChange={toggleExpanded} data-subagent-group>
3473 <CollapsibleTrigger
3474 aria-label={label}
3475 aria-description={statusSummary}
3476 className={cn(
3477 "flex w-full min-w-0 items-center gap-3 py-2 text-left transition-opacity hover:opacity-100",
3478 expanded || summary.active
3479 ? "text-foreground opacity-100"
3480 : "text-muted-foreground opacity-55",
3481 )}
3482 >
3483 <span className="flex shrink-0 items-center -space-x-1.5" aria-hidden>
3484 {agents.slice(0, 3).map(({ item, status }) => (
3485 <SubagentAvatar
3486 key={item.id}
3487 driver={item.driver}
3488 provider={ctx.providerStatuses.find(
3489 (provider) => provider.instanceId === item.providerInstanceId,
3490 )}
3491 status={agents.length === 1 ? status : undefined}
3492 />
3493 ))}
3494 {agents.length > 3 ? (
3495 <span className="inline-flex size-6 items-center justify-center rounded-full bg-muted text-3xs font-medium text-muted-foreground ring-2 ring-background">
3496 +{agents.length - 3}
3497 </span>
3498 ) : null}
3499 </span>
3500 <span className="min-w-0 flex-1">
3501 <span className="block text-xs font-semibold">{label}</span>
3502 <span
3503 className={cn(
3504 "block truncate text-3xs text-muted-foreground",
3505 summary.active ? "text-info" : summary.failed && "text-destructive",
3506 )}
3507 >
3508 {statusSummary}
3509 </span>
3510 </span>
3511 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
3512 <SubagentElapsed agent={subagentGroupTiming(agents)} />
3513 </span>
3514 <ChevronDownIcon
3515 aria-hidden
3516 className={cn(
3517 "size-3.5 shrink-0 text-muted-foreground transition-transform",
3518 expanded && "rotate-180",
3519 )}
3520 />
3521 </CollapsibleTrigger>
3522 {/* Virtualized rows must settle before disclosure scroll anchoring resumes. */}
3523 <CollapsiblePanel animate={false}>
3524 {expanded ? (
3525 <div className="mt-1 mb-1 rounded-lg border border-border/60 bg-card/30 p-1">
3526 {members.map((item) => (
3527 <V2LifecycleRow
3528 environmentId={ctx.activeThreadEnvironmentId}
3529 key={item.id}
3530 item={item}
3531 createdAt={row.createdAt}
3532 timestampFormat={ctx.timestampFormat}
3533 providerStatuses={ctx.providerStatuses}
3534 runs={ctx.runs}
3535 onOpenThread={ctx.onOpenThread}
3536 />
3537 ))}
3538 </div>
3539 ) : null}
3540 </CollapsiblePanel>
3541 </Collapsible>
3542 </WorkLogBlock>
3543 );
3544});
3545
3546// ---------------------------------------------------------------------------
3547// Extracted row sections — own their state / store subscriptions so changes
3548// re-render only the affected row, not the entire list.
3549// ---------------------------------------------------------------------------
3550
3551/** Renders standalone activity or one bounded, virtualized expanded tool group. */
3552const WorkGroupSection = memo(function WorkGroupSection({
3553 anchorKey,
3554 disclosureAnchorKey = anchorKey,
3555 groupedEntries,
3556 isExpandedToolGroup,
3557 displayLabel,
3558}: {
3559 anchorKey: string;
3560 disclosureAnchorKey?: string;
3561 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
3562 isExpandedToolGroup: boolean;
3563 displayLabel?: string | undefined;
3564}) {
3565 const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx);
3566 const onToggleStandaloneEntry = useCallback(
3567 (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed),
3568 [disclosureAnchorKey, onToggleWorkEntry],
3569 );
3570 const nonEmptyEntries = useMemo(
3571 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
3572 [groupedEntries, isExpandedToolGroup],
3573 );
3574
3575 if (nonEmptyEntries.length === 0) return null;
3576 if (isExpandedToolGroup && nonEmptyEntries.every((entry) => entry.itemType === "reasoning")) {
3577 return <ReasoningTraceContent entries={nonEmptyEntries} />;
3578 }
3579 if (isExpandedToolGroup) {
3580 return (
3581 <ExpandedWorkGroupEntries
3582 key={`${routeThreadKey}:${anchorKey}`}
3583 anchorKey={anchorKey}
3584 disclosureAnchorKey={disclosureAnchorKey}
3585 entries={nonEmptyEntries}
3586 workspaceRoot={workspaceRoot}
3587 />
3588 );
3589 }
3590
3591 return (
3592 <section aria-label="Activity">
3593 {nonEmptyEntries.map((workEntry) => (
3594 <SimpleWorkEntryRow
3595 key={workEntry.id}
3596 workEntry={workEntry}
3597 workspaceRoot={workspaceRoot}
3598 displayLabel={displayLabel}
3599 onToggleEntry={onToggleStandaloneEntry}
3600 />
3601 ))}
3602 </section>
3603 );
3604});
3605
3606function formatWorkingTimer(startIso: string, endIso: string): string | null {
3607 const startedAtMs = Date.parse(startIso);
3608 const endedAtMs = Date.parse(endIso);
3609 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
3610 return null;
3611 }
3612
3613 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
3614 if (elapsedSeconds < 60) {
3615 return `${elapsedSeconds}s`;
3616 }
3617
3618 return formatDuration(elapsedSeconds * 1_000);
3619}
3620
3621function formatWorkingTimerNow(startIso: string): string {
3622 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
3623}
3624
3625function WorkingTimer({ createdAt }: { createdAt: string }) {
3626 const textRef = useRef<HTMLSpanElement>(null);
3627 const initialText = formatWorkingTimerNow(createdAt);
3628
3629 useEffect(() => {
3630 const updateText = () => {
3631 if (textRef.current) {
3632 textRef.current.textContent = formatWorkingTimerNow(createdAt);
3633 }
3634 };
3635 updateText();
3636 const id = setInterval(updateText, 1000);
3637 return () => clearInterval(id);
3638 }, [createdAt]);
3639
3640 return (
3641 <span ref={textRef} className="tabular-nums">
3642 {initialText}
3643 </span>
3644 );
3645}
3646
3647// Matches the grouped WorkLog row's min-h-6.
3648const compactWorkEntryHeight = 24;
3649
3650function ExpandedWorkGroupEntries({
3651 anchorKey,
3652 disclosureAnchorKey,
3653 entries,
3654 workspaceRoot,
3655}: {
3656 anchorKey: string;
3657 disclosureAnchorKey: string;
3658 entries: TimelineWorkEntry[];
3659 workspaceRoot: string | undefined;
3660}) {
3661 const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx);
3662 const [initialScrollIndex] = useState(() =>
3663 resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)),
3664 );
3665 const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined);
3666 const listRef = useRef<LegendListRef>(null);
3667 const [expandedContentHeight, setExpandedContentHeight] = useState(0);
3668 const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 });
3669 const [appendState, setAppendState] = useState({ entries, follow: false });
3670 // Capture the pre-change edge once per incoming array, before new layout
3671 // metrics arrive. Edge/viewport changes never turn a status update into a follow.
3672 if (appendState.entries !== entries) {
3673 setAppendState({
3674 entries,
3675 follow:
3676 fades.viewportHeight > 0 &&
3677 shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0),
3678 });
3679 }
3680
3681 const groupView = useMemo(
3682 () => ({
3683 state: viewState,
3684 onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed),
3685 }),
3686 [disclosureAnchorKey, onToggleWorkEntry, viewState],
3687 );
3688 const updateScrollFades = useCallback(() => {
3689 const element = listRef.current?.getScrollableNode();
3690 if (!element) return;
3691 const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop;
3692 const viewportHeight = element.clientHeight;
3693 const top = element.scrollTop > 1;
3694 const bottom = distanceFromEnd > 1;
3695 setFades((previous) =>
3696 previous.top === top &&
3697 previous.bottom === bottom &&
3698 previous.viewportHeight === viewportHeight
3699 ? previous
3700 : { top, bottom, viewportHeight },
3701 );
3702 }, []);
3703
3704 const handleScroll = useCallback(() => {
3705 const state = listRef.current?.getState();
3706 const position = state && resolveWorkGroupScrollAnchor(state);
3707 if (position) {
3708 viewState.scrollPositions.set(anchorKey, {
3709 entryId: position.rowId,
3710 offset: position.offsetWithinRow,
3711 });
3712 }
3713 updateScrollFades();
3714 }, [anchorKey, updateScrollFades, viewState]);
3715
3716 const handleLoad = useCallback(() => {
3717 const list = listRef.current;
3718 const element = list?.getScrollableNode();
3719 if (initialScrollIndex && list && element) {
3720 // Bootstrap can report the restored target before the DOM has applied it.
3721 // Reconcile once at load, before releasing the measured anchor row.
3722 const offset = Math.max(
3723 0,
3724 Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight),
3725 );
3726 if (Math.abs(element.scrollTop - offset) > 1) {
3727 void list.scrollToOffset({ offset, animated: false });
3728 }
3729 }
3730 setRestoringPosition(false);
3731 }, [initialScrollIndex]);
3732
3733 useLayoutEffect(() => {
3734 const element = listRef.current?.getScrollableNode();
3735 if (!element) return;
3736 updateScrollFades();
3737 const content = element.firstElementChild;
3738 return observeResize(content ? [element, content] : element, updateScrollFades);
3739 }, [updateScrollFades]);
3740
3741 const renderEntry = useCallback(
3742 ({ item }: { item: TimelineWorkEntry }) => (
3743 <SimpleWorkEntryRow key={item.id} workEntry={item} workspaceRoot={workspaceRoot} />
3744 ),
3745 [workspaceRoot],
3746 );
3747
3748 const updateExpandedContentHeight = useCallback(() => {
3749 const state = listRef.current?.getState();
3750 let height = 0;
3751 for (const entryId of viewState.expandedEntries) {
3752 if (state?.indexByKey(entryId) === undefined) continue;
3753 // Each open row adds room for its details, including while scrolled out of view.
3754 height += Math.max(
3755 0,
3756 (state.sizes.get(entryId) ?? compactWorkEntryHeight) - compactWorkEntryHeight,
3757 );
3758 }
3759 setExpandedContentHeight(height);
3760 }, [viewState]);
3761
3762 useLayoutEffect(updateExpandedContentHeight, [entries, updateExpandedContentHeight]);
3763
3764 return (
3765 <WorkGroupViewCtx value={groupView}>
3766 <WorkLogList>
3767 <LegendList
3768 ref={listRef}
3769 data={entries}
3770 extraData={workspaceRoot}
3771 keyExtractor={workEntryKey}
3772 renderItem={renderEntry}
3773 estimatedItemSize={compactWorkEntryHeight}
3774 drawDistance={240}
3775 recycleItems
3776 {...(initialScrollIndex ? { initialScrollIndex } : {})}
3777 maintainScrollAtEnd={
3778 appendState.follow ? { animated: false, on: { dataChange: true } } : false
3779 }
3780 maintainScrollAtEndThreshold={1 / Math.max(1, fades.viewportHeight)}
3781 // Measure the restored row even when an intra-row offset puts its
3782 // estimated bounds outside the list's small bootstrap render window.
3783 {...(restoringPosition && initialScrollIndex
3784 ? { alwaysRender: { indices: [initialScrollIndex.index] } }
3785 : {})}
3786 maintainVisibleContentPosition
3787 onLoad={handleLoad}
3788 onScroll={handleScroll}
3789 onLayout={updateScrollFades}
3790 onItemSizeChanged={updateExpandedContentHeight}
3791 tabIndex={0}
3792 role="region"
3793 aria-label="Tool calls"
3794 data-tool-group-scroll
3795 style={{ maxHeight: `calc(min(18rem, 50dvh) + ${expandedContentHeight}px)` }}
3796 className={cn(
3797 "scrollbar-gutter-stable scroll-py-6 overflow-x-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
3798 getVirtualizedScrollFadeClassName(fades),
3799 )}
3800 />
3801 </WorkLogList>
3802 </WorkGroupViewCtx>
3803 );
3804}
3805
3806const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
3807
3808function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
3809 return (
3810 <span
3811 aria-hidden
3812 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
3813 >
3814 <span className="live-activity-focus-counter block">
3815 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
3816 </span>
3817 </span>
3818 );
3819}
3820
3821const failedToolIconClassName = "text-tool-error-icon/40";
3822
3823/** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */
3824function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean {
3825 return toolIcon === undefined;
3826}
3827
3828function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
3829 const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } =
3830 use(TimelineRowActivityCtx);
3831 // One span for every label so the setup-to-working handoff swaps text in
3832 // place instead of remounting the row.
3833 const compacting = row.compaction?.toolLifecycleStatus === "inProgress";
3834 const compacted = row.compaction?.toolLifecycleStatus === "completed";
3835 const projectedCompaction = row.compaction?.projectedItem?.item;
3836 const compactionStartedAt = projectedCompaction
3837 ? projectedCompaction.startedAt === null
3838 ? undefined
3839 : DateTime.formatIso(projectedCompaction.startedAt)
3840 : row.compaction?.createdAt;
3841 const shimmer = isPreparingWorktree || (isCompacting && !compacting);
3842 const label = isPreparingWorktree ? (
3843 "Setting up worktree…"
3844 ) : compacting || isCompacting ? (
3845 <CompactingLabel createdAt={compacting ? compactionStartedAt : undefined} />
3846 ) : row.createdAt ? (
3847 <>
3848 Working for <WorkingTimer createdAt={row.createdAt} />
3849 {compacted ? " (context compacted)" : null}
3850 </>
3851 ) : (
3852 "Working..."
3853 );
3854 return (
3855 <div className="border-b border-border/60 pb-2 pt-1">
3856 <div className="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
3857 <span
3858 role="status"
3859 aria-label={
3860 isPreparingWorktree
3861 ? "Setting up worktree"
3862 : compacting || isCompacting
3863 ? "Compacting context"
3864 : compacted
3865 ? "Working (context compacted)"
3866 : "Working"
3867 }
3868 ref={shimmer ? observeVisibleAnimation : undefined}
3869 className="relative shrink-0 overflow-hidden whitespace-nowrap"
3870 >
3871 <span aria-hidden="true">{label}</span>
3872 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
3873 </span>
3874 {backgroundWorktreeSetup ? (
3875 <BackgroundWorktreeSetupChip snapshot={backgroundWorktreeSetup} />
3876 ) : null}
3877 </div>
3878 </div>
3879 );
3880}
3881
3882/**
3883 * Trailing chip in the working header while a setup script still runs after
3884 * the agent started. Opens the stage list and live output in a popover; the
3885 * chip leaves with the script, so nothing lingers in the timeline.
3886 */
3887function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
3888 const ctx = use(TimelineRowCtx);
3889 const terminalId = snapshot.setupScript?.terminalId ?? null;
3890 const openTerminal = ctx.onOpenWorktreeSetupTerminal;
3891 const onOpenTerminal = useMemo(
3892 () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null),
3893 [openTerminal, terminalId],
3894 );
3895 const scriptName = snapshot.setupScript?.name ?? "Setup script";
3896 return (
3897 <Popover>
3898 <PopoverTrigger
3899 render={
3900 <Button
3901 variant="ghost-muted"
3902 size="micro"
3903 className="ml-auto min-w-0 shrink-0"
3904 aria-label={`${scriptName} is still running. Show setup progress.`}
3905 />
3906 }
3907 >
3908 <Spinner className="size-3 shrink-0" />
3909 <span className="truncate">{scriptName}</span>
3910 </PopoverTrigger>
3911 <PopoverPopup side="bottom" align="end" width="lg" padding="compact">
3912 <WorktreeSetupCard
3913 snapshot={snapshot}
3914 embedded
3915 onCancel={null}
3916 onWorkLocally={null}
3917 onOpenTerminal={onOpenTerminal}
3918 />
3919 </PopoverPopup>
3920 </Popover>
3921 );
3922}
3923
3924function CompactingLabel({ createdAt }: { createdAt?: string | undefined }) {
3925 return (
3926 <span className="inline-flex items-center gap-1.5">
3927 <Minimize2Icon aria-hidden="true" className="size-3" />
3928 {createdAt ? (
3929 <span>
3930 Compacting for <WorkingTimer createdAt={createdAt} />
3931 </span>
3932 ) : (
3933 "Compacting…"
3934 )}
3935 </span>
3936 );
3937}
3938
3939function ThinkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "thinking" }> }) {
3940 const ctx = use(TimelineRowCtx);
3941 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
3942 // Reserve the activity row during setup so the handoff keeps the same height.
3943 if (isPreparingWorktree || isCompacting) return <WorkLogRow label="" />;
3944 const activity = <LiveActivityRow label="Thinking" iconName="brain" active shimmer />;
3945 const { groupId } = row;
3946 if (groupId === undefined) return activity;
3947 return (
3948 <button
3949 type="button"
3950 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
3951 aria-expanded={row.expanded === true}
3952 onClick={() => ctx.onToggleWorkGroup(groupId, row.id)}
3953 >
3954 {activity}
3955 </button>
3956 );
3957}
3958
3959function LiveActivityRow({
3960 label,
3961 iconName,
3962 toolIcon,
3963 failed = false,
3964 active = false,
3965 shimmer = false,
3966}: {
3967 label: ReactNode;
3968 iconName?: WorkEntryIconName;
3969 toolIcon?: ToolActivityIcon | undefined;
3970 failed?: boolean;
3971 active?: boolean;
3972 shimmer?: boolean;
3973}) {
3974 const animated = active && !failed;
3975 const showShimmer = animated && shimmer;
3976 return (
3977 <div
3978 ref={animated ? observeVisibleAnimation : undefined}
3979 className={cn(
3980 "relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed",
3981 animated && "live-activity-running",
3982 )}
3983 >
3984 <LiveActivityContent
3985 label={label}
3986 iconName={iconName}
3987 toolIcon={toolIcon}
3988 failed={failed}
3989 announceFailure={failed}
3990 active={animated && !shimmer}
3991 />
3992 {showShimmer ? (
3993 <ActivityShimmerOverlay>
3994 <LiveActivityContent label={label} iconName={iconName} toolIcon={toolIcon} highlighted />
3995 </ActivityShimmerOverlay>
3996 ) : null}
3997 </div>
3998 );
3999}
4000
4001function LiveActivityContent({
4002 label,
4003 iconName,
4004 toolIcon,
4005 failed = false,
4006 announceFailure = false,
4007 active = false,
4008 highlighted = false,
4009}: {
4010 label: ReactNode;
4011 iconName: WorkEntryIconName | undefined;
4012 toolIcon?: ToolActivityIcon | undefined;
4013 failed?: boolean;
4014 announceFailure?: boolean;
4015 active?: boolean;
4016 highlighted?: boolean;
4017}) {
4018 const showTrailingFailureMark =
4019 failed && iconName !== undefined && !toolIconAcceptsTint(toolIcon);
4020
4021 return (
4022 <WorkLogRow
4023 icon={
4024 iconName ? (
4025 <span
4026 className={cn(
4027 "flex size-4 items-center justify-center",
4028 failed
4029 ? failedToolIconClassName
4030 : highlighted
4031 ? "text-foreground"
4032 : "text-icon-muted",
4033 )}
4034 role={announceFailure ? "img" : undefined}
4035 aria-label={announceFailure ? "Tool call failed" : undefined}
4036 >
4037 <ToolActivityIconView
4038 icon={toolIcon}
4039 fallbackName={iconName}
4040 className="block size-4 shrink-0 stroke-2"
4041 muted={!highlighted && !active}
4042 />
4043 </span>
4044 ) : null
4045 }
4046 label={
4047 <span
4048 className={cn(
4049 "block truncate",
4050 highlighted && "text-foreground",
4051 active && "live-tool-shine",
4052 )}
4053 >
4054 {label}
4055 </span>
4056 }
4057 trailing={
4058 showTrailingFailureMark ? (
4059 <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} />
4060 ) : null
4061 }
4062 />
4063 );
4064}
4065
4066/** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */
4067function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) {
4068 const rawThreadId = threadReadTargetId(entry);
4069 const threadId = rawThreadId === null ? null : ThreadId.make(rawThreadId);
4070 const shell = useThreadShell(threadId ? scopeThreadRef(environmentId, threadId) : null);
4071 const title = threadReadTargetTitle(shell);
4072 return threadId && title ? { threadId, title } : null;
4073}
4074
4075function threadReadLabel(label: string, target: ReturnType<typeof useThreadReadTarget>) {
4076 const prefix = target && threadReadLabelPrefix(label);
4077 return prefix ? { ...target, prefix, text: `${prefix} “${target.title}”` } : null;
4078}
4079
4080/** Only settled rows link the title; the live row is itself a button. */
4081function ThreadReadLabel({
4082 label,
4083 environmentId,
4084 linked,
4085}: {
4086 label: NonNullable<ReturnType<typeof threadReadLabel>>;
4087 environmentId: EnvironmentId;
4088 linked: boolean;
4089}) {
4090 return (
4091 <span className="flex min-w-0">
4092 <span className="shrink-0">{label.prefix} “</span>
4093 {linked ? (
4094 <Link
4095 to="/$environmentId/$threadId"
4096 params={{ environmentId, threadId: label.threadId }}
4097 className="min-w-0 truncate rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
4098 onClick={stopRowToggle}
4099 onKeyDown={stopRowToggle}
4100 >
4101 {label.title}
4102 </Link>
4103 ) : (
4104 <span className="min-w-0 truncate">{label.title}</span>
4105 )}
4106 <span className="shrink-0">”</span>
4107 </span>
4108 );
4109}
4110
4111function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
4112 const ctx = use(TimelineRowCtx);
4113 const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId);
4114 const questionHeading = row.entry.questionAnswer
4115 ? getQuestionTextPreview(row.entry.questionAnswer)
4116 : "";
4117 const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
4118 const threadLabel = threadReadLabel(label, threadTarget);
4119
4120 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
4121 // The expanded group already lists the thought, so the preview steps aside.
4122 const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : "";
4123 // While the thought itself streams, the status line just says Thinking.
4124 const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought;
4125
4126 return (
4127 <button
4128 type="button"
4129 className={cn(
4130 "group/live-work flex w-full max-w-full cursor-pointer rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
4131 thoughtLine ? "flex-col items-start" : "min-h-6 items-center",
4132 )}
4133 aria-label={
4134 failed
4135 ? `${thoughtLine ? `${thoughtLine} ` : ""}${threadLabel?.text ?? label}, tool call failed`
4136 : undefined
4137 }
4138 aria-expanded={row.expanded}
4139 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
4140 >
4141 {thoughtLine ? (
4142 // The latest thought's first sentence sits above the status line as an
4143 // ordinary work-log row, so a finding never hides behind the next tool call.
4144 <WorkLogRow
4145 icon={
4146 <span className="flex size-4 items-center justify-center text-icon-muted">
4147 <ToolActivityIconView
4148 icon={undefined}
4149 fallbackName="brain"
4150 className="block size-4 shrink-0 stroke-2"
4151 muted
4152 />
4153 </span>
4154 }
4155 label={<span className="line-clamp-4">{thoughtLine}</span>}
4156 wrapLabel
4157 />
4158 ) : null}
4159 <LiveActivityRow
4160 label={
4161 thoughtIsStatus ? (
4162 row.active ? (
4163 "Thinking"
4164 ) : (
4165 "Thought"
4166 )
4167 ) : row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? (
4168 <span className="flex min-w-0 gap-1.5">
4169 <span className="min-w-0 truncate">{label}</span>
4170 <span className="min-w-0 truncate text-foreground">
4171 {getQuestionAnswerPreview(row.entry.questionAnswer)}
4172 </span>
4173 </span>
4174 ) : row.entry.itemType === "reasoning" ? (
4175 <ReactMarkdown
4176 remarkPlugins={[
4177 remarkGfm,
4178 [remarkThoughtPreview, row.active ? "Thinking" : "Thought"],
4179 ]}
4180 >
4181 {row.entry.detail ?? label}
4182 </ReactMarkdown>
4183 ) : threadLabel ? (
4184 <ThreadReadLabel
4185 label={threadLabel}
4186 environmentId={ctx.activeThreadEnvironmentId}
4187 linked={false}
4188 />
4189 ) : (
4190 label
4191 )
4192 }
4193 iconName={workEntryIconName(row.entry)}
4194 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
4195 failed={failed}
4196 active={row.active}
4197 />
4198 </button>
4199 );
4200}
4201
4202function toolGroupSummaryIconName(
4203 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
4204): WorkEntryIconName {
4205 switch (kind) {
4206 case "pull-request":
4207 case "link-pr":
4208 case "unlink-pr":
4209 case "list-prs":
4210 case "watch-pr":
4211 case "unwatch-pr":
4212 return "pull-request";
4213 case "read":
4214 return "eye";
4215 case "edit":
4216 return "square-pen";
4217 case "command":
4218 return "terminal";
4219 case "thread-create":
4220 return "t3-code";
4221 case "browser":
4222 return "browser";
4223 case "device":
4224 return "device";
4225 case "search":
4226 return "globe";
4227 case "code-search":
4228 return "search";
4229 case "other":
4230 return "wrench";
4231 case "dynamic-tool":
4232 return "hammer";
4233 case "reasoning":
4234 return "brain";
4235 case "agent-tool":
4236 return "bot";
4237 case "tone-tool":
4238 return "zap";
4239 case "update":
4240 case "mixed":
4241 return "hammer";
4242 }
4243}
4244
4245function WorkGroupToggleTimelineRow({
4246 row,
4247}: {
4248 row: Extract<TimelineRow, { kind: "work-toggle" }>;
4249}) {
4250 const ctx = use(TimelineRowCtx);
4251 return (
4252 <WorkGroupHeader
4253 label={row.summary}
4254 iconName={row.summaryToolIcon ?? row.toolSurface ?? toolGroupSummaryIconName(row.summaryKind)}
4255 toolIcon={row.toolIcon}
4256 failed={row.hasFailure}
4257 expanded={row.expanded}
4258 createdAt={row.createdAt}
4259 timestampFormat={ctx.timestampFormat}
4260 onToggle={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
4261 />
4262 );
4263}
4264
4265function WorkGroupHeader(props: {
4266 label: string;
4267 iconName: WorkEntryIconName;
4268 toolIcon?: ToolActivityIcon | undefined;
4269 failed?: boolean | undefined;
4270 active?: boolean | undefined;
4271 expanded: boolean;
4272 createdAt: string;
4273 timestampFormat: TimestampFormat;
4274 onToggle: () => void;
4275}) {
4276 return (
4277 <WorkLogButton
4278 ref={props.active && !props.failed ? observeVisibleAnimation : undefined}
4279 aria-label={props.failed ? `${props.label}, tool call failed` : props.label}
4280 aria-expanded={props.expanded}
4281 onClick={props.onToggle}
4282 icon={
4283 <ToolActivityIconView
4284 icon={props.toolIcon}
4285 fallbackName={props.iconName}
4286 className="size-4 shrink-0 stroke-2 text-icon-muted"
4287 muted
4288 />
4289 }
4290 label={
4291 <span className={cn("block truncate", props.active && !props.failed && "live-tool-shine")}>
4292 {props.label}
4293 </span>
4294 }
4295 trailing={
4296 <TimelineRowTimestamp createdAt={props.createdAt} timestampFormat={props.timestampFormat} />
4297 }
4298 />
4299 );
4300}
4301
4302/** Subscribes directly to the UI state store for expand/collapse state,
4303 * so toggling re-renders only this component — not the entire list. */
4304const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({
4305 turnSummary,
4306 routeThreadKey,
4307 resolvedTheme,
4308 onOpenTurnDiff,
4309}: {
4310 turnSummary: TurnDiffSummary | undefined;
4311 routeThreadKey: string;
4312 resolvedTheme: "light" | "dark";
4313 displayThreadKey?: string;
4314 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
4315}) {
4316 if (!turnSummary) return null;
4317 const checkpointFiles = turnSummary.files;
4318 if (checkpointFiles.length === 0) return null;
4319
4320 return (
4321 <AssistantChangedFilesSectionInner
4322 turnSummary={turnSummary}
4323 checkpointFiles={checkpointFiles}
4324 routeThreadKey={routeThreadKey}
4325 resolvedTheme={resolvedTheme}
4326 onOpenTurnDiff={onOpenTurnDiff}
4327 />
4328 );
4329});
4330
4331/** Inner component that only mounts when there are actual changed files,
4332 * so the store subscription is unconditional (no hooks after early return). */
4333function AssistantChangedFilesSectionInner({
4334 turnSummary,
4335 checkpointFiles,
4336 routeThreadKey,
4337 resolvedTheme,
4338 onOpenTurnDiff,
4339}: {
4340 turnSummary: TurnDiffSummary;
4341 checkpointFiles: TurnDiffSummary["files"];
4342 routeThreadKey: string;
4343 resolvedTheme: "light" | "dark";
4344 displayThreadKey?: string;
4345 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
4346}) {
4347 const ctx = use(TimelineRowCtx);
4348 const persistedExpanded = useUiStateStore(
4349 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.runId],
4350 );
4351 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
4352 const allDirectoriesExpanded = persistedExpanded ?? false;
4353
4354 const thread = useThreadShell(ctx.threadRef);
4355 const activeProject = useProject(
4356 thread && thread.projectId
4357 ? { environmentId: thread.environmentId, projectId: thread.projectId }
4358 : null,
4359 );
4360 const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId);
4361
4362 return (
4363 <ChangedFilesCard
4364 runId={turnSummary.runId}
4365 files={checkpointFiles}
4366 allDirectoriesExpanded={allDirectoriesExpanded}
4367 resolvedTheme={resolvedTheme}
4368 onToggleAllDirectories={() =>
4369 setExpanded(routeThreadKey, turnSummary.runId, !allDirectoriesExpanded)
4370 }
4371 onOpenTurnDiff={onOpenTurnDiff}
4372 onFileContextMenu={(filePath, event) =>
4373 onFileContextMenu(
4374 {
4375 environmentId: ctx.activeThreadEnvironmentId,
4376 filePath,
4377 workspaceRoot: ctx.workspaceRoot,
4378 repositoryRoot:
4379 thread?.worktreePath == null
4380 ? activeProject?.repositoryIdentity?.rootPath
4381 : undefined,
4382 },
4383 event,
4384 )
4385 }
4386 />
4387 );
4388}
4389
4390// ---------------------------------------------------------------------------
4391// Leaf components
4392// ---------------------------------------------------------------------------
4393
4394function UserMessageMentionChip(props: {
4395 record: Extract<KnownComposerContextRecord, { kind: "mention" }>;
4396 copyMarkdown: string;
4397}) {
4398 const ctx = use(TimelineRowCtx);
4399 return (
4400 <Tooltip>
4401 <TooltipTrigger
4402 render={
4403 <ContextChip
4404 kind="mention"
4405 render={<button type="button" />}
4406 aria-label={`Preview ${props.record.path}`}
4407 data-markdown-copy={props.copyMarkdown}
4408 onClick={() => {
4409 if (ctx.threadRef)
4410 useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path);
4411 }}
4412 >
4413 <WorkspaceEntryIcon
4414 path={props.record.path}
4415 environmentId={ctx.activeThreadEnvironmentId}
4416 cwd={ctx.workspaceRoot}
4417 theme={ctx.resolvedTheme}
4418 />
4419 <ContextChipLabel>{props.record.label}</ContextChipLabel>
4420 </ContextChip>
4421 }
4422 />
4423 <TooltipPopup>
4424 <WorkspaceEntryTooltip
4425 path={props.record.path}
4426 environmentId={ctx.activeThreadEnvironmentId}
4427 cwd={ctx.workspaceRoot}
4428 />
4429 </TooltipPopup>
4430 </Tooltip>
4431 );
4432}
4433
4434function UserMessageContextChip(props: {
4435 icon: ReactNode;
4436 label: string;
4437 kindLabel?: string;
4438 copyMarkdown: string;
4439 tooltip?: string;
4440 kind: ContextChipKind;
4441}) {
4442 return (
4443 <ContextChipShell
4444 kind={props.kind}
4445 icon={props.icon}
4446 label={props.label}
4447 aria-label={props.kindLabel ? `${props.kindLabel}, ${props.label}` : undefined}
4448 data-markdown-copy={props.copyMarkdown}
4449 tooltip={props.tooltip}
4450 />
4451 );
4452}
4453
4454function UserMessagePullRequestContextChip(props: {
4455 record: Extract<KnownComposerContextRecord, { kind: "review-comment" }>;
4456 copyMarkdown: string;
4457 kind: ContextChipKind;
4458}) {
4459 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
4460 const metadata = props.record.pullRequest;
4461 if (metadata === undefined) return null;
4462 return (
4463 <PullRequestChip
4464 metadata={metadata}
4465 environmentId={activeThreadEnvironmentId}
4466 label={reviewCommentContextLabel(props.record)}
4467 kindLabel={pullRequestContextKindLabel(props.record)}
4468 kind={props.kind}
4469 copyMarkdown={props.copyMarkdown}
4470 onOpen={openPullRequest}
4471 />
4472 );
4473}
4474
4475function UserMessagePreviewAnnotationDetails(props: {
4476 record: Extract<KnownComposerContextRecord, { kind: "preview-annotation" }>;
4477 image: ChatImageAttachment | null;
4478}) {
4479 const ctx = use(TimelineRowCtx);
4480 const visibleElements = props.record.elements ?? [];
4481 return (
4482 <div className="max-w-full overflow-hidden rounded-lg border border-border/70 bg-background/70">
4483 {props.image?.previewUrl ? (
4484 <button
4485 type="button"
4486 className="block max-h-64 w-full cursor-zoom-in overflow-hidden border-b border-border/70 bg-muted"
4487 aria-label={`Preview ${props.image.name}`}
4488 onClick={() => {
4489 if (!props.image) return;
4490 const preview = buildExpandedImagePreview([props.image], props.image.id);
4491 if (preview) ctx.onImageExpand(preview);
4492 }}
4493 >
4494 <img
4495 src={props.image.previewUrl}
4496 alt="Annotated preview crop"
4497 className="max-h-64 w-full object-contain"
4498 />
4499 </button>
4500 ) : (
4501 <div className="border-b border-border/70 bg-muted/40 px-3 py-2 text-secondary-label text-xs">
4502 Screenshot unavailable
4503 </div>
4504 )}
4505 <div className="min-w-0 px-3 py-2.5">
4506 <div className="text-message-foreground text-xs font-medium">
4507 {props.record.pageTitle?.trim() || props.record.pageUrl || "Preview annotation"}
4508 </div>
4509 {props.record.comment ? (
4510 <div className="mt-1 whitespace-pre-wrap wrap-break-word text-sm">
4511 {props.record.comment}
4512 </div>
4513 ) : null}
4514 <div className="mt-1 flex items-center gap-2 text-secondary-label text-3xs">
4515 {props.record.targetSummary ? (
4516 <span className="truncate">{props.record.targetSummary}</span>
4517 ) : null}
4518 {(props.record.styleChanges?.length ?? 0) > 0 ? (
4519 <span className="inline-flex shrink-0 items-center gap-1">
4520 <PaintbrushIcon className="size-3" />
4521 {props.record.styleChanges?.length ?? 0}
4522 </span>
4523 ) : null}
4524 </div>
4525 {visibleElements.length > 0 ? (
4526 <div className="mt-2 space-y-2 border-t border-border/60 pt-2">
4527 {visibleElements.map((element) => {
4528 const source = element.source;
4529 const sourceLabel = source?.fileName
4530 ? `${source.fileName}${source.lineNumber === null ? "" : `:${source.lineNumber}`}`
4531 : null;
4532 return (
4533 <div
4534 key={`${element.selector}\u0000${element.tagName}\u0000${sourceLabel ?? ""}\u0000${element.htmlPreview}`}
4535 className="min-w-0"
4536 >
4537 <div className="flex min-w-0 items-center gap-2 text-xs">
4538 <code className="truncate text-message-foreground">
4539 {element.selector || `<${element.tagName}>`}
4540 </code>
4541 {sourceLabel ? (
4542 <span className="ml-auto shrink-0 text-secondary-label">{sourceLabel}</span>
4543 ) : null}
4544 </div>
4545 {element.htmlPreview?.trim() ? (
4546 <pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap rounded bg-muted/60 px-2 py-1.5 text-3xs leading-relaxed">
4547 {element.htmlPreview.trim()}
4548 </pre>
4549 ) : null}
4550 </div>
4551 );
4552 })}
4553 {(props.record.elements?.length ?? 0) > visibleElements.length ? (
4554 <div className="text-secondary-label text-3xs">
4555 {(props.record.elements?.length ?? 0) - visibleElements.length} more selected
4556 elements
4557 </div>
4558 ) : null}
4559 </div>
4560 ) : null}
4561 </div>
4562 </div>
4563 );
4564}
4565
4566function UserMessageElementDetails({
4567 record,
4568}: {
4569 record: Extract<KnownComposerContextRecord, { kind: "element" }>;
4570}) {
4571 const sourceLabel = record.source?.fileName
4572 ? `${record.source.fileName}${record.source.lineNumber === null ? "" : `:${record.source.lineNumber}`}`
4573 : null;
4574 return (
4575 <div className="max-w-full overflow-hidden rounded-lg border border-border/70 bg-background/70">
4576 <div className="border-b border-border/70 px-3 py-2.5">
4577 <div className="truncate text-message-foreground text-xs font-medium">
4578 {record.pageTitle?.trim() || record.pageUrl}
4579 </div>
4580 <div className="mt-0.5 truncate text-secondary-label text-3xs">{record.pageUrl}</div>
4581 </div>
4582 <div className="space-y-2 px-3 py-2.5">
4583 <div className="flex min-w-0 items-center gap-2 text-xs">
4584 <code className="truncate text-message-foreground">
4585 {record.selector || `<${record.tagName}>`}
4586 </code>
4587 {sourceLabel ? (
4588 <span className="ml-auto shrink-0 text-secondary-label">{sourceLabel}</span>
4589 ) : null}
4590 </div>
4591 {record.htmlPreview?.trim() ? (
4592 <div className="flex h-40 flex-col overflow-hidden rounded border border-border">
4593 <ReadOnlySourcePreview name="element.html" text={record.htmlPreview} />
4594 </div>
4595 ) : null}
4596 {record.styles?.trim() ? (
4597 <div className="flex h-32 flex-col overflow-hidden rounded border border-border">
4598 <ReadOnlySourcePreview name="styles.css" text={record.styles} />
4599 </div>
4600 ) : null}
4601 </div>
4602 </div>
4603 );
4604}
4605
4606interface UserMessageContextRenderContext {
4607 reference: ChatMarkdownContextReference;
4608 annotationImage: ChatImageAttachment | null;
4609 attachment: ChatImageAttachment | ChatFileAttachment | null;
4610 resolvedTheme: "light" | "dark";
4611 copyMarkdown: string;
4612 onExpandImage: (image: ChatImageAttachment) => void;
4613 onExpandVideo: (file: ChatFileAttachment) => void;
4614 onOpenFile: (file: ChatFileAttachment) => void;
4615}
4616
4617function UnavailableUserMessageContextChip(props: UserMessageContextRenderContext) {
4618 return (
4619 <UnresolvedChip
4620 label={props.reference.label}
4621 copyMarkdown={props.copyMarkdown}
4622 tooltip="This context is no longer available."
4623 />
4624 );
4625}
4626
4627const userMessageContextPresentationRegistry = createContextPresentationRegistry<
4628 KnownComposerContextRecord,
4629 UserMessageContextRenderContext,
4630 ReactNode
4631>({
4632 requiredKinds: COMPOSER_CONTEXT_KINDS,
4633 handlers: [
4634 {
4635 kind: "mention",
4636 canRender: (record) => record.kind === "mention",
4637 render: (record, context) =>
4638 record.kind === "mention" ? (
4639 <UserMessageMentionChip record={record} copyMarkdown={context.copyMarkdown} />
4640 ) : (
4641 <UnavailableUserMessageContextChip {...context} />
4642 ),
4643 },
4644 {
4645 kind: "skill",
4646 canRender: (record) => record.kind === "skill",
4647 render: (record, context) =>
4648 record.kind === "skill" ? (
4649 <UserMessageContextChip
4650 icon={<SkillChipIcon />}
4651 label={record.label || record.name}
4652 kindLabel="Skill"
4653 tooltip={`$${record.name}`}
4654 copyMarkdown={context.copyMarkdown}
4655 kind="skill"
4656 />
4657 ) : (
4658 <UnavailableUserMessageContextChip {...context} />
4659 ),
4660 },
4661 {
4662 kind: "thread",
4663 canRender: (record) => record.kind === "thread",
4664 render: (record, context) =>
4665 record.kind === "thread" ? (
4666 <ThreadContextChip record={record} copyMarkdown={context.copyMarkdown} />
4667 ) : (
4668 <UnavailableUserMessageContextChip {...context} />
4669 ),
4670 },
4671 {
4672 kind: "image",
4673 canRender: (record, context) =>
4674 record.kind === "image" &&
4675 context.attachment !== null &&
4676 isImageAttachment(context.attachment),
4677 render: (record, context) => {
4678 if (
4679 record.kind !== "image" ||
4680 context.attachment === null ||
4681 !isImageAttachment(context.attachment)
4682 ) {
4683 return <UnavailableUserMessageContextChip {...context} />;
4684 }
4685 const attachment = context.attachment;
4686 return (
4687 <ImageChipButton
4688 name={record.name}
4689 previewUrl={attachment.previewUrl}
4690 size={formatAttachmentSize(record.sizeBytes)}
4691 data-markdown-copy={context.copyMarkdown}
4692 onClick={() => context.onExpandImage(attachment)}
4693 />
4694 );
4695 },
4696 },
4697 {
4698 kind: "file",
4699 // A file chip names its attachment by id, so it renders whatever came back under that id.
4700 // `isFileAttachment` excludes pictures, which a legacy `file` attachment may still be.
4701 canRender: (record, context) =>
4702 record.kind === "file" && context.attachment !== null && context.attachment.type === "file",
4703 render: (record, context) => {
4704 if (
4705 record.kind !== "file" ||
4706 context.attachment === null ||
4707 context.attachment.type !== "file"
4708 ) {
4709 return <UnavailableUserMessageContextChip {...context} />;
4710 }
4711 const attachment = context.attachment;
4712 const isVideo = isVideoAttachment(attachment);
4713 const disabled =
4714 attachment.downloadable === false && (!isVideo || attachment.previewUrl === undefined);
4715 const size = formatAttachmentSize(record.sizeBytes);
4716 return (
4717 <FileChip
4718 name={record.name}
4719 size={size}
4720 isVideo={isVideo}
4721 theme={context.resolvedTheme}
4722 disabled={disabled}
4723 accessibleLabel={`${isVideo ? "Video" : "File"} attachment, ${record.name}, ${size}`}
4724 copyMarkdown={context.copyMarkdown}
4725 onOpen={() =>
4726 isVideo ? context.onExpandVideo(attachment) : context.onOpenFile(attachment)
4727 }
4728 tooltip={`${record.name}\n${size}`}
4729 />
4730 );
4731 },
4732 },
4733 {
4734 kind: "terminal",
4735 canRender: (record) => record.kind === "terminal",
4736 render: (record, context, definition) =>
4737 record.kind === "terminal" ? (
4738 <span data-markdown-copy={context.copyMarkdown}>
4739 <TerminalContextInlineChip
4740 label={record.label}
4741 terminalLabel={record.terminalLabel}
4742 lineStart={record.lineStart}
4743 lineEnd={record.lineEnd}
4744 text={record.text}
4745 detailsMode={definition.capabilities.details}
4746 />
4747 </span>
4748 ) : (
4749 <UnavailableUserMessageContextChip {...context} />
4750 ),
4751 },
4752 {
4753 kind: "element",
4754 canRender: (record) => record.kind === "element",
4755 render: (record, context) =>
4756 record.kind === "element" ? (
4757 <UserMessageContextPopover
4758 copyMarkdown={context.copyMarkdown}
4759 accessibleLabel={`Browser element, ${record.label}`}
4760 kind="element"
4761 icon={<MousePointerClickIcon />}
4762 label={record.label}
4763 >
4764 <UserMessageElementDetails record={record} />
4765 </UserMessageContextPopover>
4766 ) : (
4767 <UnavailableUserMessageContextChip {...context} />
4768 ),
4769 },
4770 {
4771 kind: "review-comment",
4772 canRender: (record) => record.kind === "review-comment",
4773 render: (record, context) => {
4774 if (record.kind !== "review-comment") {
4775 return <UnavailableUserMessageContextChip {...context} />;
4776 }
4777 const isPullRequest = isPullRequestSummaryContext(record);
4778 const label = reviewCommentContextLabel(record);
4779 const kindLabel = isPullRequest ? pullRequestContextKindLabel(record) : "Review comment";
4780 const pullRequestState = pullRequestContextDisplayState(record) ?? "unknown";
4781 if (isPullRequest && record.pullRequest !== undefined) {
4782 return (
4783 <UserMessagePullRequestContextChip
4784 record={record}
4785 copyMarkdown={context.copyMarkdown}
4786 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
4787 />
4788 );
4789 }
4790 return (
4791 <UserMessageContextPopover
4792 copyMarkdown={context.copyMarkdown}
4793 accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}
4794 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
4795 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
4796 label={label}
4797 >
4798 <UserMessageReviewCommentCard
4799 comment={{
4800 id: record.contextId,
4801 sectionId: record.sectionId,
4802 sectionTitle: record.sectionTitle,
4803 filePath: record.filePath,
4804 startIndex: record.startIndex,
4805 endIndex: record.endIndex,
4806 rangeLabel: record.rangeLabel,
4807 text: record.text,
4808 diff: record.diff,
4809 ...(record.fenceLanguage !== undefined
4810 ? { fenceLanguage: record.fenceLanguage }
4811 : {}),
4812 ...(record.pullRequest !== undefined ? { pullRequest: record.pullRequest } : {}),
4813 }}
4814 />
4815 </UserMessageContextPopover>
4816 );
4817 },
4818 },
4819 {
4820 kind: "preview-annotation",
4821 canRender: (record) => record.kind === "preview-annotation",
4822 render: (record, context) =>
4823 record.kind === "preview-annotation" ? (
4824 <UserMessageContextPopover
4825 copyMarkdown={context.copyMarkdown}
4826 accessibleLabel={`Preview annotation, ${record.label}`}
4827 kind="preview-annotation"
4828 icon={<MousePointerClickIcon />}
4829 label={record.label}
4830 >
4831 <UserMessagePreviewAnnotationDetails record={record} image={context.annotationImage} />
4832 </UserMessageContextPopover>
4833 ) : (
4834 <UnavailableUserMessageContextChip {...context} />
4835 ),
4836 },
4837 ],
4838 fallback: (_kind, _record, context) => <UnavailableUserMessageContextChip {...context} />,
4839});
4840
4841/** One inline context chip in a sent message, dispatched by the shared presentation registry. */
4842function UserMessageContextReferenceChip(props: {
4843 reference: ChatMarkdownContextReference;
4844 record: KnownComposerContextRecord | undefined;
4845 annotationImage: ChatImageAttachment | null;
4846 attachment: ChatImageAttachment | ChatFileAttachment | null;
4847 onExpandImage: (image: ChatImageAttachment) => void;
4848 onExpandVideo: (file: ChatFileAttachment) => void;
4849 onOpenFile: (file: ChatFileAttachment) => void;
4850}) {
4851 const { resolvedTheme } = use(TimelineRowCtx);
4852 const copyMarkdown = formatComposerContextReference({
4853 kind: props.reference.kind,
4854 contextId: props.reference.contextId as ComposerContextId,
4855 label: props.reference.label,
4856 });
4857 return userMessageContextPresentationRegistry.render(props.reference.kind, props.record, {
4858 reference: props.reference,
4859 annotationImage: props.annotationImage,
4860 attachment: props.attachment,
4861 resolvedTheme,
4862 copyMarkdown,
4863 onExpandImage: props.onExpandImage,
4864 onExpandVideo: props.onExpandVideo,
4865 onOpenFile: props.onOpenFile,
4866 });
4867}
4868
4869const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
4870const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;
4871
4872const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
4873 text: string;
4874 renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode;
4875 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
4876 markdownCwd: string | undefined;
4877 footer?: ReactNode;
4878}) {
4879 const [expanded, setExpanded] = useState(false);
4880 // Find opens the body only when it selects a match in the clipped part.
4881 const revealForFind = useCallback(() => setExpanded(true), []);
4882 const findRevealRef = useFindRevealRef(revealForFind);
4883 const hasVisibleBody = props.text.trim().length > 0;
4884 const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
4885 const isCollapsed = canCollapse && !expanded;
4886 const showCollapseControl = canCollapse;
4887
4888 return (
4889 <div>
4890 {hasVisibleBody ? (
4891 <div
4892 ref={findRevealRef}
4893 className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
4894 data-user-message-body="true"
4895 data-thread-find-text="true"
4896 data-thread-find-fold={isCollapsed ? "" : undefined}
4897 data-user-message-collapsed={isCollapsed ? "true" : "false"}
4898 data-user-message-collapsible={canCollapse ? "true" : "false"}
4899 data-user-message-fade={isCollapsed ? "true" : "false"}
4900 style={
4901 isCollapsed
4902 ? {
4903 WebkitMaskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
4904 maskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
4905 }
4906 : undefined
4907 }
4908 >
4909 <UserMessageBody
4910 text={props.text}
4911 renderContextReference={props.renderContextReference}
4912 skills={props.skills}
4913 markdownCwd={props.markdownCwd}
4914 />
4915 </div>
4916 ) : null}
4917 {showCollapseControl || props.footer ? (
4918 <div
4919 className={cn(
4920 "mt-1.5 flex items-center gap-2",
4921 showCollapseControl && props.footer ? "justify-between" : "justify-end",
4922 )}
4923 data-user-message-footer="true"
4924 >
4925 {showCollapseControl ? (
4926 <Button
4927 type="button"
4928 size="xs"
4929 variant="ghost-muted"
4930 aria-expanded={expanded}
4931 data-scroll-anchor-ignore
4932 onClick={() => setExpanded((value) => !value)}
4933 className="-ml-1"
4934 >
4935 {expanded ? "Show less" : "Show full message"}
4936 </Button>
4937 ) : null}
4938 {props.footer ? (
4939 <div className="ml-auto flex items-center gap-2">{props.footer}</div>
4940 ) : null}
4941 </div>
4942 ) : null}
4943 </div>
4944 );
4945});
4946
4947const UserMessageBody = memo(function UserMessageBody(props: {
4948 text: string;
4949 renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode;
4950 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
4951 markdownCwd: string | undefined;
4952}) {
4953 const ctx = use(TimelineRowCtx);
4954 if (props.text.length === 0) {
4955 return null;
4956 }
4957 return (
4958 <ChatMarkdown
4959 text={props.text}
4960 cwd={props.markdownCwd}
4961 threadRef={ctx.threadRef ?? undefined}
4962 skills={props.skills}
4963 className="text-foreground"
4964 lineBreaks
4965 parseRawHtml={false}
4966 renderContextReference={props.renderContextReference}
4967 headingLevelOffset={MESSAGE_HEADING_LEVEL}
4968 />
4969 );
4970});
4971
4972function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) {
4973 const ctx = use(TimelineRowCtx);
4974 const fenceLanguage = comment.fenceLanguage ?? "diff";
4975 const renderablePatch = getRenderablePatch(
4976 buildReviewCommentRenderablePatch(comment),
4977 `review-comment:${comment.id}`,
4978 );
4979
4980 return (
4981 <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">
4982 <div className="space-y-1">
4983 <div className="text-xs font-medium text-foreground">
4984 {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
4985 </div>
4986 <div className="text-2xs text-muted-foreground">
4987 {comment.sectionTitle} · {comment.rangeLabel}
4988 </div>
4989 </div>
4990 {comment.text.length > 0 && (
4991 <div className="whitespace-pre-wrap wrap-break-word text-sm">
4992 <SkillInlineText text={comment.text} skills={ctx.skills} />
4993 </div>
4994 )}
4995 {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && (
4996 <ChatMarkdown
4997 text={formatReviewCommentFence(fenceLanguage, comment.diff)}
4998 cwd={ctx.markdownCwd}
4999 threadRef={ctx.threadRef ?? undefined}
5000 skills={ctx.skills}
5001 className="text-foreground"
5002 />
5003 )}
5004 {renderablePatch?.kind === "files" && (
5005 <DiffWorkerPoolProvider>
5006 {renderablePatch.files.map((fileDiff) => (
5007 <FileDiff
5008 key={resolveFileDiffPath(fileDiff)}
5009 fileDiff={fileDiff}
5010 options={{
5011 collapsed: false,
5012 diffStyle: "unified",
5013 theme: resolveDiffThemeName(ctx.resolvedTheme),
5014 preferredHighlighter: PREFERRED_HIGHLIGHTER,
5015 }}
5016 />
5017 ))}
5018 </DiffWorkerPoolProvider>
5019 )}
5020 {renderablePatch?.kind === "raw" && (
5021 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">
5022 {renderablePatch.text}
5023 </pre>
5024 )}
5025 </div>
5026 );
5027}
5028
5029// ---------------------------------------------------------------------------
5030// Structural sharing — reuse old row references when data hasn't changed
5031// so LegendList (and React) can skip re-rendering unchanged items.
5032// ---------------------------------------------------------------------------
5033
5034/** Content-stable projection of the runs the handoff rows read. The incoming
5035 * array is rebuilt on every projection event (status/timestamp churn), but
5036 * the returned reference only changes when a run's identity-relevant fields
5037 * (id, ordinal, instance, model) do — keeping TimelineRowCtx stable. */
5038function useStableHandoffRuns(
5039 runs: ReadonlyArray<HandoffTimelineRun>,
5040): ReadonlyArray<HandoffTimelineRun> {
5041 const prev = useRef<{
5042 signature: string;
5043 value: ReadonlyArray<HandoffTimelineRun>;
5044 }>({ signature: "", value: EMPTY_TIMELINE_RUNS });
5045 return useMemo(() => {
5046 const signature = runs
5047 .map(
5048 (run) =>
5049 `${run.id}\0${run.providerInstanceId}\0${run.ordinal}\0${run.modelSelection.instanceId}\0${run.modelSelection.model}`,
5050 )
5051 .join("\n");
5052 if (signature === prev.current.signature) {
5053 return prev.current.value;
5054 }
5055 const value = runs.map((run) => ({
5056 id: run.id,
5057 ordinal: run.ordinal,
5058 providerInstanceId: run.providerInstanceId,
5059 modelSelection: run.modelSelection,
5060 }));
5061 prev.current = { signature, value };
5062 return value;
5063 }, [runs]);
5064}
5065
5066/** Returns a structurally-shared copy of `rows`: for each row whose content
5067 * hasn't changed since last call, the previous object reference is reused. */
5068function useStableRows(rows: MessagesTimelineRow[], identity: string): MessagesTimelineRow[] {
5069 const prevState = useRef<StableMessagesTimelineRowsState>({
5070 byId: new Map<string, MessagesTimelineRow>(),
5071 result: [],
5072 });
5073 const prevIdentity = useRef(identity);
5074
5075 return useMemo(() => {
5076 const previous =
5077 prevIdentity.current === identity
5078 ? prevState.current
5079 : { byId: new Map<string, MessagesTimelineRow>(), result: [] };
5080 prevIdentity.current = identity;
5081 const nextState = computeStableMessagesTimelineRows(rows, previous);
5082 prevState.current = nextState;
5083 return nextState.result;
5084 }, [identity, rows]);
5085}
5086
5087// ---------------------------------------------------------------------------
5088// Pure helpers
5089// ---------------------------------------------------------------------------
5090
5091type WorkEntryIconName =
5092 | "bot"
5093 | "brain"
5094 | "browser"
5095 | "check"
5096 | "circle-alert"
5097 | "computer"
5098 | "code"
5099 | "device"
5100 | "eye"
5101 | "globe"
5102 | "hammer"
5103 | "message-circle"
5104 | "search"
5105 | "square-pen"
5106 | "terminal"
5107 | "pull-request"
5108 | "t3-code"
5109 | "wrench"
5110 | "x"
5111 | "zap";
5112
5113function BrowserAppIcon({ className }: { className: string }) {
5114 return (
5115 <svg
5116 viewBox="0 0 24 24"
5117 fill="none"
5118 stroke="currentColor"
5119 strokeWidth="1.9"
5120 strokeLinecap="round"
5121 strokeLinejoin="round"
5122 className={className}
5123 aria-hidden
5124 >
5125 <path d="M8.5 19H7.2C4.4 19 3 17.5 3 14.6V7.4C3 4.5 4.5 3 7.4 3h8.2C18.5 3 20 4.5 20 7.4v2.4" />
5126 <circle cx="7.4" cy="7.2" r="0.75" fill="currentColor" stroke="none" />
5127 <path d="M11.2 7.2h4.3" />
5128 <path d="m12.4 11.4 7.5 2.6-3.4 1.6-1.5 3.6z" fill="currentColor" stroke="none" />
5129 </svg>
5130 );
5131}
5132
5133function ToolActivityIconView(props: {
5134 icon: ToolActivityIcon | undefined;
5135 fallbackName: WorkEntryIconName;
5136 className: string;
5137 muted: boolean;
5138}) {
5139 const { resolvedTheme } = use(TimelineRowCtx);
5140 const fallbackClassName = cn(props.className, props.muted && "opacity-70 light:brightness-60");
5141 if (!props.icon) {
5142 return <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} />;
5143 }
5144 if (props.icon._tag === "website") {
5145 const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32);
5146 return src ? (
5147 <ToolActivityImageIcon
5148 key={src}
5149 cacheKey={src}
5150 src={src}
5151 fallbackName={props.fallbackName}
5152 className={props.className}
5153 muted={props.muted}
5154 />
5155 ) : (
5156 <WorkEntryIcon name={props.fallbackName} className={fallbackClassName} />
5157 );
5158 }
5159 if (props.icon._tag === "themed-logo") {
5160 const src =
5161 resolvedTheme === "dark"
5162 ? (props.icon.logoUrlDark ?? props.icon.logoUrl)
5163 : props.icon.logoUrl;
5164 return (
5165 <ToolActivityImageIcon
5166 key={src}
5167 cacheKey={src}
5168 src={src}
5169 fallbackName={props.fallbackName}
5170 className={props.className}
5171 muted={props.muted}
5172 />
5173 );
5174 }
5175 return (
5176 <NativeAppToolActivityIcon
5177 app={props.icon.app}
5178 fallbackName={props.fallbackName}
5179 className={cn(props.className, "size-5")}
5180 muted={props.muted}
5181 />
5182 );
5183}
5184
5185function NativeAppToolActivityIcon(props: {
5186 app: Extract<ToolActivityIcon, { readonly _tag: "native-app" }>["app"];
5187 fallbackName: WorkEntryIconName;
5188 className: string;
5189 muted: boolean;
5190}) {
5191 const { activeThreadEnvironmentId } = use(TimelineRowCtx);
5192 const asset = useAssetUrlState(activeThreadEnvironmentId, {
5193 _tag: "native-app-icon",
5194 app: props.app,
5195 });
5196 if (asset._tag !== "Success") {
5197 return (
5198 <WorkEntryIcon
5199 name={props.fallbackName}
5200 className={cn(props.className, props.muted && "opacity-70 light:brightness-60")}
5201 />
5202 );
5203 }
5204 const cacheKey = getProjectFaviconCacheKey(
5205 activeThreadEnvironmentId,
5206 JSON.stringify(props.app),
5207 asset.url,
5208 );
5209 return (
5210 <ToolActivityImageIcon
5211 key={cacheKey}
5212 cacheKey={cacheKey}
5213 src={asset.url}
5214 fallbackName={props.fallbackName}
5215 className={props.className}
5216 muted={props.muted}
5217 />
5218 );
5219}
5220
5221const loadedToolActivityIconSrcs = new Map<string, string>();
5222
5223function ToolActivityImageIcon(props: {
5224 cacheKey: string;
5225 src: string;
5226 fallbackName: WorkEntryIconName;
5227 className: string;
5228 muted: boolean;
5229}) {
5230 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
5231 () => loadedToolActivityIconSrcs.get(props.cacheKey) ?? null,
5232 );
5233 const isLoading = displayedSrc !== props.src;
5234 const handleLoadError = (failedSrc: string) => {
5235 if (loadedToolActivityIconSrcs.get(props.cacheKey) === failedSrc) {
5236 loadedToolActivityIconSrcs.delete(props.cacheKey);
5237 }
5238 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
5239 };
5240 return (
5241 <>
5242 {displayedSrc === null ? (
5243 <WorkEntryIcon
5244 name={props.fallbackName}
5245 className={cn(props.className, props.muted && "opacity-70 light:brightness-60")}
5246 />
5247 ) : null}
5248 {displayedSrc ? (
5249 <span
5250 className={cn(
5251 props.className,
5252 "inline-block overflow-hidden rounded-xs bg-background",
5253 props.muted && "opacity-70",
5254 )}
5255 >
5256 <img
5257 src={displayedSrc}
5258 alt=""
5259 aria-hidden
5260 decoding="async"
5261 referrerPolicy="no-referrer"
5262 className={cn("block size-full object-contain", props.muted && "light:brightness-60")}
5263 onError={() => handleLoadError(displayedSrc)}
5264 />
5265 </span>
5266 ) : null}
5267 {isLoading ? (
5268 <img
5269 src={props.src}
5270 alt=""
5271 aria-hidden
5272 decoding="async"
5273 referrerPolicy="no-referrer"
5274 className="hidden"
5275 onLoad={() => {
5276 loadedToolActivityIconSrcs.set(props.cacheKey, props.src);
5277 setDisplayedSrc(props.src);
5278 }}
5279 onError={() => handleLoadError(props.src)}
5280 />
5281 ) : null}
5282 </>
5283 );
5284}
5285
5286function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
5287 switch (name) {
5288 case "pull-request":
5289 return <PullRequestGlyph.pullRequest className={className} aria-hidden />;
5290 case "bot":
5291 return <BotIcon className={className} aria-hidden />;
5292 case "brain":
5293 return <BrainIcon className={className} aria-hidden />;
5294 case "browser":
5295 case "computer":
5296 return <BrowserAppIcon className={className} />;
5297 case "code":
5298 return <Code2Icon className={className} aria-hidden />;
5299 case "device":
5300 return <SmartphoneIcon className={className} aria-hidden />;
5301 case "t3-code":
5302 return <KnifeMark className={className} aria-hidden />;
5303 case "check":
5304 return <CheckIcon className={className} aria-hidden />;
5305 case "circle-alert":
5306 return <CircleAlertIcon className={className} aria-hidden />;
5307 case "eye":
5308 return <EyeIcon className={className} aria-hidden />;
5309 case "globe":
5310 return <GlobeIcon className={className} aria-hidden />;
5311 case "hammer":
5312 return <HammerIcon className={className} aria-hidden />;
5313 case "search":
5314 return <SearchIcon className={className} aria-hidden />;
5315 case "message-circle":
5316 return <MessageCircleIcon className={className} aria-hidden />;
5317 case "square-pen":
5318 return <SquarePenIcon className={className} aria-hidden />;
5319 case "terminal":
5320 return <TerminalIcon className={className} aria-hidden />;
5321 case "wrench":
5322 return <WrenchIcon className={className} aria-hidden />;
5323 case "x":
5324 return <XIcon className={className} aria-hidden />;
5325 case "zap":
5326 return <ZapIcon className={className} aria-hidden />;
5327 }
5328}
5329
5330function workToneIcon(tone: TimelineWorkEntry["tone"]): {
5331 iconName: WorkEntryIconName;
5332 className: string;
5333} {
5334 if (tone === "error") {
5335 return {
5336 iconName: "circle-alert",
5337 className: "text-foreground/92",
5338 };
5339 }
5340 if (tone === "thinking") {
5341 return {
5342 iconName: "brain",
5343 className: "text-icon-muted",
5344 };
5345 }
5346 if (tone === "info") {
5347 return {
5348 iconName: "check",
5349 className: "text-muted-foreground",
5350 };
5351 }
5352 return {
5353 iconName: "zap",
5354 className: "text-foreground/92",
5355 };
5356}
5357
5358function workEntryRawCommand(
5359 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
5360): string | null {
5361 const rawCommand = workEntry.rawCommand?.trim();
5362 if (!rawCommand || !workEntry.command) {
5363 return null;
5364 }
5365 return rawCommand === workEntry.command.trim() ? null : rawCommand;
5366}
5367
5368function buildToolCallExpandedBody(
5369 workEntry: TimelineWorkEntry,
5370 workspaceRoot: string | undefined,
5371 visibleLabel: string,
5372 viewedImagePath: string | null,
5373): string | null {
5374 const blocks: string[] = [];
5375 const seen = new Set<string>([visibleLabel.trim()]);
5376 const addBlock = (value: string | null | undefined) => {
5377 const text = value?.trim();
5378 if (!text || seen.has(text)) return;
5379 seen.add(text);
5380 blocks.push(text);
5381 };
5382 if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) {
5383 const input =
5384 workEntry.structuredPayload?.type === "dynamic_tool"
5385 ? workEntry.structuredPayload.input
5386 : workEntry.toolData !== null &&
5387 typeof workEntry.toolData === "object" &&
5388 "input" in workEntry.toolData
5389 ? workEntry.toolData.input
5390 : undefined;
5391 if (input !== undefined) addBlock(`Tool input\n${JSON.stringify(input, null, 2)}`);
5392 }
5393 const command = workEntry.command?.trim();
5394 const raw = workEntryRawCommand(workEntry);
5395 if (command === visibleLabel.trim()) {
5396 seen.add(command);
5397 } else {
5398 addBlock(raw ?? command);
5399 }
5400 const detail = workEntry.detail?.trim();
5401 if (detail !== viewedImagePath?.trim()) {
5402 addBlock(detail);
5403 }
5404 const viewedImagePaths = new Set(
5405 viewedImagePath
5406 ? [viewedImagePath.trim(), formatWorkspaceRelativePath(viewedImagePath, workspaceRoot)]
5407 : [],
5408 );
5409 const changedFiles = (workEntry.changedFiles ?? []).flatMap((filePath) => {
5410 const formattedPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
5411 return viewedImagePaths.has(filePath) ||
5412 viewedImagePaths.has(formattedPath) ||
5413 filePath.trim() === detail ||
5414 formattedPath === detail
5415 ? []
5416 : [formattedPath];
5417 });
5418 if (changedFiles.length > 0) {
5419 addBlock([...new Set(changedFiles)].join("\n"));
5420 }
5421 return blocks.length > 0 ? blocks.join("\n\n") : null;
5422}
5423
5424const toolCallExpandedBodyClassName =
5425 "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-(length:--font-size-code,var(--text-2xs)) leading-relaxed select-text";
5426
5427function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
5428 if (workEntry.structuredPayload?.type === "notification") {
5429 if (workEntry.structuredPayload.outcome === "failed") return "circle-alert";
5430 const source = workEntry.structuredPayload.source;
5431 switch (source.kind) {
5432 case "subagent":
5433 case "delegated_task":
5434 return "bot";
5435 case "command":
5436 return "terminal";
5437 case "monitor":
5438 return "eye";
5439 case "background_task":
5440 return "zap";
5441 case "system":
5442 return "t3-code";
5443 default:
5444 source satisfies never;
5445 return "zap";
5446 }
5447 }
5448 if (workEntry.itemType === "user_input_request" || workEntry.itemType === "approval_request") {
5449 return "message-circle";
5450 }
5451 if (workEntry.toolSurface) return workEntry.toolSurface;
5452 if (
5453 workEntry.structuredPayload?.type === "dynamic_tool" &&
5454 isJavaScriptReplTool(workEntry.structuredPayload.toolName)
5455 )
5456 return "code";
5457 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
5458 if (toolPresentation) return toolPresentation.icon;
5459 const action = toolGroupAction(workEntry);
5460 if (action !== "other") return toolGroupSummaryIconName(action);
5461
5462 switch (workEntry.itemType) {
5463 case "dynamic_tool":
5464 return "wrench";
5465 case "subagent":
5466 return "bot";
5467 }
5468
5469 return workToneIcon(workEntry.tone).iconName;
5470}
5471
5472const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
5473
5474function remarkThoughtPreview(fallback: string) {
5475 return (tree: Root) => {
5476 const plainText = (node: Root | RootContent): string => {
5477 if (node.type === "html" || node.type === "definition") return "";
5478 if ("alt" in node) return node.alt ?? "";
5479 if ("value" in node) return node.value;
5480 if ("children" in node) {
5481 const separator = ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes(
5482 node.type,
5483 )
5484 ? " "
5485 : "";
5486 return node.children.map(plainText).join(separator);
5487 }
5488 return node.type === "break" ? " " : "";
5489 };
5490 tree.children = [
5491 { type: "text", value: plainText(tree).replace(/\s+/g, " ").trim() || fallback },
5492 ];
5493 };
5494}
5495
5496function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWorkEntry> }) {
5497 const ctx = use(TimelineRowCtx);
5498 const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
5499 const isStreaming = (entry: TimelineWorkEntry) =>
5500 isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress";
5501 return (
5502 <WorkLogDetails followLive={entries.some(isStreaming)}>
5503 {entries.map((entry) => (
5504 <ChatMarkdown
5505 key={entry.id}
5506 className="text-foreground"
5507 text={entry.detail ?? ""}
5508 cwd={ctx.markdownCwd}
5509 threadRef={ctx.threadRef ?? undefined}
5510 skills={ctx.skills}
5511 isStreaming={isStreaming(entry)}
5512 headingLevelOffset={MESSAGE_HEADING_LEVEL}
5513 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
5514 onImageExpand={ctx.onImageExpand}
5515 lineBreaks
5516 />
5517 ))}
5518 </WorkLogDetails>
5519 );
5520}
5521
5522type WorkEntryRowProps = {
5523 workEntry: TimelineWorkEntry;
5524 workspaceRoot: string | undefined;
5525 displayLabel?: string | undefined;
5526 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
5527};
5528
5529const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowProps) {
5530 const ctx = use(TimelineRowCtx);
5531 const item = props.workEntry.projectedItem?.item;
5532 if (item?.type === "notification" && item.source.kind === "system") {
5533 const failed = item.outcome === "failed";
5534 return (
5535 <WorkLogRow
5536 role="status"
5537 data-v2-item-type="notification"
5538 data-v2-item-visibility={props.workEntry.projectedItem?.visibility}
5539 wrapLabel
5540 icon={
5541 <WorkEntryIcon
5542 name={failed ? "circle-alert" : "t3-code"}
5543 className={cn("size-4", failed ? "text-destructive" : "text-secondary-label")}
5544 />
5545 }
5546 label={
5547 <span className={cn("text-sm font-medium", failed && "text-destructive")}>
5548 {item.summary}
5549 </span>
5550 }
5551 trailing={
5552 <TimelineRowTimestamp
5553 createdAt={props.workEntry.createdAt}
5554 timestampFormat={ctx.timestampFormat}
5555 alwaysVisible
5556 />
5557 }
5558 >
5559 {item.detail ? (
5560 <div className="ms-7 py-1 text-sm" data-thread-find-text="true">
5561 <ChatMarkdown
5562 text={item.detail}
5563 cwd={ctx.markdownCwd}
5564 threadRef={ctx.threadRef ?? undefined}
5565 headingLevelOffset={MESSAGE_HEADING_LEVEL}
5566 />
5567 </div>
5568 ) : null}
5569 </WorkLogRow>
5570 );
5571 }
5572 const childThreadId =
5573 item?.type === "notification" ? notificationChildThreadId(item.source) : undefined;
5574 if (item?.type !== "notification" || childThreadId === undefined) {
5575 return <WorkEntryLogRow {...props} />;
5576 }
5577 return (
5578 <SubagentNotificationLink
5579 parentRef={scopeThreadRef(ctx.activeThreadEnvironmentId, item.threadId)}
5580 childThreadId={childThreadId}
5581 outcome={item.outcome}
5582 createdAt={props.workEntry.createdAt}
5583 timestampFormat={ctx.timestampFormat}
5584 providerStatuses={ctx.providerStatuses}
5585 onOpenThread={ctx.onOpenThread}
5586 fallback={<WorkEntryLogRow {...props} />}
5587 />
5588 );
5589});
5590
5591function WorkEntryLogRow(props: WorkEntryRowProps) {
5592 const { workEntry, workspaceRoot, displayLabel } = props;
5593 const ctx = use(TimelineRowCtx);
5594 const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
5595 const { threadRef, onImageExpand, timestampFormat } = ctx;
5596 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
5597 const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);
5598 const createdThread =
5599 workEntry.projectedItem?.item.type === "thread_created"
5600 ? workEntry.projectedItem.item
5601 : undefined;
5602 const notifiedSubagentThreadId =
5603 workEntry.projectedItem?.item.type === "notification"
5604 ? notificationChildThreadId(workEntry.projectedItem.item.source)
5605 : undefined;
5606 const groupView = use(WorkGroupViewCtx);
5607 const [expanded, setExpanded] = useState(
5608 () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
5609 );
5610 const toggleExpanded = () => {
5611 const next = !expanded;
5612 if (groupView) {
5613 groupView.onToggleEntry(!next);
5614 if (next) groupView.state.expandedEntries.add(workEntry.id);
5615 else groupView.state.expandedEntries.delete(workEntry.id);
5616 } else {
5617 props.onToggleEntry?.(!next);
5618 }
5619 setExpanded(next);
5620 };
5621 const failureItem = workEntry.projectedItem?.item;
5622 if (failureItem?.type === "error" && failureItem.status === "failed") {
5623 const warning = failureItem.failure.class === "usage_limit";
5624 const resetAt = failureItem.failure.resetAt;
5625 const resetTime = resetAt ? formatUpcomingTimestamp(resetAt, timestampFormat) : null;
5626 const label = warning
5627 ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
5628 : workEntry.label;
5629 const retryRunId =
5630 failureItem.runId !== null &&
5631 retryableWorkspacePreparationRunIds.has(failureItem.runId) &&
5632 failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE
5633 ? failureItem.runId
5634 : null;
5635 return (
5636 <WorkLogRow
5637 data-v2-item-type="error"
5638 data-v2-item-visibility={workEntry.projectedItem?.visibility}
5639 wrapLabel
5640 icon={
5641 <CircleAlertIcon
5642 className={cn("size-4", warning ? "text-warning" : "text-destructive")}
5643 />
5644 }
5645 label={
5646 <span
5647 className={cn("text-sm font-medium", warning ? "text-warning" : "text-destructive")}
5648 >
5649 {label}
5650 </span>
5651 }
5652 trailing={
5653 <TimelineRowTimestamp
5654 createdAt={workEntry.createdAt}
5655 timestampFormat={timestampFormat}
5656 alwaysVisible
5657 />
5658 }
5659 >
5660 {!warning ? (
5661 <p className="ms-7 whitespace-pre-wrap break-words py-1 text-sm leading-relaxed text-foreground/80">
5662 {failureItem.failure.message}
5663 </p>
5664 ) : null}
5665 {retryRunId !== null && onRetryWorkspacePreparation ? (
5666 <div className="ms-7 pb-1">
5667 <Button
5668 type="button"
5669 size="xs"
5670 variant="outline"
5671 onClick={() => onRetryWorkspacePreparation(retryRunId)}
5672 >
5673 <RotateCcwIcon aria-hidden />
5674 Retry
5675 </Button>
5676 </div>
5677 ) : null}
5678 </WorkLogRow>
5679 );
5680 }
5681 const iconConfig = workToneIcon(workEntry.tone);
5682 const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning";
5683 const showFailedIndicator =
5684 !showWarningIndicator && workEntryDisplayIndicatesToolFailure(workEntry);
5685 const showDestructiveRowStyle =
5686 !showWarningIndicator &&
5687 showFailedIndicator &&
5688 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
5689 const active =
5690 isWorking &&
5691 workEntry.runId === latestRunId &&
5692 workEntry.toolLifecycleStatus === "inProgress" &&
5693 !showFailedIndicator;
5694 const entryIconName =
5695 showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry);
5696 const entryToolIcon =
5697 showWarningIndicator || showDestructiveRowStyle
5698 ? undefined
5699 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);
5700 const isReasoning = workEntry.itemType === "reasoning";
5701 // The question is the row's identity: a generic "User input submitted"
5702 // label buries what was asked, so lead with the question text (even over a
5703 // lone tool row's display label) and keep the answer as the trailing preview.
5704 const questionHeading = workEntry.questionAnswer
5705 ? getQuestionTextPreview(workEntry.questionAnswer)
5706 : "";
5707 const previewText =
5708 isReasoning && expanded
5709 ? workEntry.toolLifecycleStatus === "inProgress"
5710 ? "Thinking"
5711 : "Thought"
5712 : questionHeading || (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot));
5713 const answerPreview =
5714 workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer)
5715 ? getQuestionAnswerPreview(workEntry.questionAnswer)
5716 : null;
5717 const viewedImagePath = workEntryViewedImagePath(workEntry);
5718 const payload = workEntry.structuredPayload;
5719 const skill =
5720 payload?.type === "dynamic_tool"
5721 ? claudeSkillInvocation(payload.toolName, payload.input)
5722 : undefined;
5723 // Reads and skills expand to plain text instead of the item inspector. A
5724 // skill's heading already names it, so only its arguments are left to show.
5725 const plainOutput =
5726 toolGroupAction(workEntry) === "read"
5727 ? workEntryReadOutput(workEntry, workspaceRoot)
5728 : skill
5729 ? (skill.args ?? null)
5730 : undefined;
5731 const viewedImage =
5732 viewedImagePath && threadRef
5733 ? resolveViewedImageAsset(viewedImagePath, {
5734 threadId: threadRef.threadId,
5735 workspaceRoot,
5736 })
5737 : null;
5738 const canExpand =
5739 previewText.trim().length > 0 ||
5740 (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) ||
5741 Boolean(
5742 workEntryRawCommand(workEntry) ||
5743 workEntry.command?.trim() ||
5744 workEntry.detail?.trim() ||
5745 workEntry.changedFiles?.length ||
5746 viewedImage,
5747 );
5748 const expandedBody =
5749 expanded && !isReasoning
5750 ? plainOutput !== undefined
5751 ? plainOutput
5752 : buildToolCallExpandedBody(
5753 workEntry,
5754 workspaceRoot,
5755 previewText,
5756 viewedImage ? viewedImagePath : null,
5757 )
5758 : null;
5759 // Projected rows expand to the item inspector, so only offer a disclosure
5760 // when it has something to show, even if that output still has to load.
5761 // Reads and skills still fetch the output the timeline withheld.
5762 const plainOutputFetches =
5763 plainOutput !== undefined &&
5764 workEntry.projectedItem !== undefined &&
5765 turnItemNeedsDetailFetch(workEntry.projectedItem.item);
5766 const canExpandProjectedItem =
5767 plainOutput !== undefined
5768 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches)
5769 : workEntry.projectedItem === undefined
5770 ? canExpand
5771 : isReasoning
5772 ? Boolean(workEntry.detail?.trim())
5773 : Boolean(
5774 viewedImage ||
5775 workEntry.questionAnswer ||
5776 turnItemHasDetail(workEntry.projectedItem.item),
5777 );
5778 // Reserve destructive row styling for severe failures, not routine tool errors.
5779 const iconWrapperClass = cn(
5780 "flex size-4 items-center justify-center",
5781 showWarningIndicator
5782 ? "text-warning"
5783 : showDestructiveRowStyle
5784 ? "text-destructive"
5785 : showFailedIndicator
5786 ? failedToolIconClassName
5787 : active
5788 ? "text-(--activity-foreground)"
5789 : workEntry.tone === "tool"
5790 ? "text-icon-muted"
5791 : iconConfig.className,
5792 );
5793 const headingClass = showWarningIndicator
5794 ? "font-medium text-warning"
5795 : showDestructiveRowStyle
5796 ? "font-medium text-destructive"
5797 : active
5798 ? "text-(--activity-foreground)"
5799 : workLogEntryIsToolLike(workEntry)
5800 ? "text-secondary-label"
5801 : "text-foreground/80";
5802 const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget);
5803 const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview]
5804 .filter(Boolean)
5805 .join(": ");
5806 const accessibleDisplayText = showFailedIndicator
5807 ? `${accessiblePreview}, tool call failed`
5808 : accessiblePreview;
5809 const rowToggleProps = canExpandProjectedItem
5810 ? {
5811 role: "button" as const,
5812 tabIndex: 0 as const,
5813 "aria-label": accessibleDisplayText,
5814 "aria-expanded": expanded,
5815 onClick: toggleExpanded,
5816 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
5817 if (e.key === "Enter" || e.key === " ") {
5818 e.preventDefault();
5819 toggleExpanded();
5820 }
5821 },
5822 }
5823 : {};
5824
5825 return (
5826 <WorkLogRow
5827 data-v2-item-type={workEntry.projectedItem?.item.type}
5828 data-v2-item-visibility={workEntry.projectedItem?.visibility}
5829 {...rowToggleProps}
5830 icon={
5831 <span
5832 className={iconWrapperClass}
5833 role={showFailedIndicator ? "img" : undefined}
5834 aria-label={showFailedIndicator ? "Tool call failed" : undefined}
5835 >
5836 <ToolActivityIconView
5837 icon={entryToolIcon}
5838 fallbackName={entryIconName}
5839 className="block size-4 shrink-0 stroke-2"
5840 muted={!active}
5841 />
5842 </span>
5843 }
5844 label={
5845 <div className="min-w-0 flex-1 overflow-hidden">
5846 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
5847 <span
5848 className={cn(answerPreview ? "min-w-0" : "min-w-0 flex-1", "truncate", headingClass)}
5849 >
5850 {isReasoning && !expanded ? (
5851 <ReactMarkdown
5852 remarkPlugins={[
5853 remarkGfm,
5854 [
5855 remarkThoughtPreview,
5856 workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought",
5857 ],
5858 ]}
5859 >
5860 {workEntry.detail ?? previewText}
5861 </ReactMarkdown>
5862 ) : threadLabel ? (
5863 <ThreadReadLabel
5864 label={threadLabel}
5865 environmentId={ctx.activeThreadEnvironmentId}
5866 linked
5867 />
5868 ) : (
5869 previewText
5870 )}
5871 </span>
5872 {answerPreview ? (
5873 <span
5874 className={cn(
5875 "min-w-0 truncate",
5876 !expanded &&
5877 workEntry.questionAnswer &&
5878 hasQuestionAnswer(workEntry.questionAnswer)
5879 ? "text-foreground"
5880 : "text-muted-foreground",
5881 )}
5882 >
5883 {answerPreview}
5884 </span>
5885 ) : null}
5886 </p>
5887 </div>
5888 }
5889 trailing={
5890 <>
5891 {createdThread ? (
5892 <button
5893 type="button"
5894 className="shrink-0 rounded-sm text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
5895 aria-label={`Open ${createdThread.title ?? "created thread"}`}
5896 onClick={(event) => {
5897 event.stopPropagation();
5898 ctx.onOpenThread(createdThread.targetThreadId);
5899 }}
5900 onKeyDown={stopRowToggle}
5901 >
5902 Open chat
5903 </button>
5904 ) : null}
5905 {notifiedSubagentThreadId ? (
5906 <InlineButton
5907 aria-label="Open subagent thread"
5908 onClick={(event) => {
5909 event.stopPropagation();
5910 ctx.onOpenThread(notifiedSubagentThreadId);
5911 }}
5912 onKeyDown={stopRowToggle}
5913 >
5914 Open subagent
5915 </InlineButton>
5916 ) : null}
5917 {showFailedIndicator &&
5918 !showDestructiveRowStyle &&
5919 !toolIconAcceptsTint(entryToolIcon) ? (
5920 <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} />
5921 ) : null}
5922 <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} />
5923 <span
5924 className={cn(
5925 "flex size-4 shrink-0 items-center justify-center",
5926 !canExpandProjectedItem && "invisible",
5927 )}
5928 aria-hidden
5929 >
5930 <ChevronRightIcon
5931 className={cn(
5932 "size-3 shrink-0 text-icon-muted opacity-70 transition-transform duration-200",
5933 expanded && "rotate-90",
5934 )}
5935 />
5936 </span>
5937 </>
5938 }
5939 >
5940 {expanded && viewedImage && threadRef ? (
5941 <WorkLogDetails kind="media">
5942 <ChatMarkdownAssetImage
5943 environmentId={threadRef.environmentId}
5944 resource={viewedImage.resource}
5945 alt={viewedImage.alt}
5946 srcFragment={viewedImage.srcFragment}
5947 workspaceRoot={workspaceRoot}
5948 maxHeightRem={16}
5949 onImageExpand={onImageExpand}
5950 />
5951 </WorkLogDetails>
5952 ) : null}
5953 {expanded && workEntry.questionAnswer ? (
5954 <QuestionAnswerHistory answer={workEntry.questionAnswer} />
5955 ) : null}
5956 {expanded && isReasoning ? <ReasoningTraceContent entries={[workEntry]} /> : null}
5957 {expanded &&
5958 !isReasoning &&
5959 !workEntry.questionAnswer &&
5960 canExpandProjectedItem &&
5961 (expandedBody ||
5962 plainOutputFetches ||
5963 (workEntry.projectedItem && plainOutput === undefined)) ? (
5964 <WorkLogDetails kind="panel">
5965 {workEntry.projectedItem && plainOutput === undefined ? (
5966 <V2ItemInspector
5967 projectedItem={workEntry.projectedItem}
5968 environmentId={ctx.activeThreadEnvironmentId}
5969 cwd={ctx.markdownCwd}
5970 workspaceRoot={workspaceRoot}
5971 onOpenThread={ctx.onOpenThread}
5972 onOpenTurnDiff={ctx.onOpenTurnDiff}
5973 onRollbackCheckpoint={ctx.onRollbackCheckpoint}
5974 onImageExpand={ctx.onImageExpand}
5975 />
5976 ) : (
5977 <>
5978 {expandedBody ? (
5979 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
5980 ) : null}
5981 {plainOutputFetches && workEntry.projectedItem ? (
5982 <FetchedToolOutput
5983 projectedItem={workEntry.projectedItem}
5984 environmentId={ctx.activeThreadEnvironmentId}
5985 onImageExpand={onImageExpand}
5986 />
5987 ) : null}
5988 </>
5989 )}
5990 </WorkLogDetails>
5991 ) : null}
5992 </WorkLogRow>
5993 );
5994}
5995
5996function QuestionAnswerHistory({
5997 answer,
5998}: {
5999 answer: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
6000}) {
6001 const { activeThreadEnvironmentId } = use(TimelineRowCtx);
6002 const attachments = useMemo(() => Object.values(answer.attachmentsByQuestionId).flat(), [answer]);
6003 const resources = useMemo(
6004 () =>
6005 attachments.map((attachment) => ({
6006 _tag: "attachment" as const,
6007 attachmentId: attachment.id,
6008 })),
6009 [attachments],
6010 );
6011 const urls = useAssetUrls(activeThreadEnvironmentId, resources);
6012 return (
6013 <div className="ms-7 mt-2 space-y-2" onClick={stopRowToggle}>
6014 {[
6015 ...new Set([
6016 ...Object.keys(answer.questionTextById ?? {}),
6017 ...Object.keys(answer.answers),
6018 ...Object.keys(answer.attachmentsByQuestionId),
6019 ]),
6020 ].map((questionId) => (
6021 <div key={questionId} className="space-y-1">
6022 {answer.questionTextById?.[questionId] ? (
6023 <p className="whitespace-pre-wrap text-sm text-muted-foreground">
6024 {answer.questionTextById[questionId]}
6025 </p>
6026 ) : null}
6027 {getQuestionAnswerText(answer.answers[questionId]) ? (
6028 <p className="ms-3 whitespace-pre-wrap text-sm text-muted-foreground">
6029 {getQuestionAnswerText(answer.answers[questionId])}
6030 </p>
6031 ) : null}
6032 <div className="flex flex-wrap gap-2">
6033 {(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => {
6034 const url = urls[attachments.indexOf(attachment)];
6035 return (
6036 <a
6037 key={attachment.id}
6038 href={url ?? undefined}
6039 target="_blank"
6040 rel="noreferrer"
6041 className="text-sm underline"
6042 >
6043 {attachment.type === "image" && url ? (
6044 <img
6045 src={url}
6046 alt={attachment.name}
6047 className="h-20 max-w-32 rounded object-contain"
6048 />
6049 ) : (
6050 attachment.name
6051 )}
6052 </a>
6053 );
6054 })}
6055 </div>
6056 </div>
6057 ))}
6058 </div>
6059 );
6060}