| 1 | import type { |
| 2 | ThreadPendingApproval, |
| 3 | ThreadPendingUserInput, |
| 4 | ThreadUserInputQuestion, |
| 5 | } from "@t3tools/client-runtime/state/thread-requests"; |
| 6 | import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; |
| 7 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 8 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 9 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 10 | import { |
| 11 | turnItemDetailRevision, |
| 12 | turnItemHasDetail, |
| 13 | turnItemNeedsDetailFetch, |
| 14 | } from "@t3tools/client-runtime/work-log/item-detail"; |
| 15 | import { |
| 16 | commandDisplayText, |
| 17 | commandProgramName, |
| 18 | } from "@t3tools/client-runtime/work-log/command-label"; |
| 19 | import { |
| 20 | contextCompactionLabel, |
| 21 | liveThoughtLine, |
| 22 | toolItemForDisplay, |
| 23 | workEntryDisplayIndicatesToolFailure, |
| 24 | liveActivityToolStatus, |
| 25 | toolGroupAction, |
| 26 | resolveWorkEntryToolPresentation, |
| 27 | summarizeToolGroup, |
| 28 | toolGroupSummaryKind, |
| 29 | type ToolGroupSummaryKind, |
| 30 | type WorkLogPresentationEntry, |
| 31 | type WorkLogToolLifecycleStatus, |
| 32 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 33 | import { |
| 34 | resolveT3McpToolDefinition, |
| 35 | resolveT3McpToolPresentation, |
| 36 | type T3McpToolLogo, |
| 37 | type T3McpToolPresentation, |
| 38 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 39 | import type { |
| 40 | ChatAttachment, |
| 41 | MessageId, |
| 42 | OrchestrationV2Actor, |
| 43 | OrchestrationV2CreationSource, |
| 44 | OrchestrationV2ExecutionNode, |
| 45 | OrchestrationMessageContext, |
| 46 | OrchestrationV2ProjectedTurnItem, |
| 47 | OrchestrationV2RunAttempt, |
| 48 | OrchestrationV2RunStatus, |
| 49 | OrchestrationV2TurnItem, |
| 50 | OrchestrationV2UserMessageInputIntent, |
| 51 | RunAttemptId, |
| 52 | ScheduledTaskId, |
| 53 | TurnItemId, |
| 54 | } from "@t3tools/contracts"; |
| 55 | import { RunId, ThreadId } from "@t3tools/contracts"; |
| 56 | import { |
| 57 | classifyToolActivity, |
| 58 | collectToolFilePaths, |
| 59 | dynamicToolTitle, |
| 60 | isJavaScriptReplTool, |
| 61 | formatReadToolLabel, |
| 62 | formatSearchToolLabel, |
| 63 | } from "@t3tools/shared/toolActivity"; |
| 64 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 65 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; |
| 66 | import type { McpAppReference } from "@t3tools/shared/mcpApp"; |
| 67 | import { |
| 68 | compactDynamicToolOutput, |
| 69 | htmlRenderFromToolItem, |
| 70 | mcpAppFromToolItem, |
| 71 | } from "@t3tools/shared/toolOutput"; |
| 72 | import * as DateTime from "effect/DateTime"; |
| 73 | |
| 74 | export type PendingApproval = ThreadPendingApproval; |
| 75 | export type PendingUserInput = ThreadPendingUserInput; |
| 76 | |
| 77 | export interface PendingUserInputDraftAnswer { |
| 78 | readonly selectedOptionValues?: ReadonlyArray<string>; |
| 79 | readonly customAnswer?: string; |
| 80 | readonly attachmentCount?: number; |
| 81 | readonly attachmentsBlocked?: boolean; |
| 82 | } |
| 83 | |
| 84 | export interface ThreadFeedActivity { |
| 85 | readonly id: string; |
| 86 | readonly createdAt: string; |
| 87 | readonly runId: RunId | null; |
| 88 | readonly attemptId: RunAttemptId | null; |
| 89 | readonly summary: string; |
| 90 | readonly detail: string | null; |
| 91 | readonly canExpand: boolean; |
| 92 | /** Expanding fetches the withheld input and output with getTurnItem. */ |
| 93 | readonly fetchesDetail: boolean; |
| 94 | readonly getFullDetail: () => string | null; |
| 95 | readonly getCopyText: () => string; |
| 96 | readonly icon: |
| 97 | | "agent" |
| 98 | | "alert" |
| 99 | | "browser" |
| 100 | | "computer" |
| 101 | | "code" |
| 102 | | "check" |
| 103 | | "command" |
| 104 | | "edit" |
| 105 | | "eye" |
| 106 | | "globe" |
| 107 | | "search" |
| 108 | | "hammer" |
| 109 | | "lock" |
| 110 | | "message" |
| 111 | | "warning" |
| 112 | | "wrench" |
| 113 | | "zap"; |
| 114 | readonly logo: T3McpToolLogo | null; |
| 115 | readonly toolLike: boolean; |
| 116 | readonly prominent: boolean; |
| 117 | readonly status: "success" | "failure" | "neutral" | null; |
| 118 | readonly lifecycleStatus: WorkLogToolLifecycleStatus; |
| 119 | readonly workEntry: WorkLogPresentationEntry; |
| 120 | readonly groupedToolDetail?: boolean; |
| 121 | readonly live?: boolean; |
| 122 | readonly projectedItem: OrchestrationV2ProjectedTurnItem; |
| 123 | } |
| 124 | |
| 125 | export interface ThreadFeedMessage { |
| 126 | readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; |
| 127 | readonly id: MessageId; |
| 128 | readonly role: "user" | "assistant"; |
| 129 | readonly text: string; |
| 130 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 131 | readonly runId: RunId | null; |
| 132 | readonly streaming: boolean; |
| 133 | readonly inputIntent?: OrchestrationV2UserMessageInputIntent; |
| 134 | readonly createdBy?: OrchestrationV2Actor; |
| 135 | readonly creationSource?: OrchestrationV2CreationSource; |
| 136 | readonly scheduledTaskId?: ScheduledTaskId; |
| 137 | readonly senderThreadId?: ThreadId; |
| 138 | readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"]; |
| 139 | readonly sourceThreadId: ThreadId; |
| 140 | readonly createdAt: string; |
| 141 | readonly updatedAt: string; |
| 142 | readonly projectedItem?: OrchestrationV2ProjectedTurnItem; |
| 143 | } |
| 144 | |
| 145 | /** A message shown before the server has it: a pending creation or local feedback. */ |
| 146 | export interface LocalThreadMessage { |
| 147 | readonly id: MessageId; |
| 148 | readonly role: string; |
| 149 | readonly text: string; |
| 150 | readonly context?: OrchestrationMessageContext | undefined; |
| 151 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; |
| 152 | readonly streaming: boolean; |
| 153 | readonly createdAt: string; |
| 154 | readonly updatedAt: string; |
| 155 | } |
| 156 | |
| 157 | type RawThreadFeedEntry = |
| 158 | | { |
| 159 | readonly type: "message"; |
| 160 | readonly id: string; |
| 161 | readonly createdAt: string; |
| 162 | readonly message: ThreadFeedMessage; |
| 163 | } |
| 164 | | { |
| 165 | readonly type: "activity"; |
| 166 | readonly id: string; |
| 167 | readonly createdAt: string; |
| 168 | readonly runId: RunId | null; |
| 169 | readonly activity: ThreadFeedActivity; |
| 170 | } |
| 171 | | { |
| 172 | /** A page a completed `html_render` call published, shown in place of its work row. */ |
| 173 | readonly type: "html-render"; |
| 174 | readonly id: string; |
| 175 | readonly createdAt: string; |
| 176 | readonly runId: RunId | null; |
| 177 | readonly render: HtmlRenderReference; |
| 178 | } |
| 179 | | { |
| 180 | /** An MCP App a completed tool call captured, hosted in place of its work row. */ |
| 181 | readonly type: "mcp-app"; |
| 182 | readonly id: string; |
| 183 | readonly createdAt: string; |
| 184 | readonly runId: RunId | null; |
| 185 | /** The thread and item that own the app; a fork's inherited app is its source's. */ |
| 186 | readonly sourceThreadId: ThreadId; |
| 187 | readonly itemId: TurnItemId; |
| 188 | readonly revision: string; |
| 189 | readonly app: McpAppReference; |
| 190 | }; |
| 191 | |
| 192 | export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; |
| 193 | |
| 194 | type ThreadFeedEntryContent = |
| 195 | | Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "mcp-app" }> |
| 196 | | { |
| 197 | readonly type: "activity-group"; |
| 198 | readonly id: string; |
| 199 | readonly createdAt: string; |
| 200 | readonly runId: RunId | null; |
| 201 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 202 | } |
| 203 | | { |
| 204 | readonly type: "work-toggle"; |
| 205 | readonly id: string; |
| 206 | readonly createdAt: string; |
| 207 | readonly runId: RunId | null; |
| 208 | readonly groupId: string; |
| 209 | readonly hiddenCount: number; |
| 210 | readonly expanded: boolean; |
| 211 | readonly summary: string; |
| 212 | readonly summaryKind: ToolGroupSummaryKind; |
| 213 | readonly toolSurface?: WorkLogPresentationEntry["toolSurface"]; |
| 214 | readonly toolIcon?: WorkLogPresentationEntry["toolIcon"]; |
| 215 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; |
| 216 | readonly hasFailure: boolean; |
| 217 | readonly live: boolean; |
| 218 | readonly shimmer: boolean; |
| 219 | /** First sentence of the latest thought, shown above the live status line. */ |
| 220 | readonly thought?: string; |
| 221 | } |
| 222 | | { |
| 223 | readonly type: "run-fold"; |
| 224 | readonly id: string; |
| 225 | readonly createdAt: string; |
| 226 | readonly runId: RunId; |
| 227 | readonly label: string; |
| 228 | readonly expanded: boolean; |
| 229 | } |
| 230 | | { |
| 231 | readonly type: "thinking"; |
| 232 | readonly id: string; |
| 233 | readonly createdAt: string; |
| 234 | readonly runId: RunId | null; |
| 235 | }; |
| 236 | |
| 237 | export interface ThreadFeedLatestRun { |
| 238 | readonly runId: RunId; |
| 239 | readonly status: OrchestrationV2RunStatus; |
| 240 | readonly startedAt: string | null; |
| 241 | readonly completedAt: string | null; |
| 242 | } |
| 243 | |
| 244 | export interface AgentSpawnSummary { |
| 245 | readonly title: string; |
| 246 | readonly status: string; |
| 247 | readonly tone: "working" | "completed" | "failed" | "stopped"; |
| 248 | readonly members: ReadonlyArray<{ |
| 249 | readonly title: string; |
| 250 | readonly status: string; |
| 251 | readonly tone: "working" | "completed" | "failed" | "stopped"; |
| 252 | readonly detail: string | undefined; |
| 253 | readonly updatedAt: string; |
| 254 | }>; |
| 255 | } |
| 256 | |
| 257 | function compactWorkEntryText(value: string): string { |
| 258 | return value.replace(/\s+/gu, " ").trim(); |
| 259 | } |
| 260 | |
| 261 | /** Expanded work rows keep their detail while compact rows show a stable one-line label. */ |
| 262 | export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string { |
| 263 | if (expanded && entry.itemType === "reasoning") |
| 264 | return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; |
| 265 | const presentation = resolveWorkEntryToolPresentation(entry); |
| 266 | if (presentation) return presentation.displayName; |
| 267 | if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command)); |
| 268 | const action = toolGroupAction(entry); |
| 269 | const isToolRead = action === "read" && entry.itemType === "dynamic_tool"; |
| 270 | if (action === "code-search" || action === "search") { |
| 271 | const toolData = |
| 272 | entry.toolData !== null && |
| 273 | typeof entry.toolData === "object" && |
| 274 | !Array.isArray(entry.toolData) |
| 275 | ? (entry.toolData as Record<string, unknown>) |
| 276 | : undefined; |
| 277 | // Adapters title file searches with their target; the item keeps only the pattern. |
| 278 | const searchLabel = |
| 279 | entry.itemType === "file_search" ? entry.label : formatSearchToolLabel(toolData); |
| 280 | if (searchLabel) return searchLabel; |
| 281 | } |
| 282 | if (isToolRead) { |
| 283 | const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData); |
| 284 | if (firstPath) { |
| 285 | return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1)); |
| 286 | } |
| 287 | if (!expanded) return "Read file"; |
| 288 | } |
| 289 | const preview = |
| 290 | entry.command ?? |
| 291 | (isToolRead || (!expanded && (action === "code-search" || action === "search")) |
| 292 | ? null |
| 293 | : entry.detail) ?? |
| 294 | (entry.changedFiles?.length |
| 295 | ? entry.changedFiles.length === 1 |
| 296 | ? entry.changedFiles[0]! |
| 297 | : `${entry.changedFiles[0]!} +${entry.changedFiles.length - 1} more` |
| 298 | : null); |
| 299 | if (expanded) return preview?.trim() || entry.label; |
| 300 | return preview ? compactWorkEntryText(preview) || entry.label : entry.label; |
| 301 | } |
| 302 | |
| 303 | type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>; |
| 304 | |
| 305 | // Immutable source rows let retained history keep its identities while the active item streams. |
| 306 | const projectedEntriesCache = new WeakMap< |
| 307 | OrchestrationV2ProjectedTurnItem, |
| 308 | { |
| 309 | readonly attemptId: RunAttemptId | null; |
| 310 | readonly entry: RawThreadFeedEntry; |
| 311 | } |
| 312 | >(); |
| 313 | const localMessageEntriesCache = new WeakMap< |
| 314 | LocalThreadMessage, |
| 315 | Extract<RawThreadFeedEntry, { readonly type: "message" }> |
| 316 | >(); |
| 317 | const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>(); |
| 318 | const failedActivityGroupsCache = new WeakMap< |
| 319 | ThreadFeedActivityGroup, |
| 320 | ReadonlyArray<ThreadFeedActivityGroup> |
| 321 | >(); |
| 322 | const presentedActivityGroupsCache = new WeakMap< |
| 323 | ThreadFeedActivityGroup, |
| 324 | { |
| 325 | readonly activeRunId: RunId | null; |
| 326 | readonly isWorking: boolean; |
| 327 | readonly activeTail: boolean; |
| 328 | readonly rows: ReadonlyArray<ThreadFeedEntry>; |
| 329 | } |
| 330 | >(); |
| 331 | const runFoldRowsCache = new WeakMap< |
| 332 | ThreadFeedEntry, |
| 333 | Extract<ThreadFeedEntry, { readonly type: "run-fold" }> |
| 334 | >(); |
| 335 | let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; |
| 336 | |
| 337 | export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean { |
| 338 | return ( |
| 339 | entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "compaction" |
| 340 | ); |
| 341 | } |
| 342 | |
| 343 | export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): boolean { |
| 344 | return ( |
| 345 | entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "handoff" |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | export function isSecretRequestActivityGroup(entry: ThreadFeedActivityGroup): boolean { |
| 350 | return ( |
| 351 | entry.activities.length === 1 && |
| 352 | entry.activities[0]?.projectedItem.item.type === "secret_request" |
| 353 | ); |
| 354 | } |
| 355 | |
| 356 | function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { |
| 357 | return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); |
| 358 | } |
| 359 | |
| 360 | function normalizeDraftAnswer(value: string | undefined): string | null { |
| 361 | if (typeof value !== "string") return null; |
| 362 | const trimmed = value.trim(); |
| 363 | return trimmed.length > 0 ? trimmed : null; |
| 364 | } |
| 365 | |
| 366 | function resolvePendingUserInputOptionValue( |
| 367 | question: ThreadUserInputQuestion, |
| 368 | value: string, |
| 369 | ): string | null { |
| 370 | if (question.options.some((option) => option.value === value)) { |
| 371 | return value; |
| 372 | } |
| 373 | |
| 374 | const label = value.trim(); |
| 375 | return label.length > 0 && |
| 376 | question.options.some((option) => option.value === undefined && option.label.trim() === label) |
| 377 | ? label |
| 378 | : null; |
| 379 | } |
| 380 | |
| 381 | function normalizeSelectedOptionValues( |
| 382 | question: ThreadUserInputQuestion, |
| 383 | value: ReadonlyArray<string> | undefined, |
| 384 | ): ReadonlyArray<string> { |
| 385 | if (!Array.isArray(value)) { |
| 386 | return []; |
| 387 | } |
| 388 | |
| 389 | return Array.from( |
| 390 | new Set( |
| 391 | value |
| 392 | .map((entry) => resolvePendingUserInputOptionValue(question, entry)) |
| 393 | .filter((entry): entry is string => entry !== null), |
| 394 | ), |
| 395 | ); |
| 396 | } |
| 397 | |
| 398 | function resolvePendingUserInputAnswer( |
| 399 | question: ThreadUserInputQuestion, |
| 400 | draft: PendingUserInputDraftAnswer | undefined, |
| 401 | ): string | ReadonlyArray<string> | null { |
| 402 | if (draft?.attachmentsBlocked) return null; |
| 403 | const customAnswer = |
| 404 | question.allowCustomAnswer === false |
| 405 | ? null |
| 406 | : question.initialAnswer !== undefined |
| 407 | ? (draft?.customAnswer ?? null) |
| 408 | : normalizeDraftAnswer(draft?.customAnswer); |
| 409 | if (customAnswer !== null && customAnswer.length > 0) { |
| 410 | return customAnswer; |
| 411 | } |
| 412 | |
| 413 | const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); |
| 414 | if (question.multiSelect) { |
| 415 | return selectedOptionValues.length > 0 |
| 416 | ? selectedOptionValues |
| 417 | : (customAnswer ?? |
| 418 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); |
| 419 | } |
| 420 | return ( |
| 421 | selectedOptionValues[0] ?? |
| 422 | customAnswer ?? |
| 423 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) |
| 424 | ); |
| 425 | } |
| 426 | |
| 427 | function capitalizePhrase(value: string): string { |
| 428 | const trimmed = value.trim(); |
| 429 | return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; |
| 430 | } |
| 431 | |
| 432 | function memoizeValue<T>(build: () => T): () => T { |
| 433 | let value: T; |
| 434 | let initialized = false; |
| 435 | return () => { |
| 436 | if (!initialized) { |
| 437 | value = build(); |
| 438 | initialized = true; |
| 439 | } |
| 440 | return value; |
| 441 | }; |
| 442 | } |
| 443 | |
| 444 | function itemIsToolLike(item: OrchestrationV2TurnItem): boolean { |
| 445 | return ( |
| 446 | item.type === "reasoning" || |
| 447 | item.type === "command_execution" || |
| 448 | item.type === "file_change" || |
| 449 | item.type === "file_search" || |
| 450 | item.type === "web_search" || |
| 451 | item.type === "approval_request" || |
| 452 | item.type === "user_input_request" || |
| 453 | item.type === "dynamic_tool" || |
| 454 | item.type === "subagent" |
| 455 | ); |
| 456 | } |
| 457 | |
| 458 | function itemIsProminent(item: OrchestrationV2TurnItem): boolean { |
| 459 | return ( |
| 460 | item.type === "fork" || |
| 461 | item.type === "thread_created" || |
| 462 | item.type === "system_notice" || |
| 463 | (item.type === "notification" && item.source.kind === "system") || |
| 464 | // An answerable card: it must stand alone and never fold away with the run. |
| 465 | item.type === "secret_request" |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] { |
| 470 | if (item.type === "notification") return item.outcome === "failed" ? "failure" : null; |
| 471 | if (item.type === "error") { |
| 472 | if (item.status === "failed") |
| 473 | return item.failure.class === "usage_limit" ? "neutral" : "failure"; |
| 474 | return item.status === "completed" ? "success" : "neutral"; |
| 475 | } |
| 476 | if (!itemIsToolLike(item)) return null; |
| 477 | if (item.status === "failed") return "failure"; |
| 478 | return item.status === "completed" ? "success" : "neutral"; |
| 479 | } |
| 480 | |
| 481 | function itemLifecycleStatus(item: OrchestrationV2TurnItem): WorkLogToolLifecycleStatus { |
| 482 | switch (item.status) { |
| 483 | case "pending": |
| 484 | case "running": |
| 485 | case "waiting": |
| 486 | return "inProgress"; |
| 487 | case "idle": |
| 488 | return "idle"; |
| 489 | case "completed": |
| 490 | return "completed"; |
| 491 | case "failed": |
| 492 | return "failed"; |
| 493 | case "cancelled": |
| 494 | case "interrupted": |
| 495 | return "stopped"; |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntry["tone"] { |
| 500 | if (item.type === "error") return "info"; |
| 501 | if (item.type === "reasoning") return "thinking"; |
| 502 | switch (item.type) { |
| 503 | case "command_execution": |
| 504 | case "file_change": |
| 505 | case "file_search": |
| 506 | case "web_search": |
| 507 | case "dynamic_tool": |
| 508 | case "subagent": |
| 509 | return "tool"; |
| 510 | default: |
| 511 | return "info"; |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { |
| 516 | if (item.type === "notification") { |
| 517 | const source = item.source; |
| 518 | switch (source.kind) { |
| 519 | case "subagent": |
| 520 | case "delegated_task": |
| 521 | return "hammer"; |
| 522 | case "command": |
| 523 | return "command"; |
| 524 | case "monitor": |
| 525 | return "eye"; |
| 526 | case "background_task": |
| 527 | case "system": |
| 528 | return "zap"; |
| 529 | default: |
| 530 | source satisfies never; |
| 531 | return "zap"; |
| 532 | } |
| 533 | } |
| 534 | if (item.type === "dynamic_tool") { |
| 535 | const classified = classifyToolActivity({ |
| 536 | itemType: "dynamic_tool_call", |
| 537 | data: { toolName: item.toolName ?? undefined, input: item.input }, |
| 538 | }); |
| 539 | if (classified === "read") { |
| 540 | return "eye"; |
| 541 | } |
| 542 | if (classified === "search") return "search"; |
| 543 | } |
| 544 | switch (item.type) { |
| 545 | case "reasoning": |
| 546 | return "agent"; |
| 547 | case "command_execution": |
| 548 | return "command"; |
| 549 | case "file_change": |
| 550 | return "edit"; |
| 551 | case "file_search": |
| 552 | return "search"; |
| 553 | case "web_search": |
| 554 | return "globe"; |
| 555 | case "approval_request": |
| 556 | return item.requestKind === "permission" ? "lock" : "message"; |
| 557 | case "user_input_request": |
| 558 | case "user_message": |
| 559 | case "assistant_message": |
| 560 | return "message"; |
| 561 | case "dynamic_tool": |
| 562 | return isJavaScriptReplTool(item.toolName) ? "code" : "wrench"; |
| 563 | case "subagent": |
| 564 | return "hammer"; |
| 565 | case "run_interrupt_request": |
| 566 | case "run_interrupt_result": |
| 567 | case "system_notice": |
| 568 | return "warning"; |
| 569 | case "error": |
| 570 | return item.failure.class === "usage_limit" |
| 571 | ? item.status === "completed" |
| 572 | ? "check" |
| 573 | : "warning" |
| 574 | : "alert"; |
| 575 | case "checkpoint": |
| 576 | case "proposed_plan": |
| 577 | case "todo_list": |
| 578 | return "check"; |
| 579 | case "compaction": |
| 580 | case "handoff": |
| 581 | case "fork": |
| 582 | case "thread_created": |
| 583 | return "zap"; |
| 584 | case "secret_request": |
| 585 | return "lock"; |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | function itemToolPresentation(item: OrchestrationV2TurnItem): T3McpToolPresentation | null { |
| 590 | if (item.type !== "dynamic_tool") { |
| 591 | return null; |
| 592 | } |
| 593 | return resolveT3McpToolPresentation(item.toolName) ?? resolveT3McpToolPresentation(item.title); |
| 594 | } |
| 595 | |
| 596 | function itemSummary( |
| 597 | item: OrchestrationV2TurnItem, |
| 598 | toolPresentation: T3McpToolPresentation | null = null, |
| 599 | ): string { |
| 600 | if (item.type === "notification") return item.summary; |
| 601 | if (item.type === "system_notice") return item.message; |
| 602 | if (item.type === "compaction") return contextCompactionLabel(item); |
| 603 | const title = |
| 604 | (item.type === "dynamic_tool" |
| 605 | ? dynamicToolTitle(item.toolName, item.input, item.title) |
| 606 | : undefined) ?? item.title?.trim(); |
| 607 | if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); |
| 608 | if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); |
| 609 | switch (item.type) { |
| 610 | case "reasoning": |
| 611 | return "Thinking"; |
| 612 | case "command_execution": |
| 613 | return "Command"; |
| 614 | case "file_change": |
| 615 | return item.changes !== undefined && item.changes.length > 1 |
| 616 | ? `Changed ${item.changes.length} files` |
| 617 | : `Changed ${item.fileName}`; |
| 618 | case "file_search": |
| 619 | return item.title?.trim() || formatSearchToolLabel(item) || "Searched files"; |
| 620 | case "web_search": |
| 621 | return "Searched the web"; |
| 622 | case "approval_request": |
| 623 | return "Approval requested"; |
| 624 | case "user_input_request": |
| 625 | return "Input requested"; |
| 626 | case "checkpoint": |
| 627 | return "Checkpoint captured"; |
| 628 | case "run_interrupt_request": |
| 629 | return "Interrupt requested"; |
| 630 | case "run_interrupt_result": |
| 631 | return "Run interrupted"; |
| 632 | case "error": |
| 633 | return item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"; |
| 634 | case "handoff": |
| 635 | return "Context handed off"; |
| 636 | case "fork": |
| 637 | return "Thread forked"; |
| 638 | case "thread_created": |
| 639 | return "Thread created"; |
| 640 | case "secret_request": |
| 641 | return item.label; |
| 642 | case "dynamic_tool": { |
| 643 | const classified = classifyToolActivity({ |
| 644 | itemType: "dynamic_tool_call", |
| 645 | data: { toolName: item.toolName ?? undefined, input: item.input }, |
| 646 | }); |
| 647 | if (classified === "read") { |
| 648 | const [path] = collectToolFilePaths({ input: item.input }); |
| 649 | return formatReadToolLabel(path ?? ""); |
| 650 | } |
| 651 | if (classified === "search") { |
| 652 | return formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call"; |
| 653 | } |
| 654 | return toolPresentation?.displayName ?? item.toolName ?? "Tool call"; |
| 655 | } |
| 656 | case "proposed_plan": |
| 657 | return "Proposed plan"; |
| 658 | case "todo_list": |
| 659 | return "Plan updated"; |
| 660 | case "user_message": |
| 661 | return "User message"; |
| 662 | case "assistant_message": |
| 663 | return "Assistant message"; |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | function itemPreview(item: OrchestrationV2TurnItem): string | null { |
| 668 | switch (item.type) { |
| 669 | case "reasoning": |
| 670 | return item.text || null; |
| 671 | case "command_execution": |
| 672 | return item.input || null; |
| 673 | case "file_change": |
| 674 | return item.fileName; |
| 675 | case "file_search": |
| 676 | return item.pattern ?? null; |
| 677 | case "web_search": |
| 678 | return item.patterns?.join(", ") ?? null; |
| 679 | case "approval_request": |
| 680 | return item.prompt ?? null; |
| 681 | case "user_input_request": |
| 682 | return item.questions.map((question) => question.question).join(" · ") || null; |
| 683 | case "checkpoint": |
| 684 | return item.files.length === 1 |
| 685 | ? (item.files[0]?.path ?? null) |
| 686 | : `${item.files.length} changed files`; |
| 687 | case "run_interrupt_request": |
| 688 | case "run_interrupt_result": |
| 689 | case "system_notice": |
| 690 | return item.message || null; |
| 691 | case "error": |
| 692 | return item.failure.message; |
| 693 | case "compaction": |
| 694 | case "handoff": |
| 695 | return item.summary ?? null; |
| 696 | case "fork": |
| 697 | case "thread_created": |
| 698 | return item.targetThreadId; |
| 699 | case "secret_request": |
| 700 | return item.reason || null; |
| 701 | case "subagent": |
| 702 | return item.result ?? item.progress ?? item.prompt; |
| 703 | case "dynamic_tool": |
| 704 | return null; |
| 705 | case "notification": |
| 706 | return item.detail ?? null; |
| 707 | case "proposed_plan": |
| 708 | return item.markdown || null; |
| 709 | case "todo_list": |
| 710 | return `${item.steps.filter((step) => step.status === "completed").length}/${item.steps.length} completed`; |
| 711 | case "user_message": |
| 712 | case "assistant_message": |
| 713 | return item.text || null; |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | function toWorkLogEntry( |
| 718 | item: OrchestrationV2TurnItem, |
| 719 | createdAt: string, |
| 720 | summary: string, |
| 721 | detail: string | null, |
| 722 | ): WorkLogPresentationEntry { |
| 723 | const title = item.title?.trim() || null; |
| 724 | const common = { |
| 725 | ...extractToolActivityPresentation(item), |
| 726 | id: item.id, |
| 727 | createdAt, |
| 728 | label: summary, |
| 729 | tone: itemWorkLogTone(item), |
| 730 | itemType: item.type, |
| 731 | toolLifecycleStatus: itemLifecycleStatus(item), |
| 732 | structuredPayload: item, |
| 733 | ...(item.type === "user_input_request" && item.questionAnswer |
| 734 | ? { questionAnswer: item.questionAnswer } |
| 735 | : {}), |
| 736 | } as const; |
| 737 | |
| 738 | switch (item.type) { |
| 739 | case "reasoning": |
| 740 | return { ...common, ...(item.text ? { detail: item.text } : {}) }; |
| 741 | case "command_execution": |
| 742 | return { |
| 743 | ...common, |
| 744 | command: item.input, |
| 745 | rawCommand: item.input, |
| 746 | toolTitle: title ?? "Command", |
| 747 | toolData: item, |
| 748 | }; |
| 749 | case "file_change": |
| 750 | return { |
| 751 | ...common, |
| 752 | changedFiles: [item.fileName], |
| 753 | toolTitle: title ?? "File change", |
| 754 | toolData: item, |
| 755 | }; |
| 756 | case "file_search": |
| 757 | return { |
| 758 | ...common, |
| 759 | ...(item.pattern ? { detail: item.pattern } : {}), |
| 760 | toolTitle: title ?? "File search", |
| 761 | toolData: item, |
| 762 | }; |
| 763 | case "web_search": |
| 764 | return { |
| 765 | ...common, |
| 766 | ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}), |
| 767 | toolTitle: title ?? "Web search", |
| 768 | toolData: item, |
| 769 | }; |
| 770 | case "checkpoint": |
| 771 | return { ...common, changedFiles: item.files.map((file) => file.path), toolData: item }; |
| 772 | case "approval_request": |
| 773 | return { |
| 774 | ...common, |
| 775 | ...(item.prompt ? { detail: item.prompt } : {}), |
| 776 | requestKind: item.requestKind, |
| 777 | toolData: item, |
| 778 | }; |
| 779 | case "dynamic_tool": |
| 780 | return { |
| 781 | ...common, |
| 782 | toolTitle: title ?? item.toolName ?? "Tool", |
| 783 | toolData: { input: item.input, output: item.output }, |
| 784 | }; |
| 785 | default: |
| 786 | return { ...common, ...(detail ? { detail } : {}), toolData: item }; |
| 787 | } |
| 788 | } |
| 789 | |
| 790 | /** Expanded detail for a row, from its wire item or the full item from getTurnItem. */ |
| 791 | export function formatItemFullDetail( |
| 792 | row: OrchestrationV2ProjectedTurnItem, |
| 793 | item: OrchestrationV2TurnItem, |
| 794 | ): string { |
| 795 | return JSON.stringify( |
| 796 | { |
| 797 | visibility: row.visibility, |
| 798 | sourceThreadId: row.sourceThreadId, |
| 799 | sourceItemId: row.sourceItemId, |
| 800 | item: toolItemForDisplay(item), |
| 801 | }, |
| 802 | null, |
| 803 | 2, |
| 804 | ); |
| 805 | } |
| 806 | |
| 807 | function toFeedActivity( |
| 808 | row: OrchestrationV2ProjectedTurnItem, |
| 809 | attemptId: RunAttemptId | null, |
| 810 | ): ThreadFeedActivity { |
| 811 | const item = row.item; |
| 812 | const toolPresentation = itemToolPresentation(item); |
| 813 | const summary = itemSummary(item, toolPresentation); |
| 814 | const detail = item.type === "notification" ? null : itemPreview(item); |
| 815 | const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); |
| 816 | const workEntry = toWorkLogEntry(item, createdAt, summary, detail); |
| 817 | const readPaths = |
| 818 | item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" |
| 819 | ? collectToolFilePaths(item) |
| 820 | : null; |
| 821 | const getFullDetail = memoizeValue(() => |
| 822 | readPaths |
| 823 | ? readPaths.join("\n") || null |
| 824 | : item.type === "notification" |
| 825 | ? item.detail?.trim() || null |
| 826 | : formatItemFullDetail(row, item), |
| 827 | ); |
| 828 | const getCopyText = memoizeValue(() => |
| 829 | [summary, detail, getFullDetail()] |
| 830 | .filter( |
| 831 | (value, index, values): value is string => |
| 832 | Boolean(value) && values.indexOf(value) === index, |
| 833 | ) |
| 834 | .join("\n"), |
| 835 | ); |
| 836 | return { |
| 837 | id: `${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, |
| 838 | createdAt, |
| 839 | runId: item.runId, |
| 840 | attemptId, |
| 841 | summary, |
| 842 | detail, |
| 843 | canExpand: |
| 844 | !(item.type === "error" && item.status === "failed") && |
| 845 | !(item.type === "notification" && item.source.kind === "system") && |
| 846 | (readPaths |
| 847 | ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) |
| 848 | : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), |
| 849 | // Read rows show their paths, then the fetched file contents. |
| 850 | fetchesDetail: turnItemNeedsDetailFetch(item), |
| 851 | getFullDetail, |
| 852 | getCopyText, |
| 853 | icon: workEntry.toolSurface ?? itemIcon(item), |
| 854 | logo: toolPresentation?.logo ?? null, |
| 855 | toolLike: itemIsToolLike(item), |
| 856 | prominent: itemIsProminent(item) || (item.type === "error" && item.status === "failed"), |
| 857 | status: |
| 858 | item.type === "error" && item.failure.class === "usage_limit" |
| 859 | ? itemStatus(item) |
| 860 | : workEntryDisplayIndicatesToolFailure(workEntry) |
| 861 | ? "failure" |
| 862 | : itemStatus(item), |
| 863 | lifecycleStatus: itemLifecycleStatus(item), |
| 864 | workEntry, |
| 865 | projectedItem: row, |
| 866 | }; |
| 867 | } |
| 868 | |
| 869 | function singleToolCallLabel(activity: ThreadFeedActivity, expanded: boolean): string { |
| 870 | if (activity.workEntry.itemType === "reasoning") |
| 871 | return expanded |
| 872 | ? "Thought" |
| 873 | : compactWorkEntryText(activity.workEntry.detail ?? "") || "Thought"; |
| 874 | const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed"); |
| 875 | if (presentation) return presentation.displayName; |
| 876 | const command = activity.workEntry.command?.trim(); |
| 877 | return command || activity.summary; |
| 878 | } |
| 879 | |
| 880 | function isEmptyMessage(entry: RawThreadFeedEntry): boolean { |
| 881 | return ( |
| 882 | entry.type === "message" && |
| 883 | entry.message.text.trim().length === 0 && |
| 884 | entry.message.attachments.length === 0 |
| 885 | ); |
| 886 | } |
| 887 | |
| 888 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { |
| 889 | const childrenByRun = new Map<RunId, Set<string>>(); |
| 890 | for (const entry of entries) { |
| 891 | if (entry.type !== "activity") continue; |
| 892 | const item = entry.activity.projectedItem.item; |
| 893 | if (item.type !== "subagent" || item.origin !== "app_owned" || item.runId === null) continue; |
| 894 | const children = childrenByRun.get(item.runId) ?? new Set<string>(); |
| 895 | children.add(item.subagentId); |
| 896 | childrenByRun.set(item.runId, children); |
| 897 | } |
| 898 | const grouped: ThreadFeedEntry[] = []; |
| 899 | let firstActivityEntry: Extract<RawThreadFeedEntry, { readonly type: "activity" }> | null = null; |
| 900 | let openGroupActivities: ThreadFeedActivity[] = []; |
| 901 | const flushGroup = () => { |
| 902 | if (firstActivityEntry === null) return; |
| 903 | const cached = activityGroupsCache.get(firstActivityEntry.activity); |
| 904 | if ( |
| 905 | cached && |
| 906 | cached.activities.length === openGroupActivities.length && |
| 907 | cached.activities.every((activity, index) => activity === openGroupActivities[index]) |
| 908 | ) { |
| 909 | grouped.push(cached); |
| 910 | } else { |
| 911 | const group: ThreadFeedActivityGroup = { |
| 912 | type: "activity-group", |
| 913 | id: firstActivityEntry.id, |
| 914 | createdAt: firstActivityEntry.createdAt, |
| 915 | runId: firstActivityEntry.runId, |
| 916 | activities: openGroupActivities, |
| 917 | }; |
| 918 | activityGroupsCache.set(firstActivityEntry.activity, group); |
| 919 | grouped.push(group); |
| 920 | } |
| 921 | firstActivityEntry = null; |
| 922 | openGroupActivities = []; |
| 923 | }; |
| 924 | |
| 925 | for (const entry of entries) { |
| 926 | // A successful delegation is already represented by its durable child card. |
| 927 | // Pending, failed and unmatched calls remain visible, even with identical prompts. |
| 928 | if (entry.type === "activity") { |
| 929 | const item = entry.activity.projectedItem.item; |
| 930 | if ( |
| 931 | item.type === "dynamic_tool" && |
| 932 | item.runId !== null && |
| 933 | (item.status === "running" || item.status === "completed") && |
| 934 | resolveT3McpToolDefinition(item.toolName)?.summaryAction === "delegate" && |
| 935 | !workEntryDisplayIndicatesToolFailure(entry.activity.workEntry) |
| 936 | ) { |
| 937 | const output = compactDynamicToolOutput(item.output); |
| 938 | if ( |
| 939 | !output?.isError && |
| 940 | output?.taskId !== undefined && |
| 941 | childrenByRun.get(item.runId)?.has(output.taskId) |
| 942 | ) |
| 943 | continue; |
| 944 | } |
| 945 | } |
| 946 | // Skip empty messages so they don't break activity grouping. |
| 947 | if (isEmptyMessage(entry)) { |
| 948 | continue; |
| 949 | } |
| 950 | |
| 951 | if (entry.type !== "activity") { |
| 952 | flushGroup(); |
| 953 | grouped.push(entry); |
| 954 | continue; |
| 955 | } |
| 956 | |
| 957 | const isStandaloneActivity = |
| 958 | entry.activity.projectedItem.item.type === "compaction" || |
| 959 | entry.activity.projectedItem.item.type === "handoff" || |
| 960 | entry.activity.projectedItem.item.type === "notification"; |
| 961 | if ( |
| 962 | isStandaloneActivity || |
| 963 | entry.activity.prominent || |
| 964 | (entry.activity.projectedItem.item.type === "subagent") !== |
| 965 | (firstActivityEntry?.activity.projectedItem.item.type === "subagent") || |
| 966 | firstActivityEntry?.runId !== entry.runId || |
| 967 | (entry.activity.projectedItem.item.type === "subagent" && |
| 968 | firstActivityEntry?.activity.projectedItem.item.providerTurnId !== |
| 969 | entry.activity.projectedItem.item.providerTurnId) || |
| 970 | (entry.activity.projectedItem.item.type !== "subagent" && |
| 971 | firstActivityEntry?.activity.attemptId !== entry.activity.attemptId) |
| 972 | ) { |
| 973 | flushGroup(); |
| 974 | } |
| 975 | firstActivityEntry ??= entry; |
| 976 | openGroupActivities.push(entry.activity); |
| 977 | if (isStandaloneActivity || entry.activity.prominent) { |
| 978 | flushGroup(); |
| 979 | } |
| 980 | } |
| 981 | flushGroup(); |
| 982 | return grouped; |
| 983 | } |
| 984 | |
| 985 | function computeElapsedMs(startIso: string, endIso: string): number | null { |
| 986 | const start = Date.parse(startIso); |
| 987 | const end = Date.parse(endIso); |
| 988 | return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null; |
| 989 | } |
| 990 | |
| 991 | function maxIsoTimestamp(left: string | null, right: string | null): string | null { |
| 992 | if (left === null) return right; |
| 993 | if (right === null) return left; |
| 994 | return Date.parse(right) > Date.parse(left) ? right : left; |
| 995 | } |
| 996 | |
| 997 | function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null { |
| 998 | return threadFeedRunIsUnsettled(latestRun) ? latestRun.runId : null; |
| 999 | } |
| 1000 | |
| 1001 | export function threadFeedRunIsUnsettled( |
| 1002 | run: ThreadFeedLatestRun | null, |
| 1003 | ): run is ThreadFeedLatestRun { |
| 1004 | if (run === null || run.status === "queued") return false; |
| 1005 | return ( |
| 1006 | run.completedAt === null || |
| 1007 | run.status === "preparing" || |
| 1008 | run.status === "starting" || |
| 1009 | run.status === "running" || |
| 1010 | run.status === "waiting" |
| 1011 | ); |
| 1012 | } |
| 1013 | |
| 1014 | export function threadFeedActivityIsVisible( |
| 1015 | activity: Pick<ThreadFeedActivity, "prominent" | "status" | "toolLike"> & |
| 1016 | Partial<Pick<ThreadFeedActivity, "lifecycleStatus">>, |
| 1017 | ): boolean { |
| 1018 | return ( |
| 1019 | activity.prominent || |
| 1020 | activity.lifecycleStatus === "stopped" || |
| 1021 | activity.lifecycleStatus === "declined" || |
| 1022 | activity.lifecycleStatus === "idle" || |
| 1023 | !(activity.toolLike && activity.status === "neutral") |
| 1024 | ); |
| 1025 | } |
| 1026 | |
| 1027 | interface ThreadFeedRunFold { |
| 1028 | readonly runId: RunId; |
| 1029 | readonly createdAt: string; |
| 1030 | readonly hiddenEntryIds: ReadonlySet<string>; |
| 1031 | readonly label: string; |
| 1032 | } |
| 1033 | |
| 1034 | export function failedFeedRunIds( |
| 1035 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1036 | latestRun: ThreadFeedLatestRun | null, |
| 1037 | ) { |
| 1038 | const failed = new Set<RunId>(); |
| 1039 | if (latestRun?.status === "failed") failed.add(latestRun.runId); |
| 1040 | for (const entry of feed) { |
| 1041 | if (entry.type !== "activity-group") continue; |
| 1042 | for (const activity of entry.activities) { |
| 1043 | const item = activity.projectedItem.item; |
| 1044 | if ( |
| 1045 | item.type === "error" && |
| 1046 | item.status === "failed" && |
| 1047 | item.parentItemId === null && |
| 1048 | item.runId !== null |
| 1049 | ) |
| 1050 | failed.add(item.runId); |
| 1051 | } |
| 1052 | } |
| 1053 | return failed; |
| 1054 | } |
| 1055 | |
| 1056 | /** |
| 1057 | * A prompt without a run (a provider-native subagent, or a turn imported from |
| 1058 | * V1) folds its response like a run. `runlessWorkActive` keeps the latest |
| 1059 | * runless response open; V2 work must not reopen imported turns. |
| 1060 | */ |
| 1061 | function deriveThreadFeedRunFolds( |
| 1062 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1063 | latestRun: ThreadFeedLatestRun | null, |
| 1064 | runlessWorkActive: boolean, |
| 1065 | feedFailedRunIds: ReadonlySet<RunId>, |
| 1066 | ): ReadonlyMap<string, ThreadFeedRunFold> { |
| 1067 | const firstAssistantMessageIdByRun = new Map<RunId, string>(); |
| 1068 | const terminalAssistantMessageIdByRun = new Map<RunId, string>(); |
| 1069 | const interruptedRunIds = new Set<RunId>(); |
| 1070 | const failedRunIds = new Set(feedFailedRunIds); |
| 1071 | const promptIndexByRunId = new Map<RunId, number>(); |
| 1072 | for (const [index, entry] of feed.entries()) { |
| 1073 | // A steer joins a run that already started, maybe from a promptless wake. |
| 1074 | if ( |
| 1075 | entry.type !== "message" || |
| 1076 | entry.message.role !== "user" || |
| 1077 | entry.message.inputIntent === "steer" || |
| 1078 | entry.message.inputIntent === "promoted_queued_to_steer" |
| 1079 | ) { |
| 1080 | continue; |
| 1081 | } |
| 1082 | const runId = entry.message.runId; |
| 1083 | if (runId != null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index); |
| 1084 | } |
| 1085 | const groupsByRunId = new Map< |
| 1086 | RunId, |
| 1087 | { entries: ThreadFeedEntry[]; startBoundary: string | null } |
| 1088 | >(); |
| 1089 | // Fold state is keyed by run, so each runless prompt lends its response a |
| 1090 | // stable key of its own. Decide per prompt, not per thread: a V1 thread's |
| 1091 | // first V2 run must not unfold every imported turn above it. |
| 1092 | let runlessKey: RunId | null = null; |
| 1093 | let responseKey: RunId | null = null; |
| 1094 | let pendingUserBoundary: string | null = null; |
| 1095 | for (const [index, entry] of feed.entries()) { |
| 1096 | if (entry.type === "message" && entry.message.role === "user") { |
| 1097 | pendingUserBoundary = entry.message.createdAt; |
| 1098 | runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; |
| 1099 | responseKey = entry.message.runId ?? runlessKey; |
| 1100 | continue; |
| 1101 | } |
| 1102 | const ownRunId = |
| 1103 | entry.type === "message" && entry.message.role === "assistant" |
| 1104 | ? (entry.message.runId ?? runlessKey) |
| 1105 | : entry.type === "activity-group" || |
| 1106 | entry.type === "html-render" || |
| 1107 | entry.type === "mcp-app" |
| 1108 | ? (entry.runId ?? runlessKey) |
| 1109 | : null; |
| 1110 | if (!ownRunId) continue; |
| 1111 | // Resuming a subagent moves its item to the resuming run, but the item |
| 1112 | // keeps its place in the turn that started it. It folds with that turn, |
| 1113 | // so the later run's fold still opens under its own prompt. |
| 1114 | const runId = |
| 1115 | responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index |
| 1116 | ? responseKey |
| 1117 | : ownRunId; |
| 1118 | let group = groupsByRunId.get(runId); |
| 1119 | if (!group) { |
| 1120 | group = { entries: [], startBoundary: pendingUserBoundary }; |
| 1121 | pendingUserBoundary = null; |
| 1122 | groupsByRunId.set(runId, group); |
| 1123 | } |
| 1124 | group.entries.push(entry); |
| 1125 | if (entry.type === "message") { |
| 1126 | if (!firstAssistantMessageIdByRun.has(runId)) { |
| 1127 | firstAssistantMessageIdByRun.set(runId, entry.id); |
| 1128 | } |
| 1129 | terminalAssistantMessageIdByRun.set(runId, entry.id); |
| 1130 | } |
| 1131 | if (entry.type !== "activity-group") continue; |
| 1132 | for (const activity of entry.activities) { |
| 1133 | const item = activity.projectedItem.item; |
| 1134 | if (item.type === "run_interrupt_result") interruptedRunIds.add(runId); |
| 1135 | if ( |
| 1136 | runId === runlessKey && |
| 1137 | item.type === "error" && |
| 1138 | item.status === "failed" && |
| 1139 | item.parentItemId === null |
| 1140 | ) { |
| 1141 | failedRunIds.add(runId); |
| 1142 | } |
| 1143 | } |
| 1144 | } |
| 1145 | |
| 1146 | const activeRunId = unsettledRunId(latestRun); |
| 1147 | const foldsByAnchorId = new Map<string, ThreadFeedRunFold>(); |
| 1148 | for (const [runId, group] of groupsByRunId) { |
| 1149 | if ( |
| 1150 | runId === activeRunId || |
| 1151 | (runlessWorkActive && runId === runlessKey) || |
| 1152 | interruptedRunIds.has(runId) || |
| 1153 | failedRunIds.has(runId) || |
| 1154 | group.entries.some((entry) => entry.type === "message" && entry.message.streaming) |
| 1155 | ) { |
| 1156 | continue; |
| 1157 | } |
| 1158 | const firstAssistantId = firstAssistantMessageIdByRun.get(runId); |
| 1159 | const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); |
| 1160 | const hiddenEntryIds = new Set( |
| 1161 | group.entries |
| 1162 | .filter( |
| 1163 | (entry) => |
| 1164 | entry.id !== firstAssistantId && |
| 1165 | entry.id !== terminalAssistantId && |
| 1166 | entry.type !== "html-render" && |
| 1167 | entry.type !== "mcp-app" && |
| 1168 | !( |
| 1169 | entry.type === "activity-group" && |
| 1170 | entry.activities.some( |
| 1171 | (activity) => |
| 1172 | activity.prominent || |
| 1173 | activity.projectedItem.item.type === "notification" || |
| 1174 | activity.projectedItem.item.type === "handoff" || |
| 1175 | // A child still working outlives its settled launching run; |
| 1176 | // its group stays visible while any member is live. |
| 1177 | isLiveSubagentTurnItem(activity.projectedItem.item), |
| 1178 | ) |
| 1179 | ), |
| 1180 | ) |
| 1181 | .map((entry) => entry.id), |
| 1182 | ); |
| 1183 | const firstEntry = group.entries[0]; |
| 1184 | const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); |
| 1185 | const lastEntry = group.entries.at(-1); |
| 1186 | if (!firstHiddenEntry || !firstEntry || !lastEntry) continue; |
| 1187 | const hidesNonCompactionWork = group.entries.some( |
| 1188 | (entry) => |
| 1189 | hiddenEntryIds.has(entry.id) && |
| 1190 | !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)), |
| 1191 | ); |
| 1192 | if (!hidesNonCompactionWork) continue; |
| 1193 | const terminalEntry = terminalAssistantId |
| 1194 | ? group.entries.find((entry) => entry.id === terminalAssistantId) |
| 1195 | : null; |
| 1196 | const latestRunMatches = latestRun?.runId === runId; |
| 1197 | const lastEntryEnd = |
| 1198 | lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; |
| 1199 | const elapsedMs = |
| 1200 | latestRunMatches && latestRun.startedAt && latestRun.completedAt |
| 1201 | ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt) |
| 1202 | : computeElapsedMs( |
| 1203 | group.startBoundary ?? firstEntry.createdAt, |
| 1204 | maxIsoTimestamp( |
| 1205 | terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, |
| 1206 | lastEntryEnd, |
| 1207 | ) ?? lastEntryEnd, |
| 1208 | ); |
| 1209 | const duration = elapsedMs === null ? null : formatDuration(elapsedMs); |
| 1210 | const interrupted = |
| 1211 | latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); |
| 1212 | foldsByAnchorId.set(firstHiddenEntry.id, { |
| 1213 | runId, |
| 1214 | createdAt: firstHiddenEntry.createdAt, |
| 1215 | hiddenEntryIds, |
| 1216 | label: interrupted |
| 1217 | ? duration |
| 1218 | ? `You stopped after ${duration}` |
| 1219 | : "You stopped this response" |
| 1220 | : duration |
| 1221 | ? `Worked for ${duration}` |
| 1222 | : "Worked", |
| 1223 | }); |
| 1224 | } |
| 1225 | return foldsByAnchorId; |
| 1226 | } |
| 1227 | |
| 1228 | const supersededReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>(); |
| 1229 | const trailingReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>(); |
| 1230 | |
| 1231 | /** A steer or subsequent activity ends thinking even if the provider omits its completion. */ |
| 1232 | function settleSupersededReasoning( |
| 1233 | entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>, |
| 1234 | tail: boolean, |
| 1235 | ) { |
| 1236 | if (entry.type !== "activity-group") return entry; |
| 1237 | const cache = tail ? trailingReasoningGroups : supersededReasoningGroups; |
| 1238 | const cached = cache.get(entry); |
| 1239 | if (cached) return cached; |
| 1240 | const activities = entry.activities.map((activity, index) => |
| 1241 | activity.workEntry.itemType === "reasoning" && |
| 1242 | activity.lifecycleStatus === "inProgress" && |
| 1243 | (!tail || index < entry.activities.length - 1) |
| 1244 | ? { |
| 1245 | ...activity, |
| 1246 | lifecycleStatus: "completed" as const, |
| 1247 | status: "success" as const, |
| 1248 | workEntry: { ...activity.workEntry, toolLifecycleStatus: "completed" as const }, |
| 1249 | } |
| 1250 | : activity, |
| 1251 | ); |
| 1252 | const settled = activities.some((activity, index) => activity !== entry.activities[index]) |
| 1253 | ? { ...entry, activities } |
| 1254 | : entry; |
| 1255 | cache.set(entry, settled); |
| 1256 | return settled; |
| 1257 | } |
| 1258 | |
| 1259 | export function deriveThreadFeedPresentation( |
| 1260 | feed: ReadonlyArray<ThreadFeedEntry>, |
| 1261 | latestRun: ThreadFeedLatestRun | null, |
| 1262 | expandedRunIds: ReadonlySet<RunId>, |
| 1263 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), |
| 1264 | activeWorkStartedAt: string | null = null, |
| 1265 | /** The live work is a provider-native subagent's runless root turn. */ |
| 1266 | runlessWorkActive = false, |
| 1267 | ): ThreadFeedEntry[] { |
| 1268 | const retainedFeed = feed.filter( |
| 1269 | (entry) => |
| 1270 | entry.type !== "run-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", |
| 1271 | ); |
| 1272 | const sourceFeed = retainedFeed.map((entry, index) => |
| 1273 | settleSupersededReasoning(entry, index === retainedFeed.length - 1), |
| 1274 | ); |
| 1275 | const failedRunIds = failedFeedRunIds(sourceFeed, latestRun); |
| 1276 | const activeTailGroup = sourceFeed.at(-1); |
| 1277 | const activeRunId = unsettledRunId(latestRun); |
| 1278 | const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; |
| 1279 | const foldsByAnchorId = deriveThreadFeedRunFolds( |
| 1280 | sourceFeed, |
| 1281 | latestRun, |
| 1282 | isWorking && runlessWorkActive, |
| 1283 | failedRunIds, |
| 1284 | ); |
| 1285 | const collapsedEntryIds = new Set<string>(); |
| 1286 | for (const fold of foldsByAnchorId.values()) { |
| 1287 | if (!expandedRunIds.has(fold.runId)) { |
| 1288 | for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId); |
| 1289 | } |
| 1290 | } |
| 1291 | const result: ThreadFeedEntry[] = []; |
| 1292 | for (const entry of sourceFeed) { |
| 1293 | // A provider-native subagent works without a run: its null-run tail is |
| 1294 | // live only while that runless work is active. |
| 1295 | const isActiveTailGroup = |
| 1296 | isWorking && |
| 1297 | (activeRunId !== null || runlessWorkActive) && |
| 1298 | entry.type === "activity-group" && |
| 1299 | activeTailGroup?.type === "activity-group" && |
| 1300 | activeTailGroup.id === entry.id && |
| 1301 | entry.runId === activeRunId; |
| 1302 | const fold = foldsByAnchorId.get(entry.id); |
| 1303 | if (fold) { |
| 1304 | const expanded = expandedRunIds.has(fold.runId); |
| 1305 | let row = runFoldRowsCache.get(entry); |
| 1306 | if ( |
| 1307 | !row || |
| 1308 | row.runId !== fold.runId || |
| 1309 | row.createdAt !== fold.createdAt || |
| 1310 | row.label !== fold.label || |
| 1311 | row.expanded !== expanded |
| 1312 | ) { |
| 1313 | row = { |
| 1314 | type: "run-fold", |
| 1315 | id: `run-fold:${fold.runId}`, |
| 1316 | createdAt: fold.createdAt, |
| 1317 | runId: fold.runId, |
| 1318 | label: fold.label, |
| 1319 | expanded, |
| 1320 | }; |
| 1321 | runFoldRowsCache.set(entry, row); |
| 1322 | } |
| 1323 | result.push(row); |
| 1324 | } |
| 1325 | if (!collapsedEntryIds.has(entry.id)) { |
| 1326 | if ( |
| 1327 | entry.type === "activity-group" && |
| 1328 | entry.runId !== null && |
| 1329 | failedRunIds.has(entry.runId) |
| 1330 | ) { |
| 1331 | let rows = failedActivityGroupsCache.get(entry); |
| 1332 | if (!rows) { |
| 1333 | rows = |
| 1334 | entry.activities.length === 1 |
| 1335 | ? [entry] |
| 1336 | : entry.activities.map((activity) => ({ |
| 1337 | type: "activity-group" as const, |
| 1338 | id: activity.id, |
| 1339 | createdAt: activity.createdAt, |
| 1340 | runId: activity.runId, |
| 1341 | activities: [activity], |
| 1342 | })); |
| 1343 | failedActivityGroupsCache.set(entry, rows); |
| 1344 | } |
| 1345 | result.push(...rows); |
| 1346 | continue; |
| 1347 | } |
| 1348 | appendPresentedFeedEntry( |
| 1349 | result, |
| 1350 | entry, |
| 1351 | expandedWorkGroupIds, |
| 1352 | activeRunId, |
| 1353 | isWorking, |
| 1354 | isActiveTailGroup, |
| 1355 | ); |
| 1356 | } |
| 1357 | } |
| 1358 | // Keep exactly one live slot while a run is working. When no tool row can |
| 1359 | // carry it yet (or the latest call failed), the slot reads "Thinking". |
| 1360 | if ( |
| 1361 | isWorking && |
| 1362 | activeWorkStartedAt !== null && |
| 1363 | !result.some( |
| 1364 | (row) => |
| 1365 | (row.type === "work-toggle" && row.shimmer) || |
| 1366 | (row.type === "activity-group" && |
| 1367 | isContextCompactionActivityGroup(row) && |
| 1368 | row.runId === activeRunId && |
| 1369 | row.activities[0]?.projectedItem.item.status === "running"), |
| 1370 | ) |
| 1371 | ) { |
| 1372 | result.push(thinkingRow(activeWorkStartedAt, activeRunId)); |
| 1373 | } |
| 1374 | return result.map((row, index) => { |
| 1375 | if (!isWorkLogFeedRow(row) || !isWorkLogFeedRow(result[index + 1])) return row; |
| 1376 | let continued = continuedWorkLogRows.get(row); |
| 1377 | if (!continued) { |
| 1378 | continued = { ...row, continuesWorkLog: true }; |
| 1379 | continuedWorkLogRows.set(row, continued); |
| 1380 | } |
| 1381 | return continued; |
| 1382 | }); |
| 1383 | } |
| 1384 | |
| 1385 | const continuedWorkLogRows = new WeakMap<ThreadFeedEntry, ThreadFeedEntry>(); |
| 1386 | |
| 1387 | function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean { |
| 1388 | return ( |
| 1389 | row !== undefined && |
| 1390 | (row.type === "work-toggle" || |
| 1391 | row.type === "thinking" || |
| 1392 | (row.type === "activity-group" && |
| 1393 | !isContextCompactionActivityGroup(row) && |
| 1394 | !isContextHandoffActivityGroup(row) && |
| 1395 | row.activities.every( |
| 1396 | (activity) => |
| 1397 | !activity.prominent && |
| 1398 | activity.projectedItem.item.type !== "notification" && |
| 1399 | activity.projectedItem.item.type !== "subagent", |
| 1400 | ))) |
| 1401 | ); |
| 1402 | } |
| 1403 | |
| 1404 | /** Shared by the trailing live tool row and its Thinking fallback. */ |
| 1405 | export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; |
| 1406 | |
| 1407 | function thinkingRow(createdAt: string, runId: RunId | null) { |
| 1408 | if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.runId !== runId) { |
| 1409 | cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, runId }; |
| 1410 | } |
| 1411 | return cachedThinkingRow; |
| 1412 | } |
| 1413 | |
| 1414 | function appendPresentedFeedEntry( |
| 1415 | result: ThreadFeedEntry[], |
| 1416 | entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>, |
| 1417 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1418 | activeRunId: RunId | null, |
| 1419 | isWorking: boolean, |
| 1420 | activeTail: boolean, |
| 1421 | ): void { |
| 1422 | if (entry.type !== "activity-group") { |
| 1423 | result.push(entry); |
| 1424 | return; |
| 1425 | } |
| 1426 | if ( |
| 1427 | isContextCompactionActivityGroup(entry) || |
| 1428 | isContextHandoffActivityGroup(entry) || |
| 1429 | isUserInputActivityGroup(entry) || |
| 1430 | entry.activities[0]?.projectedItem.item.type === "subagent" |
| 1431 | ) { |
| 1432 | result.push(entry); |
| 1433 | return; |
| 1434 | } |
| 1435 | |
| 1436 | let cached = presentedActivityGroupsCache.get(entry); |
| 1437 | if ( |
| 1438 | !cached || |
| 1439 | cached.activeRunId !== activeRunId || |
| 1440 | cached.isWorking !== isWorking || |
| 1441 | cached.activeTail !== activeTail || |
| 1442 | cached.rows.some( |
| 1443 | (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, |
| 1444 | ) |
| 1445 | ) { |
| 1446 | const rows: ThreadFeedEntry[] = []; |
| 1447 | appendActivityGroupRows(rows, entry, expandedWorkGroupIds, activeRunId, isWorking, activeTail); |
| 1448 | cached = { activeRunId, isWorking, activeTail, rows }; |
| 1449 | presentedActivityGroupsCache.set(entry, cached); |
| 1450 | } |
| 1451 | for (const row of cached.rows) { |
| 1452 | result.push(row); |
| 1453 | } |
| 1454 | } |
| 1455 | |
| 1456 | function appendActivityGroupRows( |
| 1457 | result: ThreadFeedEntry[], |
| 1458 | entry: ThreadFeedActivityGroup, |
| 1459 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1460 | activeRunId: RunId | null, |
| 1461 | isWorking: boolean, |
| 1462 | activeTail: boolean, |
| 1463 | ): void { |
| 1464 | const groupAnchorIdByActivityId = new Map<string, string>(); |
| 1465 | let groupAnchorId: string | null = null; |
| 1466 | for (const activity of entry.activities) { |
| 1467 | const item = activity.projectedItem.item; |
| 1468 | if (activity.prominent || (item.type === "error" && item.status === "failed")) { |
| 1469 | groupAnchorId = null; |
| 1470 | continue; |
| 1471 | } |
| 1472 | groupAnchorId ??= activity.id; |
| 1473 | groupAnchorIdByActivityId.set(activity.id, groupAnchorId); |
| 1474 | } |
| 1475 | const activities = entry.activities.filter( |
| 1476 | (activity) => |
| 1477 | threadFeedActivityIsVisible(activity) || |
| 1478 | (isWorking && activity.lifecycleStatus === "inProgress" && activity.runId === activeRunId), |
| 1479 | ); |
| 1480 | if (activities.length === 0) { |
| 1481 | return; |
| 1482 | } |
| 1483 | |
| 1484 | let groupableRun: ThreadFeedActivity[] = []; |
| 1485 | const flushGroupableRun = (isTrailingRun: boolean) => { |
| 1486 | if (groupableRun.length === 0) return; |
| 1487 | appendToolGroupRows( |
| 1488 | result, |
| 1489 | entry, |
| 1490 | groupableRun, |
| 1491 | `work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`, |
| 1492 | expandedWorkGroupIds, |
| 1493 | activeRunId, |
| 1494 | isWorking, |
| 1495 | activeTail && isTrailingRun, |
| 1496 | ); |
| 1497 | groupableRun = []; |
| 1498 | }; |
| 1499 | for (const activity of activities) { |
| 1500 | const item = activity.projectedItem.item; |
| 1501 | const severeProviderError = item.type === "error" && item.status === "failed"; |
| 1502 | if (!activity.prominent && !severeProviderError && item.type !== "notification") { |
| 1503 | groupableRun.push(activity); |
| 1504 | continue; |
| 1505 | } |
| 1506 | flushGroupableRun(false); |
| 1507 | result.push({ |
| 1508 | type: "activity-group", |
| 1509 | id: activity.id, |
| 1510 | createdAt: activity.createdAt, |
| 1511 | runId: activity.runId, |
| 1512 | activities: [activity], |
| 1513 | }); |
| 1514 | } |
| 1515 | flushGroupableRun(true); |
| 1516 | } |
| 1517 | |
| 1518 | function appendToolGroupRows( |
| 1519 | result: ThreadFeedEntry[], |
| 1520 | sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| 1521 | activities: ReadonlyArray<ThreadFeedActivity>, |
| 1522 | groupId: string, |
| 1523 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1524 | activeRunId: RunId | null, |
| 1525 | isWorking: boolean, |
| 1526 | activeTail: boolean, |
| 1527 | ): void { |
| 1528 | const expanded = expandedWorkGroupIds.has(groupId); |
| 1529 | const latestActiveActivity = activities.findLast( |
| 1530 | (activity) => |
| 1531 | isWorking && activity.lifecycleStatus === "inProgress" && activity.runId === activeRunId, |
| 1532 | ); |
| 1533 | const active = latestActiveActivity !== undefined; |
| 1534 | const live = activeTail || active; |
| 1535 | const latestActivity = latestActiveActivity ?? activities.at(-1)!; |
| 1536 | // A successful trailing call remains the live slot until the next activity. |
| 1537 | // Failed/stopped calls hand that slot to the Thinking row. |
| 1538 | const shimmer = activeTail && (active || latestActivity.status === "success"); |
| 1539 | const singleActivity = activities.length === 1 ? latestActivity : null; |
| 1540 | const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); |
| 1541 | const latestThought = |
| 1542 | live && !expanded |
| 1543 | ? activities.findLast( |
| 1544 | (activity) => |
| 1545 | activity.workEntry.itemType === "reasoning" && |
| 1546 | (activity.workEntry.detail?.trim() ?? "") !== "", |
| 1547 | )?.workEntry.detail |
| 1548 | : undefined; |
| 1549 | const thought = latestThought ? liveThoughtLine(latestThought) : ""; |
| 1550 | const summary = live |
| 1551 | ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" |
| 1552 | ? latestActivity.lifecycleStatus === "inProgress" |
| 1553 | ? "Thinking" |
| 1554 | : "Thought" |
| 1555 | : liveToolActivitySummary(latestActivity, live) |
| 1556 | : singleActivity !== null && |
| 1557 | singleActivity.toolLike && |
| 1558 | toolGroupAction(singleActivity.workEntry) !== "edit" |
| 1559 | ? singleToolCallLabel(singleActivity, expanded) |
| 1560 | : singleActivity !== null && !singleActivity.toolLike |
| 1561 | ? singleActivity.workEntry.label |
| 1562 | : groupSummary.summary; |
| 1563 | const primarySourceActivity = activities.find( |
| 1564 | (activity) => activity.workEntry.toolSource !== undefined, |
| 1565 | ); |
| 1566 | const primarySourceKey = primarySourceActivity?.workEntry.toolSource?.key; |
| 1567 | const primarySourceIcon = primarySourceKey |
| 1568 | ? (activities.find( |
| 1569 | (activity) => |
| 1570 | activity.workEntry.toolSource?.key === primarySourceKey && |
| 1571 | activity.workEntry.toolIcon !== undefined, |
| 1572 | )?.workEntry.toolIcon ?? primarySourceActivity?.workEntry.toolSource?.icon) |
| 1573 | : undefined; |
| 1574 | const groupToolSurface = |
| 1575 | primarySourceActivity?.workEntry.toolSurface ?? |
| 1576 | latestActivity.workEntry.toolSurface ?? |
| 1577 | activities.findLast((activity) => activity.workEntry.toolSurface !== undefined)?.workEntry |
| 1578 | .toolSurface; |
| 1579 | const groupToolIcon = |
| 1580 | primarySourceIcon ?? |
| 1581 | latestActivity.workEntry.toolIcon ?? |
| 1582 | activities.findLast((activity) => activity.workEntry.toolIcon !== undefined)?.workEntry |
| 1583 | .toolIcon; |
| 1584 | const summaryToolIcon = live |
| 1585 | ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon |
| 1586 | : singleActivity !== null && |
| 1587 | singleActivity.toolLike && |
| 1588 | toolGroupAction(singleActivity.workEntry) !== "edit" |
| 1589 | ? resolveWorkEntryToolPresentation(singleActivity.workEntry, "completed")?.icon |
| 1590 | : undefined; |
| 1591 | result.push({ |
| 1592 | type: "work-toggle", |
| 1593 | id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, |
| 1594 | createdAt: sourceGroup.createdAt, |
| 1595 | runId: sourceGroup.runId, |
| 1596 | groupId, |
| 1597 | hiddenCount: activities.length, |
| 1598 | expanded, |
| 1599 | summary, |
| 1600 | summaryKind: toolGroupSummaryKind( |
| 1601 | (live ? [latestActivity] : activities).map((activity) => activity.workEntry), |
| 1602 | ), |
| 1603 | ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), |
| 1604 | ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), |
| 1605 | ...(summaryToolIcon ? { summaryToolIcon } : {}), |
| 1606 | ...(thought ? { thought } : {}), |
| 1607 | hasFailure: (() => { |
| 1608 | const lastToolLike = activities.findLast((activity) => activity.toolLike); |
| 1609 | return ( |
| 1610 | lastToolLike !== undefined && workEntryDisplayIndicatesToolFailure(lastToolLike.workEntry) |
| 1611 | ); |
| 1612 | })(), |
| 1613 | live, |
| 1614 | shimmer, |
| 1615 | }); |
| 1616 | if (!expanded) return; |
| 1617 | result.push({ |
| 1618 | type: "activity-group", |
| 1619 | id: `work-details:${groupId}`, |
| 1620 | createdAt: activities[0]!.createdAt, |
| 1621 | runId: activities[0]!.runId, |
| 1622 | activities: activities.map((activity) => ({ |
| 1623 | ...activity, |
| 1624 | groupedToolDetail: true, |
| 1625 | live: |
| 1626 | isWorking && |
| 1627 | activity.id === latestActivity.id && |
| 1628 | activity.lifecycleStatus === "inProgress" && |
| 1629 | activity.runId === activeRunId, |
| 1630 | })), |
| 1631 | }); |
| 1632 | } |
| 1633 | |
| 1634 | function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { |
| 1635 | const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); |
| 1636 | if (activity.workEntry.itemType === "reasoning") { |
| 1637 | return ( |
| 1638 | activity.workEntry.detail?.trim().replace(/\s+/g, " ") || |
| 1639 | (status === "inProgress" ? "Thinking" : "Thought") |
| 1640 | ); |
| 1641 | } |
| 1642 | const presentation = resolveWorkEntryToolPresentation({ |
| 1643 | ...activity.workEntry, |
| 1644 | toolLifecycleStatus: status, |
| 1645 | }); |
| 1646 | if (presentation) return presentation.displayName; |
| 1647 | const command = activity.workEntry.command?.trim(); |
| 1648 | if (command) { |
| 1649 | const program = commandProgramName(command); |
| 1650 | const verb = |
| 1651 | status === "inProgress" |
| 1652 | ? "Running" |
| 1653 | : status === "failed" |
| 1654 | ? "Failed" |
| 1655 | : status === "declined" |
| 1656 | ? "Declined" |
| 1657 | : status === "stopped" |
| 1658 | ? "Stopped" |
| 1659 | : "Ran"; |
| 1660 | return `${verb} ${program ?? "command"}`; |
| 1661 | } |
| 1662 | return activity.detail ?? activity.summary; |
| 1663 | } |
| 1664 | |
| 1665 | export function setPendingUserInputCustomAnswer( |
| 1666 | question: ThreadUserInputQuestion, |
| 1667 | draft: PendingUserInputDraftAnswer | undefined, |
| 1668 | customAnswer: string, |
| 1669 | ): PendingUserInputDraftAnswer { |
| 1670 | if (question.allowCustomAnswer === false) { |
| 1671 | return draft ?? {}; |
| 1672 | } |
| 1673 | |
| 1674 | const selectedOptionValues = |
| 1675 | question.initialAnswer !== undefined || customAnswer.trim().length > 0 |
| 1676 | ? undefined |
| 1677 | : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); |
| 1678 | return { |
| 1679 | customAnswer, |
| 1680 | ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), |
| 1681 | }; |
| 1682 | } |
| 1683 | |
| 1684 | export function isPendingUserInputOptionSelected( |
| 1685 | question: ThreadUserInputQuestion, |
| 1686 | draft: PendingUserInputDraftAnswer | undefined, |
| 1687 | optionValue: string, |
| 1688 | ): boolean { |
| 1689 | if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { |
| 1690 | return false; |
| 1691 | } |
| 1692 | |
| 1693 | const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); |
| 1694 | return ( |
| 1695 | resolvedOptionValue !== null && |
| 1696 | normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( |
| 1697 | resolvedOptionValue, |
| 1698 | ) |
| 1699 | ); |
| 1700 | } |
| 1701 | |
| 1702 | export function togglePendingUserInputOptionSelection( |
| 1703 | question: ThreadUserInputQuestion, |
| 1704 | draft: PendingUserInputDraftAnswer | undefined, |
| 1705 | optionValue: string, |
| 1706 | ): PendingUserInputDraftAnswer { |
| 1707 | const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); |
| 1708 | if (resolvedOptionValue === null) { |
| 1709 | return draft ?? {}; |
| 1710 | } |
| 1711 | |
| 1712 | if (question.multiSelect) { |
| 1713 | const selectedOptionValues = normalizeSelectedOptionValues( |
| 1714 | question, |
| 1715 | draft?.selectedOptionValues, |
| 1716 | ); |
| 1717 | const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) |
| 1718 | ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) |
| 1719 | : [...selectedOptionValues, resolvedOptionValue]; |
| 1720 | |
| 1721 | return { |
| 1722 | customAnswer: "", |
| 1723 | ...(nextSelectedOptionValues.length > 0 |
| 1724 | ? { selectedOptionValues: nextSelectedOptionValues } |
| 1725 | : {}), |
| 1726 | }; |
| 1727 | } |
| 1728 | |
| 1729 | return { |
| 1730 | customAnswer: "", |
| 1731 | selectedOptionValues: [resolvedOptionValue], |
| 1732 | }; |
| 1733 | } |
| 1734 | |
| 1735 | export function buildPendingUserInputAnswers( |
| 1736 | questions: ReadonlyArray<ThreadUserInputQuestion>, |
| 1737 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 1738 | ): Record<string, string | ReadonlyArray<string>> | null { |
| 1739 | const answers: Record<string, string | ReadonlyArray<string>> = {}; |
| 1740 | |
| 1741 | for (const question of questions) { |
| 1742 | const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); |
| 1743 | if (answer === null) { |
| 1744 | return null; |
| 1745 | } |
| 1746 | answers[question.id] = answer; |
| 1747 | } |
| 1748 | |
| 1749 | return answers; |
| 1750 | } |
| 1751 | |
| 1752 | /** |
| 1753 | * Projects the server-authored visible sequence into mobile row presentation. |
| 1754 | * It deliberately preserves the incoming order and never rebuilds chat from |
| 1755 | * separate message, plan, or work-entry collections. |
| 1756 | */ |
| 1757 | export function buildThreadFeed( |
| 1758 | visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>, |
| 1759 | options?: { |
| 1760 | readonly anchoredMessages?: ReadonlyArray<LocalThreadMessage>; |
| 1761 | readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>; |
| 1762 | readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>; |
| 1763 | }, |
| 1764 | ): ThreadFeedEntry[] { |
| 1765 | const entries: RawThreadFeedEntry[] = []; |
| 1766 | const attemptByRootNodeId = new Map( |
| 1767 | (options?.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const), |
| 1768 | ); |
| 1769 | const nodeById = new Map((options?.nodes ?? []).map((node) => [node.id, node] as const)); |
| 1770 | const resolveAttemptId = (item: OrchestrationV2TurnItem): RunAttemptId | null => { |
| 1771 | if (item.nodeId === null || item.runId === null) return null; |
| 1772 | let nodeId: OrchestrationV2ExecutionNode["id"] | null = item.nodeId; |
| 1773 | const visited = new Set<OrchestrationV2ExecutionNode["id"]>(); |
| 1774 | while (nodeId !== null && !visited.has(nodeId)) { |
| 1775 | visited.add(nodeId); |
| 1776 | const directAttempt = attemptByRootNodeId.get(nodeId); |
| 1777 | if (directAttempt?.runId === item.runId) return directAttempt.id; |
| 1778 | const node = nodeById.get(nodeId); |
| 1779 | if (node === undefined) return null; |
| 1780 | const rootAttempt = attemptByRootNodeId.get(node.rootNodeId); |
| 1781 | if (rootAttempt?.runId === item.runId) return rootAttempt.id; |
| 1782 | nodeId = node.parentNodeId; |
| 1783 | } |
| 1784 | return null; |
| 1785 | }; |
| 1786 | const foldedAnswerMessageIds = new Set( |
| 1787 | visibleTurnItems.flatMap(({ item }) => |
| 1788 | item.type === "user_input_request" && item.questionAnswer |
| 1789 | ? [`async-answer:${item.questionAnswer.requestId}`] |
| 1790 | : [], |
| 1791 | ), |
| 1792 | ); |
| 1793 | for (const row of visibleTurnItems) { |
| 1794 | const item = row.item; |
| 1795 | if (turnItemIsWorkspacePreparation(item)) continue; |
| 1796 | if (item.type === "todo_list" || item.type === "checkpoint") continue; |
| 1797 | if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; |
| 1798 | // Match the web timeline: only the terminal interrupt result is useful to |
| 1799 | // users; the preceding request is transient bookkeeping. |
| 1800 | if (item.type === "run_interrupt_request") { |
| 1801 | continue; |
| 1802 | } |
| 1803 | const attemptId = resolveAttemptId(item); |
| 1804 | const cached = projectedEntriesCache.get(row); |
| 1805 | if (cached?.attemptId === attemptId) { |
| 1806 | entries.push(cached.entry); |
| 1807 | continue; |
| 1808 | } |
| 1809 | const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); |
| 1810 | // A running or failed render stays an ordinary work row. |
| 1811 | const render = |
| 1812 | item.type === "dynamic_tool" && item.status === "completed" |
| 1813 | ? htmlRenderFromToolItem(item) |
| 1814 | : undefined; |
| 1815 | if (render) { |
| 1816 | const entry: RawThreadFeedEntry = { |
| 1817 | type: "html-render", |
| 1818 | id: `html-render:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, |
| 1819 | createdAt, |
| 1820 | runId: item.runId, |
| 1821 | render, |
| 1822 | }; |
| 1823 | projectedEntriesCache.set(row, { attemptId, entry }); |
| 1824 | entries.push(entry); |
| 1825 | continue; |
| 1826 | } |
| 1827 | const app = |
| 1828 | item.type === "dynamic_tool" && item.status === "completed" |
| 1829 | ? mcpAppFromToolItem(item) |
| 1830 | : undefined; |
| 1831 | if (app) { |
| 1832 | const entry: RawThreadFeedEntry = { |
| 1833 | type: "mcp-app", |
| 1834 | id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, |
| 1835 | createdAt, |
| 1836 | runId: item.runId, |
| 1837 | sourceThreadId: row.sourceThreadId, |
| 1838 | itemId: row.sourceItemId, |
| 1839 | revision: turnItemDetailRevision(item), |
| 1840 | app, |
| 1841 | }; |
| 1842 | projectedEntriesCache.set(row, { attemptId, entry }); |
| 1843 | entries.push(entry); |
| 1844 | continue; |
| 1845 | } |
| 1846 | if (item.type === "user_message" || item.type === "assistant_message") { |
| 1847 | const updatedAt = DateTime.formatIso(item.updatedAt); |
| 1848 | const entry: RawThreadFeedEntry = { |
| 1849 | type: "message", |
| 1850 | id: item.messageId, |
| 1851 | createdAt, |
| 1852 | message: { |
| 1853 | id: item.messageId, |
| 1854 | role: item.type === "user_message" ? "user" : "assistant", |
| 1855 | text: item.text, |
| 1856 | ...(item.type === "user_message" && item.context ? { context: item.context } : {}), |
| 1857 | attachments: item.attachments ?? [], |
| 1858 | runId: item.runId, |
| 1859 | streaming: item.type === "assistant_message" && item.streaming, |
| 1860 | ...(item.type === "user_message" |
| 1861 | ? { |
| 1862 | inputIntent: item.inputIntent, |
| 1863 | createdBy: item.createdBy, |
| 1864 | creationSource: item.creationSource, |
| 1865 | ...(item.scheduledTaskId ? { scheduledTaskId: item.scheduledTaskId } : {}), |
| 1866 | ...(item.senderThreadId ? { senderThreadId: item.senderThreadId } : {}), |
| 1867 | } |
| 1868 | : {}), |
| 1869 | visibility: row.visibility, |
| 1870 | sourceThreadId: row.sourceThreadId, |
| 1871 | createdAt, |
| 1872 | updatedAt, |
| 1873 | projectedItem: row, |
| 1874 | }, |
| 1875 | }; |
| 1876 | projectedEntriesCache.set(row, { attemptId, entry }); |
| 1877 | entries.push(entry); |
| 1878 | continue; |
| 1879 | } |
| 1880 | const activity = toFeedActivity(row, attemptId); |
| 1881 | const entry: RawThreadFeedEntry = { |
| 1882 | type: "activity", |
| 1883 | id: activity.id, |
| 1884 | createdAt, |
| 1885 | runId: item.runId, |
| 1886 | activity, |
| 1887 | }; |
| 1888 | projectedEntriesCache.set(row, { attemptId, entry }); |
| 1889 | entries.push(entry); |
| 1890 | } |
| 1891 | const retainedMessageIds = new Set([ |
| 1892 | ...foldedAnswerMessageIds, |
| 1893 | ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])), |
| 1894 | ]); |
| 1895 | const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => { |
| 1896 | const cached = localMessageEntriesCache.get(message); |
| 1897 | if (cached) return cached; |
| 1898 | const entry: Extract<RawThreadFeedEntry, { readonly type: "message" }> = { |
| 1899 | type: "message", |
| 1900 | id: message.id, |
| 1901 | createdAt: message.createdAt, |
| 1902 | message: { |
| 1903 | id: message.id, |
| 1904 | role: message.role === "assistant" ? "assistant" : "user", |
| 1905 | text: message.text, |
| 1906 | ...(message.context ? { context: message.context } : {}), |
| 1907 | attachments: message.attachments ?? [], |
| 1908 | runId: null, |
| 1909 | streaming: message.streaming, |
| 1910 | visibility: "local", |
| 1911 | sourceThreadId: ThreadId.make("local-feedback"), |
| 1912 | createdAt: message.createdAt, |
| 1913 | updatedAt: message.updatedAt, |
| 1914 | }, |
| 1915 | }; |
| 1916 | localMessageEntriesCache.set(message, entry); |
| 1917 | return entry; |
| 1918 | }; |
| 1919 | for (const message of options?.anchoredMessages ?? []) { |
| 1920 | if (retainedMessageIds.has(message.id)) continue; |
| 1921 | retainedMessageIds.add(message.id); |
| 1922 | const entry = appendLocalMessage(message); |
| 1923 | const insertionIndex = entries.findIndex( |
| 1924 | (candidate) => candidate.createdAt > message.createdAt, |
| 1925 | ); |
| 1926 | if (insertionIndex === -1) entries.push(entry); |
| 1927 | else entries.splice(insertionIndex, 0, entry); |
| 1928 | } |
| 1929 | return groupAdjacentActivities(entries); |
| 1930 | } |