| 1 | import { ThreadFindTimelineContext } from "./ThreadFindProvider"; |
| 2 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; |
| 3 | import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; |
| 4 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 5 | import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; |
| 6 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 7 | import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; |
| 8 | import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; |
| 9 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 10 | import { |
| 11 | getQuestionAnswerPreview, |
| 12 | getQuestionAnswerText, |
| 13 | getQuestionTextPreview, |
| 14 | hasQuestionAnswer, |
| 15 | } from "@t3tools/client-runtime/work-log/user-input"; |
| 16 | import { |
| 17 | deriveTimelineMinimapItems, |
| 18 | resolveTimelineMinimapPreview, |
| 19 | type TimelineMinimapItem, |
| 20 | } from "./timelineMinimapItems"; |
| 21 | import { |
| 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"; |
| 36 | import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 37 | import { useAtomValue } from "@effect/atom-react"; |
| 38 | import { environmentThreadDetails } from "../../state/threads"; |
| 39 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 40 | import { Link } from "@tanstack/react-router"; |
| 41 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 42 | import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; |
| 43 | import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; |
| 44 | import { |
| 45 | liveThoughtLine, |
| 46 | resolveWorkEntryToolPresentation, |
| 47 | resolveViewedImageAsset, |
| 48 | workEntryViewedImagePath, |
| 49 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 50 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; |
| 51 | import { |
| 52 | turnItemHasDetail, |
| 53 | turnItemNeedsDetailFetch, |
| 54 | } from "@t3tools/client-runtime/work-log/item-detail"; |
| 55 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 56 | import { |
| 57 | subagentGroupSummary, |
| 58 | summarizeSubagentStatuses, |
| 59 | } from "@t3tools/client-runtime/state/subagent-display"; |
| 60 | import { observeResize } from "~/lib/observeResize"; |
| 61 | |
| 62 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 63 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 64 | |
| 65 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 66 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 67 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 68 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 69 | import { claudeSkillInvocation, isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; |
| 70 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 71 | import { |
| 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"; |
| 86 | import { |
| 87 | LegendList, |
| 88 | type LegendListRef, |
| 89 | type MaintainScrollAtEndOptions, |
| 90 | } from "@legendapp/list/react"; |
| 91 | import { FileDiff } from "@pierre/diffs/react"; |
| 92 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; |
| 93 | import { |
| 94 | type TimelineEntry, |
| 95 | providerErrorPresentation, |
| 96 | createMessageAttachmentPreviewProjector, |
| 97 | selectMessageImageResources, |
| 98 | workEntryDisplayIndicatesToolFailure, |
| 99 | workEntrySignalsSevereFailure, |
| 100 | workLogEntryIsToolLike, |
| 101 | } from "../../session-logic"; |
| 102 | import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; |
| 103 | import { |
| 104 | type ChatMessage, |
| 105 | type ChatFileAttachment, |
| 106 | type ChatImageAttachment, |
| 107 | isFileAttachment, |
| 108 | isImageAttachment, |
| 109 | isVideoAttachment, |
| 110 | type TurnDiffSummary, |
| 111 | } from "../../types"; |
| 112 | import { |
| 113 | getRenderablePatch, |
| 114 | resolveDiffThemeName, |
| 115 | resolveFileDiffPath, |
| 116 | } from "../../lib/diffRendering"; |
| 117 | import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; |
| 118 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 119 | import ReactMarkdown from "react-markdown"; |
| 120 | import remarkGfm from "remark-gfm"; |
| 121 | import type { Root, RootContent } from "mdast"; |
| 122 | import { KnifeMark } from "../KnifeMark"; |
| 123 | import { ThreadContextChip } from "../ThreadContextChip"; |
| 124 | import { |
| 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"; |
| 155 | import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide"; |
| 156 | import type { |
| 157 | ComposerContextId, |
| 158 | ComposerContextRecord, |
| 159 | KnownComposerContextRecord, |
| 160 | } from "@t3tools/contracts"; |
| 161 | import { Button, InlineButton } from "../ui/button"; |
| 162 | import { MorphIcon } from "~/components/MorphIcon"; |
| 163 | import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; |
| 164 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 165 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 166 | import { |
| 167 | buildAttachmentVideoAsset, |
| 168 | buildAttachmentVideoPreview, |
| 169 | buildExpandedImagePreview, |
| 170 | ExpandedImagePreview, |
| 171 | } from "./ExpandedImagePreview"; |
| 172 | import { |
| 173 | SNAP_SHOT_ATTACHMENT_FRAME_CLASS, |
| 174 | SnapShotAttachmentDetails, |
| 175 | } from "./SnapShotAttachmentDetails"; |
| 176 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 177 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; |
| 178 | import { McpAppFrame } from "./McpAppFrame"; |
| 179 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 180 | import { useFileContextMenuHandler } from "../../fileContextMenu"; |
| 181 | import { useProject, useThreadShell } from "../../state/entities"; |
| 182 | import { |
| 183 | CHAT_TIMELINE_ANCHOR_OFFSET, |
| 184 | readTimelinePosition, |
| 185 | rememberTimelinePosition, |
| 186 | timelineContentOverflowsViewport, |
| 187 | } from "./timelineScrollAnchoring"; |
| 188 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 189 | import { promptLinkFor } from "../promptLinks/copyPromptLink"; |
| 190 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 191 | import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; |
| 192 | import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; |
| 193 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 194 | import { |
| 195 | AssistantCitationSource, |
| 196 | type AssistantCitationRequest, |
| 197 | type AssistantCitationTarget, |
| 198 | } from "./AssistantCitationSource"; |
| 199 | import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; |
| 200 | import { |
| 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"; |
| 233 | import { type ThreadFindMatch } from "./threadFind"; |
| 234 | import { useThreadFindNavigation } from "./useThreadFindNavigation"; |
| 235 | import { readThreadFindPosition } from "./threadFindPosition"; |
| 236 | import type { ThreadFindPositionReader } from "./threadFind"; |
| 237 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 238 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 239 | import { Spinner } from "../ui/spinner"; |
| 240 | import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip"; |
| 241 | import { WorktreeSetupCard } from "./WorktreeSetupCard"; |
| 242 | import { |
| 243 | ContextChipPopover as UserMessageContextPopover, |
| 244 | ContextChipShell, |
| 245 | FileChip, |
| 246 | ImageChipButton, |
| 247 | PULL_REQUEST_CHIP_KINDS, |
| 248 | PullRequestChip, |
| 249 | UnresolvedChip, |
| 250 | } from "../contextChipParts"; |
| 251 | import { |
| 252 | asKnownContextRecord, |
| 253 | isPullRequestSummaryContext, |
| 254 | pullRequestContextDisplayState, |
| 255 | pullRequestContextKindLabel, |
| 256 | resolveUserMessageContext, |
| 257 | reviewCommentContextLabel, |
| 258 | selectedMessageContextFragment, |
| 259 | } from "~/lib/composerContextRecords"; |
| 260 | import { |
| 261 | collectComposerContextReferences, |
| 262 | formatComposerContextReference, |
| 263 | } from "@t3tools/shared/composerContextReferences"; |
| 264 | import { |
| 265 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| 266 | encodeComposerContextClipboardHtml, |
| 267 | encodeComposerContextFragment, |
| 268 | } from "@t3tools/shared/composerContextClipboard"; |
| 269 | import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; |
| 270 | import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip"; |
| 271 | import { createContextPresentationRegistry } from "../contextPresentationRegistry"; |
| 272 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; |
| 273 | import { useClientSettings } from "~/hooks/useSettings"; |
| 274 | import type { ChatMarkdownContextReference } from "../ChatMarkdown"; |
| 275 | import { useMediaQuery } from "~/hooks/useMediaQuery"; |
| 276 | import { cn } from "~/lib/utils"; |
| 277 | import { useUiStateStore } from "~/uiStateStore"; |
| 278 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 279 | import { |
| 280 | formatChatTimestampTooltip, |
| 281 | formatDayAwareTimestamp, |
| 282 | formatUpcomingTimestamp, |
| 283 | } from "../../timestampFormat"; |
| 284 | import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; |
| 285 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; |
| 286 | import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; |
| 287 | import { |
| 288 | isV2LifecycleItem, |
| 289 | SubagentAvatar, |
| 290 | SubagentElapsed, |
| 291 | SubagentNotificationLink, |
| 292 | V2LifecycleRow, |
| 293 | type HandoffTimelineRun, |
| 294 | } from "./V2LifecycleRow"; |
| 295 | import { SecretRequestCard } from "./SecretRequestCard"; |
| 296 | import { TimelineSystemDivider } from "./TimelineSystemDivider"; |
| 297 | |
| 298 | import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; |
| 299 | import * as DateTime from "effect/DateTime"; |
| 300 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 301 | import { forwardWheelToTimeline } from "./timelineMinimapWheel"; |
| 302 | import { |
| 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 | |
| 315 | interface 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 | |
| 369 | interface 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 | |
| 386 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 387 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 388 | |
| 389 | interface WorkGroupViewState { |
| 390 | scrollPositions: Map<string, WorkGroupScrollAnchor>; |
| 391 | expandedEntries: Set<string>; |
| 392 | } |
| 393 | |
| 394 | const WorkGroupViewCtx = createContext<{ |
| 395 | state: WorkGroupViewState; |
| 396 | onToggleEntry: (collapsed: boolean) => void; |
| 397 | } | null>(null); |
| 398 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 399 | const TIMELINE_LIST_FADE_HEADER = ( |
| 400 | <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" /> |
| 401 | ); |
| 402 | function 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 | |
| 418 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 419 | const 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; |
| 431 | const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = []; |
| 432 | const 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. |
| 436 | const 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 | |
| 445 | export interface MessagesTimelineHistoryControls { |
| 446 | readonly hasMoreHistory: boolean; |
| 447 | readonly loading: boolean; |
| 448 | readonly error: string | null; |
| 449 | readonly onLoadEarlier: (throughEntryId?: string) => void; |
| 450 | } |
| 451 | |
| 452 | interface 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 | |
| 555 | export 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 | |
| 571 | const 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 | |
| 1727 | function 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 | |
| 1755 | function keyExtractor(item: MessagesTimelineRow) { |
| 1756 | return item.id; |
| 1757 | } |
| 1758 | |
| 1759 | function getItemType(item: MessagesTimelineRow) { |
| 1760 | return item.kind === "message" ? `message:${item.message.role}` : item.kind; |
| 1761 | } |
| 1762 | |
| 1763 | interface 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 | |
| 1771 | function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { |
| 1772 | const top = state.positionAtIndex?.(rowIndex); |
| 1773 | return typeof top === "number" && Number.isFinite(top) ? top : null; |
| 1774 | } |
| 1775 | |
| 1776 | function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) { |
| 1777 | const height = state.sizeAtIndex?.(rowIndex); |
| 1778 | return typeof height === "number" && Number.isFinite(height) ? height : null; |
| 1779 | } |
| 1780 | |
| 1781 | function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { |
| 1782 | return target instanceof Element && target.closest("[data-minimap-preview]") !== null; |
| 1783 | } |
| 1784 | |
| 1785 | function 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 | |
| 2040 | function 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 | |
| 2088 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; |
| 2089 | type TimelineRow = MessagesTimelineRow; |
| 2090 | |
| 2091 | const 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 | |
| 2176 | function 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 | |
| 2201 | function 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 | |
| 2235 | function 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. |
| 2282 | const MESSAGE_HEADING_LEVEL = 3; |
| 2283 | |
| 2284 | function MessageAuthorHeading({ children }: { children: string }) { |
| 2285 | return <h3 className="sr-only select-none">{children}</h3>; |
| 2286 | } |
| 2287 | |
| 2288 | function 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 | |
| 2678 | function 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 | |
| 2723 | export 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 | |
| 2745 | function 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 | */ |
| 2785 | function 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 | |
| 2816 | function 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 | |
| 2846 | function 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 | |
| 2868 | function 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 | |
| 2918 | function 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 | |
| 2964 | function 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 | |
| 2983 | function 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 | |
| 3037 | function 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 | |
| 3059 | function 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 | |
| 3080 | function 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 | |
| 3102 | function 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 | |
| 3125 | type V2EventTone = "muted" | "warning" | "danger" | "success"; |
| 3126 | |
| 3127 | function 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 | |
| 3208 | function 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 | */ |
| 3401 | function 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 | |
| 3433 | const 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. */ |
| 3552 | const 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 | |
| 3606 | function 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 | |
| 3621 | function formatWorkingTimerNow(startIso: string): string { |
| 3622 | return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; |
| 3623 | } |
| 3624 | |
| 3625 | function 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. |
| 3648 | const compactWorkEntryHeight = 24; |
| 3649 | |
| 3650 | function 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 | |
| 3806 | const workEntryKey = (entry: TimelineWorkEntry) => entry.id; |
| 3807 | |
| 3808 | function 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 | |
| 3821 | const failedToolIconClassName = "text-tool-error-icon/40"; |
| 3822 | |
| 3823 | /** Image icons cannot take a currentColor tint, so failed rows get a trailing x. */ |
| 3824 | function toolIconAcceptsTint(toolIcon: ToolActivityIcon | undefined): boolean { |
| 3825 | return toolIcon === undefined; |
| 3826 | } |
| 3827 | |
| 3828 | function 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 | */ |
| 3887 | function 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 | |
| 3924 | function 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 | |
| 3939 | function 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 | |
| 3959 | function 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 | |
| 4001 | function 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. */ |
| 4067 | function 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 | |
| 4075 | function 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. */ |
| 4081 | function 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 | |
| 4111 | function 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 | |
| 4202 | function 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 | |
| 4245 | function 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 | |
| 4265 | function 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. */ |
| 4304 | const 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). */ |
| 4333 | function 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 | |
| 4394 | function 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 | |
| 4434 | function 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 | |
| 4454 | function 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 | |
| 4475 | function 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 | |
| 4566 | function 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 | |
| 4606 | interface 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 | |
| 4617 | function 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 | |
| 4627 | const 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. */ |
| 4842 | function 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 | |
| 4869 | const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; |
| 4870 | const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; |
| 4871 | |
| 4872 | const 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 | |
| 4947 | const 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 | |
| 4972 | function 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. */ |
| 5038 | function 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. */ |
| 5068 | function 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 | |
| 5091 | type 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 | |
| 5113 | function 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 | |
| 5133 | function 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 | |
| 5185 | function 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 | |
| 5221 | const loadedToolActivityIconSrcs = new Map<string, string>(); |
| 5222 | |
| 5223 | function 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 | |
| 5286 | function 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 | |
| 5330 | function 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 | |
| 5358 | function 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 | |
| 5368 | function 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 | |
| 5424 | const 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 | |
| 5427 | function 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 | |
| 5472 | const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); |
| 5473 | |
| 5474 | function 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 | |
| 5496 | function 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 | |
| 5522 | type WorkEntryRowProps = { |
| 5523 | workEntry: TimelineWorkEntry; |
| 5524 | workspaceRoot: string | undefined; |
| 5525 | displayLabel?: string | undefined; |
| 5526 | onToggleEntry?: ((collapsed: boolean) => void) | undefined; |
| 5527 | }; |
| 5528 | |
| 5529 | const 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 | |
| 5591 | function 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 | |
| 5996 | function 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 | } |