| author | |
| committer | |
| log | 22539f2a428947ab218589c984b2af44e434012e |
| tree | 836ae462ef5803c1069624d3a74834f2f6aa0137 |
| parent | dda44ed33280106aa46612fd9e1f1475bb5a0712 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 1005 insertions(+), 110 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+19-23| ... | ... | @@ -1476,33 +1476,31 @@ function renderFeedEntry( |
| 1476 | 1476 | if (entry.type === "message") { |
| 1477 | 1477 | const { message } = entry; |
| 1478 | 1478 | if (message.role === "reasoning") { |
| 1479 | // Only the live turn may claim to still be thinking, and only while the | |
| 1480 | // thread is actually working: a block left open by a crashed provider | |
| 1481 | // must not shimmer on a turn that settled long ago. Same test as web. | |
| 1482 | const liveReasoning = | |
| 1483 | Boolean(message.streaming) && | |
| 1484 | props.isWorking && | |
| 1485 | message.turnId !== null && | |
| 1486 | message.turnId === props.unsettledTurnId; | |
| 1479 | const messages = entry.reasoningMessages ?? [message]; | |
| 1487 | 1480 | return ( |
| 1488 | 1481 | <ThreadReasoningRow |
| 1489 | 1482 | rowSizing={props.workRowSizing} |
| 1490 | 1483 | iconSubtleColor={iconSubtleColor} |
| 1491 | expanded={props.expandedReasoningMessageIds.has(message.id)} | |
| 1492 | label={liveReasoning ? "Thinking" : "Thought"} | |
| 1493 | streaming={liveReasoning} | |
| 1494 | onToggle={() => props.onToggleReasoning(message.id)} | |
| 1484 | expanded={props.expandedReasoningMessageIds.has(entry.id)} | |
| 1485 | label={`Thought${messages.length > 1 ? ` (×${messages.length})` : ""}`} | |
| 1486 | streaming={false} | |
| 1487 | onToggle={() => props.onToggleReasoning(entry.id)} | |
| 1495 | 1488 | > |
| 1496 | 1489 | <MarkdownImageAvailableWidthContext |
| 1497 | 1490 | value={props.markdownContentWidth - REASONING_CONTENT_INSET} |
| 1498 | 1491 | > |
| 1499 | <AssistantMarkdownContent | |
| 1500 | markdown={message.text} | |
| 1501 | markdownStyles={markdownStyles.assistant} | |
| 1502 | linkHandlers={props.markdownLinkHandlers} | |
| 1503 | renderImage={props.renderMarkdownImage} | |
| 1504 | skills={props.skills} | |
| 1505 | /> | |
| 1492 | <View className="gap-3"> | |
| 1493 | {messages.map((reasoningMessage) => ( | |
| 1494 | <AssistantMarkdownContent | |
| 1495 | key={reasoningMessage.id} | |
| 1496 | markdown={reasoningMessage.text} | |
| 1497 | markdownStyles={markdownStyles.assistant} | |
| 1498 | linkHandlers={props.markdownLinkHandlers} | |
| 1499 | renderImage={props.renderMarkdownImage} | |
| 1500 | skills={props.skills} | |
| 1501 | /> | |
| 1502 | ))} | |
| 1503 | </View> | |
| 1506 | 1504 | </MarkdownImageAvailableWidthContext> |
| 1507 | 1505 | </ThreadReasoningRow> |
| 1508 | 1506 | ); |
| ... | ... | @@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2656 | 2654 | |
| 2657 | 2655 | const onToggleReasoning = useCallback( |
| 2658 | 2656 | (messageId: string) => { |
| 2659 | // The anchor must be the feed row id, which for a message row is the | |
| 2660 | // message id, or position restoration is skipped for every row. | |
| 2657 | // Reasoning details use their own row within the expanded activity history. | |
| 2661 | 2658 | suspendEndScrollMaintenanceForDisclosure(messageId); |
| 2662 | 2659 | setInteractionState((current) => { |
| 2663 | 2660 | const next = new Set(current.expandedReasoningMessageIds); |
| ... | ... | @@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2700 | 2697 | switch (entry.type) { |
| 2701 | 2698 | case "message": |
| 2702 | 2699 | // A collapsed reasoning row is the same chrome as a work toggle. |
| 2703 | return entry.message.role === "reasoning" && | |
| 2704 | !expandedReasoningMessageIds.has(entry.message.id) | |
| 2700 | return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id) | |
| 2705 | 2701 | ? WORK_GROUP_TOGGLE_HEIGHT |
| 2706 | 2702 | : undefined; |
| 2707 | 2703 | case "turn-fold": |
apps/mobile/src/features/threads/thread-work-log.tsx+1-1| ... | ... | @@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: { |
| 920 | 920 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 921 | 921 | readonly summary: string; |
| 922 | 922 | readonly summaryKind: ToolGroupSummaryKind; |
| 923 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; | |
| 923 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; | |
| 924 | 924 | readonly themeAppearance: "light" | "dark"; |
| 925 | 925 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; |
| 926 | 926 | readonly toolIcon?: ToolActivityIcon; |
apps/mobile/src/lib/threadActivity.test.ts+275| ... | ... | @@ -2295,6 +2295,281 @@ describe("buildThreadFeed", () => { |
| 2295 | 2295 | }, |
| 2296 | 2296 | ); |
| 2297 | 2297 | |
| 2298 | it("groups ordered reasoning blocks, keeps the live slot, and restores the group after unfolding", () => { | |
| 2299 | const turnId = TurnId.make("reasoning-group"); | |
| 2300 | const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((second) => ({ | |
| 2301 | id: MessageId.make(`reasoning-${second}`), | |
| 2302 | role: "reasoning", | |
| 2303 | text: `**Step ${second}**\n\nCheck ${second}.`, | |
| 2304 | turnId, | |
| 2305 | streaming: second === 4, | |
| 2306 | createdAt: `2026-04-01T00:00:0${second}.000Z`, | |
| 2307 | updatedAt: `2026-04-01T00:00:0${second}.000Z`, | |
| 2308 | })); | |
| 2309 | const thread = makeThread({ | |
| 2310 | id: ThreadId.make("reasoning-group"), | |
| 2311 | projectId: ProjectId.make("project-1"), | |
| 2312 | title: "Reasoning", | |
| 2313 | messages, | |
| 2314 | latestTurn: { | |
| 2315 | turnId, | |
| 2316 | state: "running", | |
| 2317 | requestedAt: "2026-04-01T00:00:00.000Z", | |
| 2318 | startedAt: "2026-04-01T00:00:00.000Z", | |
| 2319 | completedAt: null, | |
| 2320 | assistantMessageId: null, | |
| 2321 | }, | |
| 2322 | }); | |
| 2323 | const feed = buildThreadFeed(thread); | |
| 2324 | const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now"); | |
| 2325 | expect(rows).toMatchObject([ | |
| 2326 | { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 }, | |
| 2327 | ]); | |
| 2328 | expect(rows).toHaveLength(1); | |
| 2329 | expect( | |
| 2330 | deriveThreadFeedPresentation(feed, thread.latestTurn, new Set(), new Set(), "now")[0], | |
| 2331 | ).toBe(rows[0]); | |
| 2332 | ||
| 2333 | const settledTurn = { | |
| 2334 | ...thread.latestTurn!, | |
| 2335 | state: "completed" as const, | |
| 2336 | completedAt: "2026-04-01T00:00:06.000Z", | |
| 2337 | }; | |
| 2338 | // Reasoning alone stays visible, including a streaming flag left behind on settlement. | |
| 2339 | expect(deriveThreadFeedPresentation(feed, settledTurn, new Set())).toMatchObject([ | |
| 2340 | { type: "work-toggle", summary: "Thought (×4)", hiddenCount: 4 }, | |
| 2341 | ]); | |
| 2342 | const completedMessages = messages.map((message) => ({ ...message, streaming: false })); | |
| 2343 | const waiting = deriveThreadFeedPresentation( | |
| 2344 | buildThreadFeed({ ...thread, messages: completedMessages }), | |
| 2345 | thread.latestTurn, | |
| 2346 | new Set(), | |
| 2347 | new Set(), | |
| 2348 | "now", | |
| 2349 | ); | |
| 2350 | expect(waiting).toMatchObject([ | |
| 2351 | { type: "work-toggle", id: "live-activity-row", summary: "Thinking", hiddenCount: 4 }, | |
| 2352 | ]); | |
| 2353 | const feedWithWork = buildThreadFeed({ | |
| 2354 | ...thread, | |
| 2355 | activities: [ | |
| 2356 | makeActivity({ | |
| 2357 | id: EventId.make("reasoning-tool"), | |
| 2358 | kind: "tool.completed", | |
| 2359 | tone: "tool", | |
| 2360 | summary: "Read files", | |
| 2361 | createdAt: "2026-04-01T00:00:05.000Z", | |
| 2362 | turnId, | |
| 2363 | payload: { itemType: "file_read", status: "completed" }, | |
| 2364 | }), | |
| 2365 | ], | |
| 2366 | }); | |
| 2367 | const toolRunning = deriveThreadFeedPresentation( | |
| 2368 | feedWithWork, | |
| 2369 | thread.latestTurn, | |
| 2370 | new Set(), | |
| 2371 | new Set(), | |
| 2372 | "now", | |
| 2373 | ); | |
| 2374 | expect(toolRunning).toMatchObject([ | |
| 2375 | { type: "work-toggle", id: "live-activity-row", shimmer: true, hiddenCount: 5 }, | |
| 2376 | ]); | |
| 2377 | expect(toolRunning[0]).not.toMatchObject({ summary: "Thinking" }); | |
| 2378 | const nextThought = { | |
| 2379 | ...messages[3]!, | |
| 2380 | id: MessageId.make("reasoning-after-tool"), | |
| 2381 | createdAt: "2026-04-01T00:00:06.000Z", | |
| 2382 | updatedAt: "2026-04-01T00:00:06.000Z", | |
| 2383 | }; | |
| 2384 | const reasoningAgainFeed: ThreadFeedEntry[] = [ | |
| 2385 | ...feedWithWork, | |
| 2386 | { | |
| 2387 | type: "message", | |
| 2388 | id: nextThought.id, | |
| 2389 | createdAt: nextThought.createdAt, | |
| 2390 | message: nextThought, | |
| 2391 | }, | |
| 2392 | ]; | |
| 2393 | const reasoningAgain = deriveThreadFeedPresentation( | |
| 2394 | reasoningAgainFeed, | |
| 2395 | thread.latestTurn, | |
| 2396 | new Set(), | |
| 2397 | new Set(), | |
| 2398 | "now", | |
| 2399 | ); | |
| 2400 | expect(reasoningAgain.filter((row) => row.id === "live-activity-row")).toMatchObject([ | |
| 2401 | { type: "work-toggle", summary: "Thinking", hiddenCount: 6 }, | |
| 2402 | ]); | |
| 2403 | expect(reasoningAgain).toHaveLength(1); | |
| 2404 | const expandedLive = deriveThreadFeedPresentation( | |
| 2405 | reasoningAgainFeed, | |
| 2406 | thread.latestTurn, | |
| 2407 | new Set(), | |
| 2408 | new Set([`activity-run:${messages[0]!.id}`]), | |
| 2409 | "now", | |
| 2410 | ); | |
| 2411 | expect(expandedLive.map((entry) => entry.type)).toEqual([ | |
| 2412 | "work-toggle", | |
| 2413 | "message", | |
| 2414 | "activity-group", | |
| 2415 | "message", | |
| 2416 | ]); | |
| 2417 | expect(expandedLive[1]).toMatchObject({ reasoningMessages: messages }); | |
| 2418 | expect(expandedLive[3]).toMatchObject({ message: nextThought }); | |
| 2419 | expect(deriveThreadFeedPresentation(feedWithWork, settledTurn, new Set())).toMatchObject([ | |
| 2420 | { type: "turn-fold", expanded: false }, | |
| 2421 | ]); | |
| 2422 | const reopened = deriveThreadFeedPresentation( | |
| 2423 | feedWithWork, | |
| 2424 | settledTurn, | |
| 2425 | new Set([turnId]), | |
| 2426 | new Set([`activity-run:${messages[0]!.id}`]), | |
| 2427 | ); | |
| 2428 | expect(reopened.map((entry) => entry.type)).toEqual([ | |
| 2429 | "turn-fold", | |
| 2430 | "work-toggle", | |
| 2431 | "message", | |
| 2432 | "activity-group", | |
| 2433 | ]); | |
| 2434 | expect(reopened[2]).toMatchObject({ id: messages[0]!.id, reasoningMessages: messages }); | |
| 2435 | const toolFirstFeed = feedWithWork.filter((entry) => entry.type === "activity-group"); | |
| 2436 | const toolFirst = deriveThreadFeedPresentation( | |
| 2437 | toolFirstFeed, | |
| 2438 | thread.latestTurn, | |
| 2439 | new Set(), | |
| 2440 | new Set(), | |
| 2441 | "now", | |
| 2442 | )[0]; | |
| 2443 | expect(toolFirst?.type).toBe("work-toggle"); | |
| 2444 | if (toolFirst?.type !== "work-toggle") return; | |
| 2445 | const preservedExpansion = deriveThreadFeedPresentation( | |
| 2446 | [...toolFirstFeed, reasoningAgainFeed.at(-1)!], | |
| 2447 | thread.latestTurn, | |
| 2448 | new Set(), | |
| 2449 | new Set([toolFirst.groupId]), | |
| 2450 | "now", | |
| 2451 | ); | |
| 2452 | expect(preservedExpansion.map((entry) => entry.type)).toEqual([ | |
| 2453 | "work-toggle", | |
| 2454 | "activity-group", | |
| 2455 | "message", | |
| 2456 | ]); | |
| 2457 | expect(preservedExpansion[0]).toMatchObject({ | |
| 2458 | id: "live-activity-row", | |
| 2459 | groupId: toolFirst.groupId, | |
| 2460 | expanded: true, | |
| 2461 | summary: "Thinking", | |
| 2462 | }); | |
| 2463 | const strandedToolFeed = buildThreadFeed({ | |
| 2464 | ...thread, | |
| 2465 | messages: [], | |
| 2466 | activities: [ | |
| 2467 | makeActivity({ | |
| 2468 | id: EventId.make("stranded-tool"), | |
| 2469 | kind: "tool.updated", | |
| 2470 | tone: "tool", | |
| 2471 | summary: "Running command", | |
| 2472 | createdAt: "2026-04-01T00:00:00.000Z", | |
| 2473 | turnId, | |
| 2474 | payload: { | |
| 2475 | toolCallId: "stranded-tool", | |
| 2476 | itemType: "command_execution", | |
| 2477 | command: "sleep 60", | |
| 2478 | status: "inProgress", | |
| 2479 | }, | |
| 2480 | }), | |
| 2481 | ], | |
| 2482 | }); | |
| 2483 | const afterStrandedTool = deriveThreadFeedPresentation( | |
| 2484 | [...strandedToolFeed, ...feedWithWork], | |
| 2485 | thread.latestTurn, | |
| 2486 | new Set(), | |
| 2487 | new Set(), | |
| 2488 | "now", | |
| 2489 | ); | |
| 2490 | expect(afterStrandedTool).toMatchObject([ | |
| 2491 | { type: "work-toggle", id: "live-activity-row", summary: toolFirst.summary, hiddenCount: 6 }, | |
| 2492 | ]); | |
| 2493 | }); | |
| 2494 | ||
| 2495 | it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)( | |
| 2496 | "preserves a %s boundary in expanded activity history", | |
| 2497 | (boundary) => { | |
| 2498 | const turnId = TurnId.make("reasoning-boundary"); | |
| 2499 | const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({ | |
| 2500 | id: MessageId.make(`reasoning-${second}`), | |
| 2501 | role: "reasoning", | |
| 2502 | text: `Step ${second}`, | |
| 2503 | turnId: | |
| 2504 | boundary === "unknown-turn" | |
| 2505 | ? null | |
| 2506 | : boundary === "turn" && second === 3 | |
| 2507 | ? TurnId.make("other-turn") | |
| 2508 | : turnId, | |
| 2509 | streaming: false, | |
| 2510 | createdAt: `2026-04-01T00:00:0${second}.000Z`, | |
| 2511 | updatedAt: `2026-04-01T00:00:0${second}.000Z`, | |
| 2512 | })); | |
| 2513 | const thread = makeThread({ | |
| 2514 | id: ThreadId.make("reasoning-boundary"), | |
| 2515 | projectId: ProjectId.make("project-1"), | |
| 2516 | title: "Reasoning", | |
| 2517 | messages: | |
| 2518 | boundary === "assistant" | |
| 2519 | ? [ | |
| 2520 | messages[0]!, | |
| 2521 | { | |
| 2522 | ...messages[0]!, | |
| 2523 | id: MessageId.make("assistant-between"), | |
| 2524 | role: "assistant", | |
| 2525 | text: "Checking the next file.", | |
| 2526 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 2527 | }, | |
| 2528 | messages[1]!, | |
| 2529 | ] | |
| 2530 | : messages, | |
| 2531 | activities: | |
| 2532 | boundary === "tool" || boundary === "failed-tool" | |
| 2533 | ? [ | |
| 2534 | makeActivity({ | |
| 2535 | id: EventId.make("tool-between"), | |
| 2536 | kind: "tool.completed", | |
| 2537 | tone: "tool", | |
| 2538 | summary: "Read files", | |
| 2539 | createdAt: "2026-04-01T00:00:02.000Z", | |
| 2540 | turnId, | |
| 2541 | payload: { | |
| 2542 | itemType: "file_read", | |
| 2543 | status: boundary === "failed-tool" ? "failed" : "completed", | |
| 2544 | }, | |
| 2545 | }), | |
| 2546 | ] | |
| 2547 | : [], | |
| 2548 | }); | |
| 2549 | const rows = deriveThreadFeedPresentation( | |
| 2550 | buildThreadFeed(thread), | |
| 2551 | null, | |
| 2552 | new Set([turnId]), | |
| 2553 | new Set(messages.map((message) => `activity-run:${message.id}`)), | |
| 2554 | ); | |
| 2555 | const reasoningRows = rows.filter( | |
| 2556 | (entry) => entry.type === "message" && entry.message.role === "reasoning", | |
| 2557 | ); | |
| 2558 | if (boundary === "failed-tool") { | |
| 2559 | expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3); | |
| 2560 | expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true); | |
| 2561 | } | |
| 2562 | expect(reasoningRows).toEqual( | |
| 2563 | messages.map((message) => ({ | |
| 2564 | type: "message", | |
| 2565 | id: message.id, | |
| 2566 | createdAt: message.createdAt, | |
| 2567 | message, | |
| 2568 | })), | |
| 2569 | ); | |
| 2570 | }, | |
| 2571 | ); | |
| 2572 | ||
| 2298 | 2573 | it("shows one Thinking row while a turn works without live tool activity", () => { |
| 2299 | 2574 | const turnId = TurnId.make("turn-thinking"); |
| 2300 | 2575 | const latestTurn = { |
apps/mobile/src/lib/threadActivity.ts+219-27| ... | ... | @@ -148,7 +148,9 @@ type RawThreadFeedEntry = |
| 148 | 148 | }; |
| 149 | 149 | |
| 150 | 150 | export type ThreadFeedEntry = |
| 151 | | Extract<RawThreadFeedEntry, { type: "message" }> | |
| 151 | | (Extract<RawThreadFeedEntry, { type: "message" }> & { | |
| 152 | readonly reasoningMessages?: OrchestrationThread["messages"]; | |
| 153 | }) | |
| 152 | 154 | | { |
| 153 | 155 | readonly type: "activity-group"; |
| 154 | 156 | readonly id: string; |
| ... | ... | @@ -168,7 +170,7 @@ export type ThreadFeedEntry = |
| 168 | 170 | readonly summaryKind: ToolGroupSummaryKind; |
| 169 | 171 | readonly toolSurface?: WorkLogEntry["toolSurface"]; |
| 170 | 172 | readonly toolIcon?: WorkLogEntry["toolIcon"]; |
| 171 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; | |
| 173 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; | |
| 172 | 174 | readonly hasFailure: boolean; |
| 173 | 175 | readonly live: boolean; |
| 174 | 176 | readonly shimmer: boolean; |
| ... | ... | @@ -254,6 +256,18 @@ const turnFoldRowsCache = new WeakMap< |
| 254 | 256 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> |
| 255 | 257 | >(); |
| 256 | 258 | let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; |
| 259 | const reasoningGroupsCache = new WeakMap< | |
| 260 | ThreadFeedEntry, | |
| 261 | Extract<ThreadFeedEntry, { readonly type: "message" }> | |
| 262 | >(); | |
| 263 | const activityRunsCache = new WeakMap< | |
| 264 | ThreadFeedEntry, | |
| 265 | { | |
| 266 | readonly source: ReadonlyArray<ThreadFeedEntry>; | |
| 267 | readonly state: string; | |
| 268 | readonly rows: ThreadFeedEntry[]; | |
| 269 | } | |
| 270 | >(); | |
| 257 | 271 | |
| 258 | 272 | export function isContextCompactionActivityGroup( |
| 259 | 273 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| ... | ... | @@ -1783,7 +1797,8 @@ export function deriveThreadFeedPresentation( |
| 1783 | 1797 | } |
| 1784 | 1798 | |
| 1785 | 1799 | const result: ThreadFeedEntry[] = []; |
| 1786 | for (const entry of sourceFeed) { | |
| 1800 | for (let index = 0; index < sourceFeed.length; index += 1) { | |
| 1801 | const entry = sourceFeed[index]!; | |
| 1787 | 1802 | const isActiveTailGroup = |
| 1788 | 1803 | isWorking && |
| 1789 | 1804 | unsettledTurnId !== null && |
| ... | ... | @@ -1815,6 +1830,31 @@ export function deriveThreadFeedPresentation( |
| 1815 | 1830 | result.push(row); |
| 1816 | 1831 | } |
| 1817 | 1832 | if (!collapsedEntryIds.has(entry.id)) { |
| 1833 | const runTurnId = activityRunTurnId(entry); | |
| 1834 | if (runTurnId !== null) { | |
| 1835 | let end = index + 1; | |
| 1836 | while ( | |
| 1837 | end < sourceFeed.length && | |
| 1838 | activityRunTurnId(sourceFeed[end]!) === runTurnId && | |
| 1839 | !collapsedEntryIds.has(sourceFeed[end]!.id) && | |
| 1840 | !foldsByAnchorId.has(sourceFeed[end]!.id) | |
| 1841 | ) { | |
| 1842 | end += 1; | |
| 1843 | } | |
| 1844 | const run = sourceFeed.slice(index, end); | |
| 1845 | if (run.some((row) => row.type === "message")) { | |
| 1846 | appendMixedActivityRun( | |
| 1847 | result, | |
| 1848 | run, | |
| 1849 | expandedWorkGroupIds, | |
| 1850 | unsettledTurnId, | |
| 1851 | isWorking, | |
| 1852 | run.at(-1) === activeTailGroup, | |
| 1853 | ); | |
| 1854 | index = end - 1; | |
| 1855 | continue; | |
| 1856 | } | |
| 1857 | } | |
| 1818 | 1858 | appendPresentedFeedEntry( |
| 1819 | 1859 | result, |
| 1820 | 1860 | entry, |
| ... | ... | @@ -1835,15 +1875,7 @@ export function deriveThreadFeedPresentation( |
| 1835 | 1875 | !result.some( |
| 1836 | 1876 | (row) => |
| 1837 | 1877 | (row.type === "work-toggle" && row.shimmer) || |
| 1838 | // A live thinking block is the real version of this row, so it takes | |
| 1839 | // the slot instead of sitting under a second "Thinking". Scoped to the | |
| 1840 | // live turn: a block stranded by a killed server must not silence this | |
| 1841 | // row for every turn that follows. | |
| 1842 | (row.type === "message" && | |
| 1843 | row.message.role === "reasoning" && | |
| 1844 | row.message.streaming && | |
| 1845 | row.message.turnId !== null && | |
| 1846 | row.message.turnId === unsettledTurnId) || | |
| 1878 | row.id === LIVE_ACTIVITY_ROW_ID || | |
| 1847 | 1879 | // A working spawn card is the live activity: its status line shows |
| 1848 | 1880 | // what the agents are doing, so a Thinking row under it would lie. |
| 1849 | 1881 | (row.type === "agent-spawn" && |
| ... | ... | @@ -1856,6 +1888,157 @@ export function deriveThreadFeedPresentation( |
| 1856 | 1888 | return result; |
| 1857 | 1889 | } |
| 1858 | 1890 | |
| 1891 | function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null { | |
| 1892 | if (entry.type === "message" && entry.message.role === "reasoning") { | |
| 1893 | return entry.message.turnId; | |
| 1894 | } | |
| 1895 | if ( | |
| 1896 | entry.type === "activity-group" && | |
| 1897 | !isContextCompactionActivityGroup(entry) && | |
| 1898 | !isUserInputActivityGroup(entry) && | |
| 1899 | entry.activities.every( | |
| 1900 | (activity) => | |
| 1901 | !activity.workEntry.agentSpawn && | |
| 1902 | activity.workEntry.tone !== "error" && | |
| 1903 | !workEntryIndicatesToolFailure(activity.workEntry), | |
| 1904 | ) | |
| 1905 | ) { | |
| 1906 | return entry.turnId; | |
| 1907 | } | |
| 1908 | return null; | |
| 1909 | } | |
| 1910 | ||
| 1911 | function appendMixedActivityRun( | |
| 1912 | result: ThreadFeedEntry[], | |
| 1913 | run: ReadonlyArray<ThreadFeedEntry>, | |
| 1914 | expandedWorkGroupIds: ReadonlySet<string>, | |
| 1915 | unsettledTurnId: TurnId | null, | |
| 1916 | isWorking: boolean, | |
| 1917 | activeTail: boolean, | |
| 1918 | ) { | |
| 1919 | const first = run[0]!; | |
| 1920 | const last = run.at(-1)!; | |
| 1921 | const turnId = activityRunTurnId(first); | |
| 1922 | const live = isWorking && activeTail && turnId === unsettledTurnId; | |
| 1923 | const firstTool = | |
| 1924 | first.type === "activity-group" | |
| 1925 | ? visibleActivityGroupEntries(first, unsettledTurnId, isWorking)[0] | |
| 1926 | : undefined; | |
| 1927 | const groupId = firstTool ? toolActivityGroupId(firstTool) : `activity-run:${first.id}`; | |
| 1928 | const expanded = expandedWorkGroupIds.has(groupId); | |
| 1929 | const state = `${isWorking}:${unsettledTurnId}:${activeTail}:${expanded}`; | |
| 1930 | const cached = activityRunsCache.get(first); | |
| 1931 | if ( | |
| 1932 | cached?.state === state && | |
| 1933 | cached.source.length === run.length && | |
| 1934 | run.every((entry, index) => entry === cached.source[index]) | |
| 1935 | ) { | |
| 1936 | result.push(...cached.rows); | |
| 1937 | return; | |
| 1938 | } | |
| 1939 | const outputStart = result.length; | |
| 1940 | const history = groupConsecutiveReasoningMessages(run).map((entry) => | |
| 1941 | entry.type === "activity-group" | |
| 1942 | ? { | |
| 1943 | ...entry, | |
| 1944 | activities: visibleActivityGroupEntries(entry, unsettledTurnId, isWorking).map( | |
| 1945 | (activity) => ({ ...activity, groupedToolDetail: true }), | |
| 1946 | ), | |
| 1947 | } | |
| 1948 | : entry, | |
| 1949 | ); | |
| 1950 | const activities = history.flatMap((entry) => | |
| 1951 | entry.type === "activity-group" ? entry.activities : [], | |
| 1952 | ); | |
| 1953 | const trailingGroup = history.at(-1); | |
| 1954 | // A missing completion before the latest thought must not reclaim the live line. | |
| 1955 | const summaryActivities = | |
| 1956 | live && trailingGroup?.type === "activity-group" ? trailingGroup.activities : activities; | |
| 1957 | const toolRows: ThreadFeedEntry[] = []; | |
| 1958 | if (summaryActivities.length > 0) { | |
| 1959 | appendToolGroupRows( | |
| 1960 | toolRows, | |
| 1961 | { type: "activity-group", id: first.id, createdAt: first.createdAt, turnId, activities }, | |
| 1962 | summaryActivities, | |
| 1963 | new Set(), | |
| 1964 | unsettledTurnId, | |
| 1965 | isWorking, | |
| 1966 | live && last.type === "activity-group", | |
| 1967 | ); | |
| 1968 | } | |
| 1969 | const toolSummary = toolRows.find((entry) => entry.type === "work-toggle"); | |
| 1970 | const thinking = live && (last.type === "message" || !toolSummary?.shimmer); | |
| 1971 | const thoughtCount = run.filter((entry) => entry.type === "message").length; | |
| 1972 | result.push({ | |
| 1973 | type: "work-toggle", | |
| 1974 | id: live ? LIVE_ACTIVITY_ROW_ID : `work-toggle:${groupId}`, | |
| 1975 | createdAt: first.createdAt, | |
| 1976 | turnId, | |
| 1977 | groupId, | |
| 1978 | hiddenCount: activities.length + thoughtCount, | |
| 1979 | expanded, | |
| 1980 | summary: thinking | |
| 1981 | ? "Thinking" | |
| 1982 | : (toolSummary?.summary ?? `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`), | |
| 1983 | summaryKind: toolSummary?.summaryKind ?? "other", | |
| 1984 | ...(thinking || !toolSummary | |
| 1985 | ? { summaryToolIcon: "brain" as const } | |
| 1986 | : { | |
| 1987 | ...(toolSummary.toolSurface ? { toolSurface: toolSummary.toolSurface } : {}), | |
| 1988 | ...(toolSummary.toolIcon ? { toolIcon: toolSummary.toolIcon } : {}), | |
| 1989 | ...(toolSummary.summaryToolIcon ? { summaryToolIcon: toolSummary.summaryToolIcon } : {}), | |
| 1990 | }), | |
| 1991 | hasFailure: toolSummary?.hasFailure ?? false, | |
| 1992 | live, | |
| 1993 | shimmer: live, | |
| 1994 | }); | |
| 1995 | if (expanded) result.push(...history); | |
| 1996 | activityRunsCache.set(first, { source: run, state, rows: result.slice(outputStart) }); | |
| 1997 | } | |
| 1998 | ||
| 1999 | function groupConsecutiveReasoningMessages( | |
| 2000 | feed: ReadonlyArray<ThreadFeedEntry>, | |
| 2001 | ): ThreadFeedEntry[] { | |
| 2002 | const result: ThreadFeedEntry[] = []; | |
| 2003 | for (let index = 0; index < feed.length; index += 1) { | |
| 2004 | const entry = feed[index]!; | |
| 2005 | if (entry.type !== "message" || entry.message.role !== "reasoning" || !entry.message.turnId) { | |
| 2006 | result.push(entry); | |
| 2007 | continue; | |
| 2008 | } | |
| 2009 | const messages = [entry.message]; | |
| 2010 | while (index + 1 < feed.length) { | |
| 2011 | const next = feed[index + 1]!; | |
| 2012 | if ( | |
| 2013 | next.type !== "message" || | |
| 2014 | next.message.role !== "reasoning" || | |
| 2015 | next.message.turnId !== entry.message.turnId | |
| 2016 | ) { | |
| 2017 | break; | |
| 2018 | } | |
| 2019 | messages.push(next.message); | |
| 2020 | index += 1; | |
| 2021 | } | |
| 2022 | if (messages.length === 1) { | |
| 2023 | result.push(entry); | |
| 2024 | continue; | |
| 2025 | } | |
| 2026 | let group = reasoningGroupsCache.get(entry); | |
| 2027 | if ( | |
| 2028 | !group || | |
| 2029 | group.reasoningMessages?.length !== messages.length || | |
| 2030 | !messages.every( | |
| 2031 | (message, messageIndex) => group?.reasoningMessages?.[messageIndex] === message, | |
| 2032 | ) | |
| 2033 | ) { | |
| 2034 | group = { ...entry, reasoningMessages: messages }; | |
| 2035 | reasoningGroupsCache.set(entry, group); | |
| 2036 | } | |
| 2037 | result.push(group); | |
| 2038 | } | |
| 2039 | return result; | |
| 2040 | } | |
| 2041 | ||
| 1859 | 2042 | /** |
| 1860 | 2043 | * Shared by the trailing tool group's live row and the "Thinking" row so the |
| 1861 | 2044 | * list keeps one mounted row for the turn's live slot (mirrors web's |
| ... | ... | @@ -1924,16 +2107,7 @@ function appendActivityGroupRows( |
| 1924 | 2107 | isWorking: boolean, |
| 1925 | 2108 | activeTail: boolean, |
| 1926 | 2109 | ): void { |
| 1927 | const activities = omitSupersededLifecycleMarkers( | |
| 1928 | entry.activities.filter( | |
| 1929 | (activity) => | |
| 1930 | !(activity.toolLike && activity.status === "neutral") || | |
| 1931 | (isWorking && | |
| 1932 | activity.lifecycleStatus === "inProgress" && | |
| 1933 | activity.turnId === unsettledTurnId), | |
| 1934 | ), | |
| 1935 | (activity) => activity.workEntry, | |
| 1936 | ); | |
| 2110 | const activities = visibleActivityGroupEntries(entry, unsettledTurnId, isWorking); | |
| 1937 | 2111 | if (activities.length === 0) { |
| 1938 | 2112 | return; |
| 1939 | 2113 | } |
| ... | ... | @@ -1984,6 +2158,28 @@ function appendActivityGroupRows( |
| 1984 | 2158 | flushGroupableRun(true); |
| 1985 | 2159 | } |
| 1986 | 2160 | |
| 2161 | function visibleActivityGroupEntries( | |
| 2162 | entry: ThreadFeedActivityGroup, | |
| 2163 | unsettledTurnId: TurnId | null, | |
| 2164 | isWorking: boolean, | |
| 2165 | ) { | |
| 2166 | return omitSupersededLifecycleMarkers( | |
| 2167 | entry.activities.filter( | |
| 2168 | (activity) => | |
| 2169 | !(activity.toolLike && activity.status === "neutral") || | |
| 2170 | (isWorking && | |
| 2171 | activity.lifecycleStatus === "inProgress" && | |
| 2172 | activity.turnId === unsettledTurnId), | |
| 2173 | ), | |
| 2174 | (activity) => activity.workEntry, | |
| 2175 | ); | |
| 2176 | } | |
| 2177 | ||
| 2178 | function toolActivityGroupId(activity: ThreadFeedActivity): string { | |
| 2179 | const entry = activity.workEntry; | |
| 2180 | return `work-group:${entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : activity.id}`; | |
| 2181 | } | |
| 2182 | ||
| 1987 | 2183 | function appendToolGroupRows( |
| 1988 | 2184 | result: ThreadFeedEntry[], |
| 1989 | 2185 | sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| ... | ... | @@ -1993,11 +2189,7 @@ function appendToolGroupRows( |
| 1993 | 2189 | isWorking: boolean, |
| 1994 | 2190 | activeTail: boolean, |
| 1995 | 2191 | ): void { |
| 1996 | const firstEntry = activities[0]!.workEntry; | |
| 1997 | const identity = firstEntry.toolCallId | |
| 1998 | ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}` | |
| 1999 | : activities[0]!.id; | |
| 2000 | const groupId = `work-group:${identity}`; | |
| 2192 | const groupId = toolActivityGroupId(activities[0]!); | |
| 2001 | 2193 | const expanded = expandedWorkGroupIds.has(groupId); |
| 2002 | 2194 | const latestActiveActivity = activities.findLast( |
| 2003 | 2195 | (activity) => |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+245-13| ... | ... | @@ -1950,37 +1950,269 @@ describe("deriveMessagesTimelineRows", () => { |
| 1950 | 1950 | }, |
| 1951 | 1951 | }); |
| 1952 | 1952 | |
| 1953 | it("keeps a thought-only turn out of the work fold", () => { | |
| 1953 | it("keeps all thoughts in one activity row as current and earlier traces stream", () => { | |
| 1954 | const entries = [1, 2, 3, 4].map((second) => { | |
| 1955 | const entry = reasoningEntry(`reasoning-${second}`, `2026-01-01T00:00:0${second}Z`, "turn-1"); | |
| 1956 | return { | |
| 1957 | ...entry, | |
| 1958 | message: { | |
| 1959 | ...entry.message, | |
| 1960 | text: `Step ${second}`, | |
| 1961 | streaming: second === 2 || second === 4, | |
| 1962 | }, | |
| 1963 | }; | |
| 1964 | }); | |
| 1965 | const input = { | |
| 1966 | timelineEntries: entries, | |
| 1967 | runningTurnId: TurnId.make("turn-1"), | |
| 1968 | isWorking: true, | |
| 1969 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 1970 | turnDiffSummaries: [], | |
| 1971 | supportsConversationRollback: false, | |
| 1972 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 1973 | const initial = deriveMessagesTimelineRowsWithState(input); | |
| 1974 | expect(initial.rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); | |
| 1975 | expect(initial.rows.at(-1)).toMatchObject({ | |
| 1976 | id: "live-activity-row", | |
| 1977 | entries, | |
| 1978 | expanded: false, | |
| 1979 | active: true, | |
| 1980 | }); | |
| 1981 | expect(deriveMessagesTimelineRowsWithState(input, initial).rows).toBe(initial.rows); | |
| 1982 | const stable = computeStableMessagesTimelineRows(initial.rows, { byId: new Map(), result: [] }); | |
| 1983 | expect(computeStableMessagesTimelineRows(deriveMessagesTimelineRows(input), stable)).toBe( | |
| 1984 | stable, | |
| 1985 | ); | |
| 1986 | ||
| 1987 | for (const index of [3, 1]) { | |
| 1988 | const entry = entries[index]!; | |
| 1989 | const updatedEntry = { ...entry, message: { ...entry.message, text: "Updated trace" } }; | |
| 1990 | const updatedEntries = entries.map((entry, position) => | |
| 1991 | position === index ? updatedEntry : entry, | |
| 1992 | ); | |
| 1993 | const updatedInput = { ...input, timelineEntries: updatedEntries }; | |
| 1994 | const updated = deriveMessagesTimelineRowsWithState(updatedInput, initial); | |
| 1995 | expect(updated.rows).toEqual(deriveMessagesTimelineRows(updatedInput)); | |
| 1996 | const updatedStable = computeStableMessagesTimelineRows(updated.rows, stable); | |
| 1997 | expect(updatedStable.byId.get("live-activity-row")).not.toBe( | |
| 1998 | stable.byId.get("live-activity-row"), | |
| 1999 | ); | |
| 2000 | expect(updatedStable.byId.get("live-activity-row")).toMatchObject({ | |
| 2001 | entries: updatedEntries, | |
| 2002 | }); | |
| 2003 | expect(updatedStable.byId.get("working-indicator-row")).toBe( | |
| 2004 | stable.byId.get("working-indicator-row"), | |
| 2005 | ); | |
| 2006 | expect(initial.rows.at(-1)).toMatchObject({ entries }); | |
| 2007 | } | |
| 2008 | }); | |
| 2009 | ||
| 2010 | it("updates the same collapsed row as tools and thoughts alternate", () => { | |
| 2011 | const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); | |
| 2012 | const current = reasoningEntry("thought-current", "2026-01-01T00:00:02Z", "turn-1"); | |
| 2013 | current.message.streaming = true; | |
| 2014 | const tool = toolEntry("tool-current", "2026-01-01T00:00:03Z", "turn-1"); | |
| 2015 | const runningTool = { | |
| 2016 | ...tool, | |
| 2017 | entry: { ...tool.entry, command: "pwd", toolLifecycleStatus: "inProgress" as const }, | |
| 2018 | }; | |
| 2019 | const next = reasoningEntry("thought-next", "2026-01-01T00:00:04Z", "turn-1"); | |
| 2020 | next.message.streaming = true; | |
| 2021 | const completed = { ...current, message: { ...current.message, streaming: false } }; | |
| 2022 | for (const entries of [ | |
| 2023 | [first, current], | |
| 2024 | [first, current, runningTool], | |
| 2025 | [first, current, runningTool, next], | |
| 2026 | [first, completed], | |
| 2027 | ]) { | |
| 2028 | const input = { | |
| 2029 | timelineEntries: entries, | |
| 2030 | runningTurnId: TurnId.make("turn-1"), | |
| 2031 | isWorking: true, | |
| 2032 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 2033 | turnDiffSummaries: [], | |
| 2034 | supportsConversationRollback: false, | |
| 2035 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 2036 | const rows = deriveMessagesTimelineRows(input); | |
| 2037 | expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); | |
| 2038 | expect(rows.at(-1)).toMatchObject({ | |
| 2039 | id: "live-activity-row", | |
| 2040 | entries, | |
| 2041 | active: true, | |
| 2042 | expanded: false, | |
| 2043 | }); | |
| 2044 | const expanded = deriveMessagesTimelineRows({ | |
| 2045 | ...input, | |
| 2046 | expandedWorkGroupIds: new Set(["activity-group:thought-first"]), | |
| 2047 | }); | |
| 2048 | expect(expanded.at(-1)).toMatchObject({ id: "live-activity-row", entries, expanded: true }); | |
| 2049 | } | |
| 2050 | }); | |
| 2051 | ||
| 2052 | it.each(["assistant", "user", "error", "turn"] as const)( | |
| 2053 | "keeps activity separate across a %s boundary", | |
| 2054 | (boundary) => { | |
| 2055 | const first = reasoningEntry("reasoning-first", "2026-01-01T00:00:01Z", "turn-1"); | |
| 2056 | const last = reasoningEntry( | |
| 2057 | "reasoning-last", | |
| 2058 | "2026-01-01T00:00:03Z", | |
| 2059 | boundary === "turn" ? "turn-2" : "turn-1", | |
| 2060 | ); | |
| 2061 | const answer = answerEntry("answer-between", "2026-01-01T00:00:02Z", "turn-1"); | |
| 2062 | const error = toolEntry("error-between", "2026-01-01T00:00:02Z", "turn-1"); | |
| 2063 | const middle = | |
| 2064 | boundary === "turn" | |
| 2065 | ? [] | |
| 2066 | : boundary === "error" | |
| 2067 | ? [{ ...error, entry: { ...error.entry, tone: "error" as const } }] | |
| 2068 | : [ | |
| 2069 | { | |
| 2070 | ...answer, | |
| 2071 | message: { | |
| 2072 | ...answer.message, | |
| 2073 | role: boundary === "user" ? ("user" as const) : ("assistant" as const), | |
| 2074 | }, | |
| 2075 | }, | |
| 2076 | ]; | |
| 2077 | const rows = deriveMessagesTimelineRows({ | |
| 2078 | timelineEntries: [first, ...middle, last], | |
| 2079 | expandedTurnIds: new Set([TurnId.make("turn-1"), TurnId.make("turn-2")]), | |
| 2080 | isWorking: false, | |
| 2081 | activeTurnStartedAt: null, | |
| 2082 | turnDiffSummaries: [], | |
| 2083 | supportsConversationRollback: false, | |
| 2084 | }); | |
| 2085 | expect(rows.filter((row) => row.kind === "activity-group")).toMatchObject([ | |
| 2086 | { entries: [first], active: false }, | |
| 2087 | { entries: [last], active: false }, | |
| 2088 | ]); | |
| 2089 | if (boundary === "error") { | |
| 2090 | expect(rows).toContainEqual(expect.objectContaining({ kind: "work", id: error.id })); | |
| 2091 | } | |
| 2092 | }, | |
| 2093 | ); | |
| 2094 | ||
| 2095 | it("does not combine thoughts without a known turn", () => { | |
| 2096 | const thoughts = [1, 2].map((second) => | |
| 2097 | reasoningEntry(`unknown-${second}`, `2026-01-01T00:00:0${second}Z`, null), | |
| 2098 | ); | |
| 1954 | 2099 | const rows = deriveMessagesTimelineRows({ |
| 1955 | timelineEntries: [ | |
| 1956 | reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), | |
| 1957 | answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"), | |
| 1958 | ], | |
| 2100 | timelineEntries: thoughts, | |
| 1959 | 2101 | isWorking: false, |
| 1960 | 2102 | activeTurnStartedAt: null, |
| 1961 | 2103 | turnDiffSummaries: [], |
| 1962 | 2104 | supportsConversationRollback: false, |
| 1963 | 2105 | }); |
| 2106 | expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id)); | |
| 2107 | }); | |
| 1964 | 2108 | |
| 2109 | it("keeps a thought-only turn out of the work fold", () => { | |
| 2110 | const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); | |
| 2111 | const rows = deriveMessagesTimelineRows({ | |
| 2112 | timelineEntries: [thought, answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1")], | |
| 2113 | isWorking: false, | |
| 2114 | activeTurnStartedAt: null, | |
| 2115 | turnDiffSummaries: [], | |
| 2116 | supportsConversationRollback: false, | |
| 2117 | }); | |
| 1965 | 2118 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); |
| 1966 | expect(rows.map((row) => row.id)).toContain("reasoning-entry"); | |
| 2119 | expect(rows.find((row) => row.kind === "activity-group")).toMatchObject({ | |
| 2120 | entries: [thought], | |
| 2121 | expanded: false, | |
| 2122 | }); | |
| 1967 | 2123 | }); |
| 1968 | 2124 | |
| 1969 | it("folds a thinking block away with the tool work beside it", () => { | |
| 2125 | it("keeps the assistant footer before a trailing thought-only group", () => { | |
| 2126 | const answer = answerEntry("assistant-entry", "2026-01-01T00:00:01Z", "turn-1"); | |
| 2127 | const thought = reasoningEntry("reasoning-after", "2026-01-01T00:00:02Z", "turn-1"); | |
| 1970 | 2128 | const rows = deriveMessagesTimelineRows({ |
| 2129 | timelineEntries: [answer, thought], | |
| 2130 | isWorking: false, | |
| 2131 | activeTurnStartedAt: null, | |
| 2132 | turnDiffSummaries: [], | |
| 2133 | supportsConversationRollback: false, | |
| 2134 | }); | |
| 2135 | expect(rows.map((row) => row.kind)).toEqual(["message", "activity-group"]); | |
| 2136 | expect(rows[0]).toMatchObject({ message: answer.message, showAssistantMeta: true }); | |
| 2137 | expect(rows[1]).toMatchObject({ entries: [thought] }); | |
| 2138 | }); | |
| 2139 | ||
| 2140 | it("shows each tool once across expanded activity histories separated by a failed tool", () => { | |
| 2141 | const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); | |
| 2142 | const tools = ["a", "b", "c"].map((id, index) => { | |
| 2143 | const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1"); | |
| 2144 | return { | |
| 2145 | ...entry, | |
| 2146 | entry: { | |
| 2147 | ...entry.entry, | |
| 2148 | command: `echo ${id}`, | |
| 2149 | toolCallId: id, | |
| 2150 | toolLifecycleStatus: id === "b" ? ("failed" as const) : ("completed" as const), | |
| 2151 | sourceActivityKind: "tool.completed" as const, | |
| 2152 | }, | |
| 2153 | }; | |
| 2154 | }); | |
| 2155 | const input = { | |
| 2156 | timelineEntries: [thought, ...tools], | |
| 2157 | runningTurnId: TurnId.make("turn-1"), | |
| 2158 | isWorking: true, | |
| 2159 | activeTurnStartedAt: "2026-01-01T00:00:00Z", | |
| 2160 | turnDiffSummaries: [], | |
| 2161 | supportsConversationRollback: false, | |
| 2162 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 2163 | const rows = deriveMessagesTimelineRows(input); | |
| 2164 | const expanded = deriveMessagesTimelineRows({ | |
| 2165 | ...input, | |
| 2166 | expandedWorkGroupIds: new Set(rows.flatMap((row) => ("groupId" in row ? [row.groupId] : []))), | |
| 2167 | }); | |
| 2168 | const visibleTools = expanded.flatMap((row) => | |
| 2169 | row.kind === "activity-group" && row.expanded | |
| 2170 | ? row.entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry.id] : [])) | |
| 2171 | : row.kind === "work" | |
| 2172 | ? row.groupedEntries.map((entry) => entry.id) | |
| 2173 | : [], | |
| 2174 | ); | |
| 2175 | expect(visibleTools).toEqual(["a", "b", "c"]); | |
| 2176 | expect(expanded.filter((row) => row.id === "live-activity-row")).toMatchObject([ | |
| 2177 | { kind: "work-live", entry: { id: "c" } }, | |
| 2178 | ]); | |
| 2179 | }); | |
| 2180 | ||
| 2181 | it("folds mixed activity under worked-for and restores ordered details when expanded", () => { | |
| 2182 | const entries = [ | |
| 2183 | reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), | |
| 2184 | toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), | |
| 2185 | reasoningEntry("reasoning-next", "2026-01-01T00:00:03Z", "turn-1"), | |
| 2186 | ]; | |
| 2187 | const input = { | |
| 1971 | 2188 | timelineEntries: [ |
| 1972 | reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), | |
| 1973 | toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), | |
| 1974 | answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"), | |
| 2189 | ...entries, | |
| 2190 | answerEntry("assistant-entry", "2026-01-01T00:00:04Z", "turn-1"), | |
| 1975 | 2191 | ], |
| 1976 | 2192 | isWorking: false, |
| 1977 | 2193 | activeTurnStartedAt: null, |
| 1978 | 2194 | turnDiffSummaries: [], |
| 1979 | 2195 | supportsConversationRollback: false, |
| 2196 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 2197 | const rows = deriveMessagesTimelineRows(input); | |
| 2198 | expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); | |
| 2199 | const expanded = deriveMessagesTimelineRows({ | |
| 2200 | ...input, | |
| 2201 | expandedTurnIds: new Set([TurnId.make("turn-1")]), | |
| 1980 | 2202 | }); |
| 1981 | ||
| 1982 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); | |
| 1983 | expect(rows.map((row) => row.id)).not.toContain("reasoning-entry"); | |
| 2203 | expect(expanded.filter((row) => row.kind === "activity-group")).toMatchObject([ | |
| 2204 | { entries, expanded: false, active: false }, | |
| 2205 | ]); | |
| 2206 | const details = deriveMessagesTimelineRows({ | |
| 2207 | ...input, | |
| 2208 | expandedTurnIds: new Set([TurnId.make("turn-1")]), | |
| 2209 | expandedWorkGroupIds: new Set(["activity-group:reasoning-entry"]), | |
| 2210 | }); | |
| 2211 | expect(details.find((row) => row.kind === "activity-group")).toMatchObject({ | |
| 2212 | entries, | |
| 2213 | expanded: true, | |
| 2214 | }); | |
| 2215 | expect(deriveMessagesTimelineRows(input)).toEqual(rows); | |
| 1984 | 2216 | }); |
| 1985 | 2217 | |
| 1986 | 2218 | it("still folds a lone trailing tool call when a thought follows the answer", () => { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+99-20| ... | ... | @@ -310,9 +310,32 @@ export type TimelineLatestTurn = Pick< |
| 310 | 310 | "turnId" | "state" | "startedAt" | "completedAt" |
| 311 | 311 | >; |
| 312 | 312 | |
| 313 | const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; | |
| 313 | export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; | |
| 314 | ||
| 315 | type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>; | |
| 316 | ||
| 317 | function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { | |
| 318 | return entry.kind === "message" | |
| 319 | ? entry.message.role === "reasoning" | |
| 320 | : entry.kind === "work" && | |
| 321 | entry.entry.agentSpawn === undefined && | |
| 322 | entry.entry.questionAnswer === undefined && | |
| 323 | entry.entry.sourceActivityKind !== "context-compaction" && | |
| 324 | entry.entry.tone !== "error" && | |
| 325 | !workEntryDisplayIndicatesToolFailure(entry.entry); | |
| 326 | } | |
| 314 | 327 | |
| 315 | 328 | export type MessagesTimelineRow = |
| 329 | | { | |
| 330 | kind: "activity-group"; | |
| 331 | id: string; | |
| 332 | createdAt: string; | |
| 333 | turnId: TurnId; | |
| 334 | groupId: string; | |
| 335 | entries: ActivityEntry[]; | |
| 336 | expanded: boolean; | |
| 337 | active: boolean; | |
| 338 | } | |
| 316 | 339 | | { |
| 317 | 340 | kind: "work"; |
| 318 | 341 | id: string; |
| ... | ... | @@ -366,6 +389,7 @@ export type MessagesTimelineRow = |
| 366 | 389 | createdAt: string; |
| 367 | 390 | message: ChatMessage; |
| 368 | 391 | durationStart: string; |
| 392 | reasoningMessages?: ReadonlyArray<ChatMessage>; | |
| 369 | 393 | showAssistantMeta: boolean; |
| 370 | 394 | showAssistantCopyButton: boolean; |
| 371 | 395 | assistantCopyStreaming: boolean; |
| ... | ... | @@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: { |
| 587 | 611 | return turnIds; |
| 588 | 612 | } |
| 589 | 613 | |
| 590 | function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { | |
| 614 | export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { | |
| 591 | 615 | return ( |
| 592 | 616 | entry.toolLifecycleStatus === "inProgress" || |
| 593 | 617 | (entry.toolLifecycleStatus === undefined && |
| ... | ... | @@ -819,7 +843,12 @@ function attachTrailingToolGroupsToAssistant( |
| 819 | 843 | if (candidate.kind === "message") { |
| 820 | 844 | break; |
| 821 | 845 | } |
| 822 | if (candidate.kind === "work-toggle" && candidate.turnId === turnId) { | |
| 846 | if ( | |
| 847 | (candidate.kind === "work-toggle" || | |
| 848 | (candidate.kind === "activity-group" && | |
| 849 | candidate.entries.some((entry) => entry.kind === "work"))) && | |
| 850 | candidate.turnId === turnId | |
| 851 | ) { | |
| 823 | 852 | hasTrailingToolGroup = true; |
| 824 | 853 | lastTrailingWorkIndex = index; |
| 825 | 854 | continue; |
| ... | ... | @@ -996,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: { |
| 996 | 1025 | break; |
| 997 | 1026 | } |
| 998 | 1027 | activeToolEntries.unshift(entry); |
| 1028 | if (workEntryDisplayIndicatesToolFailure(entry.entry)) break; | |
| 999 | 1029 | } |
| 1000 | 1030 | const visibleActiveToolEntries = omitSupersededLifecycleMarkers( |
| 1001 | 1031 | activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), |
| ... | ... | @@ -1074,6 +1104,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1074 | 1104 | ); |
| 1075 | 1105 | }; |
| 1076 | 1106 | |
| 1107 | let scannedActivityThrough = -1; | |
| 1077 | 1108 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 1078 | 1109 | const timelineEntry = input.timelineEntries[index]; |
| 1079 | 1110 | if (!timelineEntry) { |
| ... | ... | @@ -1104,6 +1135,48 @@ export function deriveMessagesTimelineRows(input: { |
| 1104 | 1135 | continue; |
| 1105 | 1136 | } |
| 1106 | 1137 | |
| 1138 | const activityTurnId = timelineEntryTurnId(timelineEntry); | |
| 1139 | if (index > scannedActivityThrough && activityTurnId && isActivityEntry(timelineEntry)) { | |
| 1140 | const entries = [timelineEntry]; | |
| 1141 | let cursor = index + 1; | |
| 1142 | while (cursor < input.timelineEntries.length) { | |
| 1143 | const next = input.timelineEntries[cursor]!; | |
| 1144 | if ( | |
| 1145 | !isActivityEntry(next) || | |
| 1146 | timelineEntryTurnId(next) !== activityTurnId || | |
| 1147 | collapsedEntryIds.has(next.id) || | |
| 1148 | foldsByAnchorEntryId.has(next.id) | |
| 1149 | ) | |
| 1150 | break; | |
| 1151 | entries.push(next); | |
| 1152 | cursor += 1; | |
| 1153 | } | |
| 1154 | scannedActivityThrough = cursor - 1; | |
| 1155 | if (entries.some((entry) => entry.kind === "message")) { | |
| 1156 | const active = | |
| 1157 | input.isWorking && | |
| 1158 | activityTurnId === unsettledTurnId && | |
| 1159 | cursor === input.timelineEntries.length; | |
| 1160 | const groupId = | |
| 1161 | timelineEntry.kind === "work" | |
| 1162 | ? workGroupId(timelineEntry.id, timelineEntry.entry) | |
| 1163 | : `activity-group:${timelineEntry.id}`; | |
| 1164 | nextRows.push({ | |
| 1165 | kind: "activity-group", | |
| 1166 | id: active ? LIVE_ACTIVITY_ROW_ID : groupId, | |
| 1167 | createdAt: timelineEntry.createdAt, | |
| 1168 | turnId: activityTurnId, | |
| 1169 | groupId, | |
| 1170 | entries, | |
| 1171 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, | |
| 1172 | active, | |
| 1173 | }); | |
| 1174 | hasActivityRow ||= active; | |
| 1175 | index = cursor - 1; | |
| 1176 | continue; | |
| 1177 | } | |
| 1178 | } | |
| 1179 | ||
| 1107 | 1180 | if (activeWorkEntryIds.has(timelineEntry.id)) { |
| 1108 | 1181 | continue; |
| 1109 | 1182 | } |
| ... | ... | @@ -1364,29 +1437,13 @@ export function deriveMessagesTimelineRows(input: { |
| 1364 | 1437 | ); |
| 1365 | 1438 | } |
| 1366 | 1439 | } |
| 1367 | // A live thinking block is the real version of the placeholder below, so it | |
| 1368 | // suppresses it rather than sitting under a second "Thinking" row. | |
| 1369 | const hasStreamingReasoningRow = nextRows.some( | |
| 1370 | (row) => | |
| 1371 | row.kind === "message" && | |
| 1372 | row.message.role === "reasoning" && | |
| 1373 | row.message.streaming && | |
| 1374 | row.message.turnId !== null && | |
| 1375 | row.message.turnId === unsettledTurnId, | |
| 1376 | ); | |
| 1377 | ||
| 1378 | 1440 | // A running setup owns the working slot above its card and shows no |
| 1379 | 1441 | // activity row of its own; every other state gets the usual tail. |
| 1380 | 1442 | const hasWorkingRow = nextRows.some((row) => row.kind === "working"); |
| 1381 | 1443 | if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1382 | 1444 | appendWorkingRow(); |
| 1383 | 1445 | } |
| 1384 | if ( | |
| 1385 | input.isWorking && | |
| 1386 | !setupRunning && | |
| 1387 | !hasStreamingReasoningRow && | |
| 1388 | (!hasActivityRow || latestToolFailed) | |
| 1389 | ) { | |
| 1446 | if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { | |
| 1390 | 1447 | nextRows.push({ |
| 1391 | 1448 | kind: "thinking", |
| 1392 | 1449 | id: LIVE_ACTIVITY_ROW_ID, |
| ... | ... | @@ -1468,6 +1525,18 @@ function replaceStreamingMessageRows( |
| 1468 | 1525 | } |
| 1469 | 1526 | if (replacements.size === 0) return previous.rows; |
| 1470 | 1527 | return previous.rows.map((row) => { |
| 1528 | if (row.kind === "activity-group") { | |
| 1529 | if (!row.entries.some((entry) => entry.kind === "message" && replacements.has(entry.message))) | |
| 1530 | return row; | |
| 1531 | return { | |
| 1532 | ...row, | |
| 1533 | entries: row.entries.map((entry) => { | |
| 1534 | if (entry.kind !== "message") return entry; | |
| 1535 | const message = replacements.get(entry.message); | |
| 1536 | return message ? { ...entry, message } : entry; | |
| 1537 | }), | |
| 1538 | }; | |
| 1539 | } | |
| 1471 | 1540 | if (row.kind !== "message" && row.kind !== "assistant-meta") return row; |
| 1472 | 1541 | const message = replacements.get(row.message); |
| 1473 | 1542 | return message ? { ...row, message } : row; |
| ... | ... | @@ -1512,6 +1581,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1512 | 1581 | if (a.kind !== b.kind || a.id !== b.id) return false; |
| 1513 | 1582 | |
| 1514 | 1583 | switch (a.kind) { |
| 1584 | case "activity-group": { | |
| 1585 | const group = b as typeof a; | |
| 1586 | return ( | |
| 1587 | a.active === group.active && | |
| 1588 | a.expanded === group.expanded && | |
| 1589 | a.groupId === group.groupId && | |
| 1590 | a.entries.length === group.entries.length && | |
| 1591 | a.entries.every((entry, index) => entry === group.entries[index]) | |
| 1592 | ); | |
| 1593 | } | |
| 1515 | 1594 | case "working": |
| 1516 | 1595 | case "thinking": |
| 1517 | 1596 | return a.createdAt === (b as typeof a).createdAt; |
apps/web/src/components/chat/MessagesTimeline.tsx+147-26| ... | ... | @@ -29,6 +29,8 @@ import { |
| 29 | 29 | resolveWorkEntryToolPresentation, |
| 30 | 30 | resolveViewedImageAsset, |
| 31 | 31 | workEntryViewedImagePath, |
| 32 | summarizeToolGroup, | |
| 33 | omitSupersededLifecycleMarkers, | |
| 32 | 34 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 33 | 35 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; |
| 34 | 36 | import type { |
| ... | ... | @@ -167,9 +169,11 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis |
| 167 | 169 | import { |
| 168 | 170 | computeStableMessagesTimelineRows, |
| 169 | 171 | deriveMessagesTimelineRowsWithState, |
| 172 | LIVE_ACTIVITY_ROW_ID, | |
| 170 | 173 | deriveUnsettledTurnId, |
| 171 | 174 | type MessagesTimelineRowsProjection, |
| 172 | 175 | liveWorkEntryLabel, |
| 176 | workEntryIsActiveTurnActivity, | |
| 173 | 177 | resolveAssistantMessageCopyState, |
| 174 | 178 | resolveTimelineIsAtEnd, |
| 175 | 179 | resolveTimelineMinimapHasPersistentGutter, |
| ... | ... | @@ -281,7 +285,7 @@ interface TimelineRowSharedState { |
| 281 | 285 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 282 | 286 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; |
| 283 | 287 | onToggleSpawnRow: (entryId: string, expanded: boolean) => void; |
| 284 | onToggleReasoning: (messageId: string, expanded: boolean) => void; | |
| 288 | onToggleReasoning: (messageId: string, expanded: boolean, anchorKey: string) => void; | |
| 285 | 289 | expandedReasoningMessageIds: ReadonlySet<string>; |
| 286 | 290 | workGroupViewState: WorkGroupViewState; |
| 287 | 291 | agentPanelModel: AgentPanelModel; |
| ... | ... | @@ -671,10 +675,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 671 | 675 | [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], |
| 672 | 676 | ); |
| 673 | 677 | const onToggleReasoning = useCallback( |
| 674 | (messageId: string, expanded: boolean) => { | |
| 675 | // The anchor must be the timeline row id, which for a message row is the | |
| 676 | // message id, or position restoration is skipped for every row. | |
| 677 | suspendEndScrollMaintenanceForDisclosure(messageId, !expanded); | |
| 678 | (messageId: string, expanded: boolean, anchorKey: string) => { | |
| 679 | suspendEndScrollMaintenanceForDisclosure(anchorKey, !expanded); | |
| 678 | 680 | setExpandedReasoningMessageIds((current) => { |
| 679 | 681 | if (current.has(messageId) === expanded) return current; |
| 680 | 682 | const next = new Set(current); |
| ... | ... | @@ -1494,6 +1496,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1494 | 1496 | row.kind === "work" || |
| 1495 | 1497 | row.kind === "work-live" || |
| 1496 | 1498 | row.kind === "work-toggle" || |
| 1499 | row.kind === "activity-group" || | |
| 1497 | 1500 | row.kind === "thinking" || |
| 1498 | 1501 | row.kind === "worktree-setup" |
| 1499 | 1502 | ? "pb-2" |
| ... | ... | @@ -1519,6 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1519 | 1522 | /> |
| 1520 | 1523 | ) : null} |
| 1521 | 1524 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| 1525 | {row.kind === "activity-group" ? <ActivityGroupTimelineRow row={row} /> : null} | |
| 1522 | 1526 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} |
| 1523 | 1527 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| 1524 | 1528 | {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null} |
| ... | ... | @@ -2392,6 +2396,108 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap |
| 2392 | 2396 | ); |
| 2393 | 2397 | } |
| 2394 | 2398 | |
| 2399 | function ActivityGroupTimelineRow({ | |
| 2400 | row, | |
| 2401 | }: { | |
| 2402 | row: Extract<TimelineRow, { kind: "activity-group" }>; | |
| 2403 | }) { | |
| 2404 | const ctx = use(TimelineRowCtx); | |
| 2405 | const work = omitSupersededLifecycleMarkers( | |
| 2406 | row.entries.flatMap((entry) => | |
| 2407 | entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) | |
| 2408 | ? [entry.entry] | |
| 2409 | : [], | |
| 2410 | ), | |
| 2411 | (entry) => entry, | |
| 2412 | ); | |
| 2413 | const thoughtCount = row.entries.filter((entry) => entry.kind === "message").length; | |
| 2414 | const lastThoughtIndex = row.entries.findLastIndex((entry) => entry.kind === "message"); | |
| 2415 | const trailingWork = omitSupersededLifecycleMarkers( | |
| 2416 | row.entries | |
| 2417 | .slice(lastThoughtIndex + 1) | |
| 2418 | .flatMap((entry) => | |
| 2419 | entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) | |
| 2420 | ? [entry.entry] | |
| 2421 | : [], | |
| 2422 | ), | |
| 2423 | (entry) => entry, | |
| 2424 | ); | |
| 2425 | const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1); | |
| 2426 | const thinking = row.active && liveWork === undefined; | |
| 2427 | const iconWork = row.active ? liveWork : work.at(-1); | |
| 2428 | const label = row.active | |
| 2429 | ? liveWork | |
| 2430 | ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true) | |
| 2431 | : "Thinking" | |
| 2432 | : work.length > 0 | |
| 2433 | ? summarizeToolGroup(work) | |
| 2434 | : `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`; | |
| 2435 | const details: ReactNode[] = []; | |
| 2436 | if (row.expanded) { | |
| 2437 | for (let index = 0; index < row.entries.length; index += 1) { | |
| 2438 | const entry = row.entries[index]!; | |
| 2439 | if (entry.kind === "work") { | |
| 2440 | const entries = [entry.entry]; | |
| 2441 | while (row.entries[index + 1]?.kind === "work") { | |
| 2442 | const next = row.entries[++index]!; | |
| 2443 | if (next.kind === "work") entries.push(next.entry); | |
| 2444 | } | |
| 2445 | details.push( | |
| 2446 | <WorkGroupSection | |
| 2447 | key={entry.id} | |
| 2448 | anchorKey={entry.id} | |
| 2449 | disclosureAnchorKey={row.id} | |
| 2450 | groupedEntries={omitSupersededLifecycleMarkers(entries, (entry) => entry)} | |
| 2451 | isExpandedToolGroup | |
| 2452 | />, | |
| 2453 | ); | |
| 2454 | } else { | |
| 2455 | const messages = [entry.message]; | |
| 2456 | while (row.entries[index + 1]?.kind === "message") { | |
| 2457 | const next = row.entries[++index]!; | |
| 2458 | if (next.kind === "message") messages.push(next.message); | |
| 2459 | } | |
| 2460 | details.push( | |
| 2461 | <ReasoningTimelineRow | |
| 2462 | key={entry.id} | |
| 2463 | disclosureAnchorKey={row.id} | |
| 2464 | row={{ | |
| 2465 | kind: "message", | |
| 2466 | id: row.active && index === row.entries.length - 1 ? LIVE_ACTIVITY_ROW_ID : entry.id, | |
| 2467 | createdAt: entry.createdAt, | |
| 2468 | message: entry.message, | |
| 2469 | reasoningMessages: messages, | |
| 2470 | durationStart: entry.createdAt, | |
| 2471 | showAssistantMeta: false, | |
| 2472 | showAssistantCopyButton: false, | |
| 2473 | assistantCopyStreaming: false, | |
| 2474 | }} | |
| 2475 | />, | |
| 2476 | ); | |
| 2477 | } | |
| 2478 | } | |
| 2479 | } | |
| 2480 | return ( | |
| 2481 | <div> | |
| 2482 | <button | |
| 2483 | type="button" | |
| 2484 | 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" | |
| 2485 | aria-expanded={row.expanded} | |
| 2486 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | |
| 2487 | > | |
| 2488 | <LiveActivityRow | |
| 2489 | label={label} | |
| 2490 | iconName={iconWork ? workEntryIconName(iconWork) : "brain"} | |
| 2491 | toolIcon={iconWork?.toolIcon ?? iconWork?.toolSource?.icon} | |
| 2492 | active={row.active} | |
| 2493 | shimmer={thinking} | |
| 2494 | /> | |
| 2495 | </button> | |
| 2496 | {row.expanded ? <div className="mt-2 space-y-2">{details}</div> : null} | |
| 2497 | </div> | |
| 2498 | ); | |
| 2499 | } | |
| 2500 | ||
| 2395 | 2501 | function ThinkingTimelineRow() { |
| 2396 | 2502 | const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); |
| 2397 | 2503 | // Reserve the activity row during setup so the handoff keeps the same height. |
| ... | ... | @@ -2411,28 +2517,35 @@ function ThinkingTimelineRow() { |
| 2411 | 2517 | */ |
| 2412 | 2518 | const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ |
| 2413 | 2519 | row, |
| 2520 | disclosureAnchorKey = row.id, | |
| 2414 | 2521 | }: { |
| 2415 | 2522 | row: Extract<TimelineRow, { kind: "message" }>; |
| 2523 | disclosureAnchorKey?: string; | |
| 2416 | 2524 | }) { |
| 2417 | 2525 | const ctx = use(TimelineRowCtx); |
| 2418 | 2526 | const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); |
| 2419 | 2527 | const { message } = row; |
| 2528 | const messages = row.reasoningMessages ?? [message]; | |
| 2420 | 2529 | // A block left open by a crashed provider or a restarted server never gets |
| 2421 | 2530 | // its completion. Only the live turn may claim to still be thinking, so a |
| 2422 | 2531 | // settled turn cannot shimmer "Thinking" at the user forever. |
| 2423 | 2532 | const streaming = |
| 2424 | Boolean(message.streaming) && | |
| 2533 | row.id === LIVE_ACTIVITY_ROW_ID && | |
| 2534 | messages.some((reasoningMessage) => reasoningMessage.streaming) && | |
| 2425 | 2535 | isWorking && |
| 2426 | 2536 | message.turnId !== null && |
| 2427 | 2537 | message.turnId === unsettledTurnId; |
| 2428 | 2538 | const expanded = ctx.expandedReasoningMessageIds.has(message.id); |
| 2429 | 2539 | const { onToggleReasoning } = ctx; |
| 2430 | 2540 | const toggle = useCallback(() => { |
| 2431 | onToggleReasoning(message.id, !expanded); | |
| 2432 | }, [expanded, message.id, onToggleReasoning]); | |
| 2433 | const label = streaming ? "Thinking" : "Thought"; | |
| 2541 | onToggleReasoning(message.id, !expanded, disclosureAnchorKey); | |
| 2542 | }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]); | |
| 2543 | const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`; | |
| 2434 | 2544 | |
| 2435 | if (message.text.trim().length === 0 && !streaming) { | |
| 2545 | if ( | |
| 2546 | messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) && | |
| 2547 | !streaming | |
| 2548 | ) { | |
| 2436 | 2549 | return null; |
| 2437 | 2550 | } |
| 2438 | 2551 | |
| ... | ... | @@ -2466,18 +2579,21 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ |
| 2466 | 2579 | </span> |
| 2467 | 2580 | </button> |
| 2468 | 2581 | {expanded ? ( |
| 2469 | <div className="mt-1 ms-7 max-h-96 overflow-auto rounded-md bg-muted/40 px-3 py-2 text-secondary-label select-text"> | |
| 2470 | <ChatMarkdown | |
| 2471 | text={message.text} | |
| 2472 | cwd={ctx.markdownCwd} | |
| 2473 | threadRef={ctx.threadRef ?? undefined} | |
| 2474 | isStreaming={streaming} | |
| 2475 | lineBreaks | |
| 2476 | skills={ctx.skills} | |
| 2477 | headingLevelOffset={MESSAGE_HEADING_LEVEL} | |
| 2478 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} | |
| 2479 | onImageExpand={ctx.onImageExpand} | |
| 2480 | /> | |
| 2582 | <div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto rounded-md bg-muted/40 px-3 py-2 text-secondary-label select-text"> | |
| 2583 | {messages.map((reasoningMessage) => ( | |
| 2584 | <ChatMarkdown | |
| 2585 | key={reasoningMessage.id} | |
| 2586 | text={reasoningMessage.text} | |
| 2587 | cwd={ctx.markdownCwd} | |
| 2588 | threadRef={ctx.threadRef ?? undefined} | |
| 2589 | isStreaming={streaming && reasoningMessage.streaming} | |
| 2590 | lineBreaks | |
| 2591 | skills={ctx.skills} | |
| 2592 | headingLevelOffset={MESSAGE_HEADING_LEVEL} | |
| 2593 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} | |
| 2594 | onImageExpand={ctx.onImageExpand} | |
| 2595 | /> | |
| 2596 | ))} | |
| 2481 | 2597 | </div> |
| 2482 | 2598 | ) : null} |
| 2483 | 2599 | </div> |
| ... | ... | @@ -2529,19 +2645,21 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 2529 | 2645 | /** Renders standalone activity or one bounded, virtualized expanded tool group. */ |
| 2530 | 2646 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 2531 | 2647 | anchorKey, |
| 2648 | disclosureAnchorKey = anchorKey, | |
| 2532 | 2649 | groupedEntries, |
| 2533 | 2650 | isExpandedToolGroup, |
| 2534 | 2651 | displayLabel, |
| 2535 | 2652 | }: { |
| 2536 | 2653 | anchorKey: string; |
| 2654 | disclosureAnchorKey?: string; | |
| 2537 | 2655 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 2538 | 2656 | isExpandedToolGroup: boolean; |
| 2539 | 2657 | displayLabel?: string | undefined; |
| 2540 | 2658 | }) { |
| 2541 | 2659 | const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); |
| 2542 | 2660 | const onToggleStandaloneEntry = useCallback( |
| 2543 | (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), | |
| 2544 | [anchorKey, onToggleWorkEntry], | |
| 2661 | (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), | |
| 2662 | [disclosureAnchorKey, onToggleWorkEntry], | |
| 2545 | 2663 | ); |
| 2546 | 2664 | const nonEmptyEntries = useMemo( |
| 2547 | 2665 | () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), |
| ... | ... | @@ -2554,6 +2672,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 2554 | 2672 | <ExpandedWorkGroupEntries |
| 2555 | 2673 | key={`${routeThreadKey}:${anchorKey}`} |
| 2556 | 2674 | anchorKey={anchorKey} |
| 2675 | disclosureAnchorKey={disclosureAnchorKey} | |
| 2557 | 2676 | entries={nonEmptyEntries} |
| 2558 | 2677 | workspaceRoot={workspaceRoot} |
| 2559 | 2678 | /> |
| ... | ... | @@ -2580,10 +2699,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 2580 | 2699 | |
| 2581 | 2700 | function ExpandedWorkGroupEntries({ |
| 2582 | 2701 | anchorKey, |
| 2702 | disclosureAnchorKey, | |
| 2583 | 2703 | entries, |
| 2584 | 2704 | workspaceRoot, |
| 2585 | 2705 | }: { |
| 2586 | 2706 | anchorKey: string; |
| 2707 | disclosureAnchorKey: string; | |
| 2587 | 2708 | entries: TimelineWorkEntry[]; |
| 2588 | 2709 | workspaceRoot: string | undefined; |
| 2589 | 2710 | }) { |
| ... | ... | @@ -2609,9 +2730,9 @@ function ExpandedWorkGroupEntries({ |
| 2609 | 2730 | const groupView = useMemo( |
| 2610 | 2731 | () => ({ |
| 2611 | 2732 | state: viewState, |
| 2612 | onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), | |
| 2733 | onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), | |
| 2613 | 2734 | }), |
| 2614 | [anchorKey, onToggleWorkEntry, viewState], | |
| 2735 | [disclosureAnchorKey, onToggleWorkEntry, viewState], | |
| 2615 | 2736 | ); |
| 2616 | 2737 | const updateScrollFades = useCallback(() => { |
| 2617 | 2738 | const element = listRef.current?.getScrollableNode(); |