| 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,33 +1476,31 @@ function renderFeedEntry( |
| 1476 | if (entry.type === "message") { | 1476 | if (entry.type === "message") { |
| 1477 | const { message } = entry; | 1477 | const { message } = entry; |
| 1478 | if (message.role === "reasoning") { | 1478 | if (message.role === "reasoning") { |
| 1479 | // Only the live turn may claim to still be thinking, and only while the | 1479 | const messages = entry.reasoningMessages ?? [message]; |
| 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; | ||
| 1487 | return ( | 1480 | return ( |
| 1488 | <ThreadReasoningRow | 1481 | <ThreadReasoningRow |
| 1489 | rowSizing={props.workRowSizing} | 1482 | rowSizing={props.workRowSizing} |
| 1490 | iconSubtleColor={iconSubtleColor} | 1483 | iconSubtleColor={iconSubtleColor} |
| 1491 | expanded={props.expandedReasoningMessageIds.has(message.id)} | 1484 | expanded={props.expandedReasoningMessageIds.has(entry.id)} |
| 1492 | label={liveReasoning ? "Thinking" : "Thought"} | 1485 | label={`Thought${messages.length > 1 ? ` (×${messages.length})` : ""}`} |
| 1493 | streaming={liveReasoning} | 1486 | streaming={false} |
| 1494 | onToggle={() => props.onToggleReasoning(message.id)} | 1487 | onToggle={() => props.onToggleReasoning(entry.id)} |
| 1495 | > | 1488 | > |
| 1496 | <MarkdownImageAvailableWidthContext | 1489 | <MarkdownImageAvailableWidthContext |
| 1497 | value={props.markdownContentWidth - REASONING_CONTENT_INSET} | 1490 | value={props.markdownContentWidth - REASONING_CONTENT_INSET} |
| 1498 | > | 1491 | > |
| 1499 | <AssistantMarkdownContent | 1492 | <View className="gap-3"> |
| 1500 | markdown={message.text} | 1493 | {messages.map((reasoningMessage) => ( |
| 1501 | markdownStyles={markdownStyles.assistant} | 1494 | <AssistantMarkdownContent |
| 1502 | linkHandlers={props.markdownLinkHandlers} | 1495 | key={reasoningMessage.id} |
| 1503 | renderImage={props.renderMarkdownImage} | 1496 | markdown={reasoningMessage.text} |
| 1504 | skills={props.skills} | 1497 | markdownStyles={markdownStyles.assistant} |
| 1505 | /> | 1498 | linkHandlers={props.markdownLinkHandlers} |
| 1499 | renderImage={props.renderMarkdownImage} | ||
| 1500 | skills={props.skills} | ||
| 1501 | /> | ||
| 1502 | ))} | ||
| 1503 | </View> | ||
| 1506 | </MarkdownImageAvailableWidthContext> | 1504 | </MarkdownImageAvailableWidthContext> |
| 1507 | </ThreadReasoningRow> | 1505 | </ThreadReasoningRow> |
| 1508 | ); | 1506 | ); |
| ... | @@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2656 | 2654 | ||
| 2657 | const onToggleReasoning = useCallback( | 2655 | const onToggleReasoning = useCallback( |
| 2658 | (messageId: string) => { | 2656 | (messageId: string) => { |
| 2659 | // The anchor must be the feed row id, which for a message row is the | 2657 | // Reasoning details use their own row within the expanded activity history. |
| 2660 | // message id, or position restoration is skipped for every row. | ||
| 2661 | suspendEndScrollMaintenanceForDisclosure(messageId); | 2658 | suspendEndScrollMaintenanceForDisclosure(messageId); |
| 2662 | setInteractionState((current) => { | 2659 | setInteractionState((current) => { |
| 2663 | const next = new Set(current.expandedReasoningMessageIds); | 2660 | const next = new Set(current.expandedReasoningMessageIds); |
| ... | @@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2700 | switch (entry.type) { | 2697 | switch (entry.type) { |
| 2701 | case "message": | 2698 | case "message": |
| 2702 | // A collapsed reasoning row is the same chrome as a work toggle. | 2699 | // A collapsed reasoning row is the same chrome as a work toggle. |
| 2703 | return entry.message.role === "reasoning" && | 2700 | return entry.message.role === "reasoning" && !expandedReasoningMessageIds.has(entry.id) |
| 2704 | !expandedReasoningMessageIds.has(entry.message.id) | ||
| 2705 | ? WORK_GROUP_TOGGLE_HEIGHT | 2701 | ? WORK_GROUP_TOGGLE_HEIGHT |
| 2706 | : undefined; | 2702 | : undefined; |
| 2707 | case "turn-fold": | 2703 | case "turn-fold": |
apps/mobile/src/features/threads/thread-work-log.tsx+1-1| ... | @@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: { |
| 920 | readonly iconSubtleColor: import("react-native").ColorValue; | 920 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 921 | readonly summary: string; | 921 | readonly summary: string; |
| 922 | readonly summaryKind: ToolGroupSummaryKind; | 922 | readonly summaryKind: ToolGroupSummaryKind; |
| 923 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; | 923 | readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain"; |
| 924 | readonly themeAppearance: "light" | "dark"; | 924 | readonly themeAppearance: "light" | "dark"; |
| 925 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; | 925 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; |
| 926 | readonly toolIcon?: ToolActivityIcon; | 926 | readonly toolIcon?: ToolActivityIcon; |
apps/mobile/src/lib/threadActivity.test.ts+275| ... | @@ -2295,6 +2295,281 @@ describe("buildThreadFeed", () => { | ... | @@ -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 | it("shows one Thinking row while a turn works without live tool activity", () => { | 2573 | it("shows one Thinking row while a turn works without live tool activity", () => { |
| 2299 | const turnId = TurnId.make("turn-thinking"); | 2574 | const turnId = TurnId.make("turn-thinking"); |
| 2300 | const latestTurn = { | 2575 | const latestTurn = { |
apps/mobile/src/lib/threadActivity.ts+219-27| ... | @@ -148,7 +148,9 @@ type RawThreadFeedEntry = | ... | @@ -148,7 +148,9 @@ type RawThreadFeedEntry = |
| 148 | }; | 148 | }; |
| 149 | 149 | ||
| 150 | export type ThreadFeedEntry = | 150 | export type ThreadFeedEntry = |
| 151 | | Extract<RawThreadFeedEntry, { type: "message" }> | 151 | | (Extract<RawThreadFeedEntry, { type: "message" }> & { |
| 152 | readonly reasoningMessages?: OrchestrationThread["messages"]; | ||
| 153 | }) | ||
| 152 | | { | 154 | | { |
| 153 | readonly type: "activity-group"; | 155 | readonly type: "activity-group"; |
| 154 | readonly id: string; | 156 | readonly id: string; |
| ... | @@ -168,7 +170,7 @@ export type ThreadFeedEntry = | ... | @@ -168,7 +170,7 @@ export type ThreadFeedEntry = |
| 168 | readonly summaryKind: ToolGroupSummaryKind; | 170 | readonly summaryKind: ToolGroupSummaryKind; |
| 169 | readonly toolSurface?: WorkLogEntry["toolSurface"]; | 171 | readonly toolSurface?: WorkLogEntry["toolSurface"]; |
| 170 | readonly toolIcon?: WorkLogEntry["toolIcon"]; | 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 | readonly hasFailure: boolean; | 174 | readonly hasFailure: boolean; |
| 173 | readonly live: boolean; | 175 | readonly live: boolean; |
| 174 | readonly shimmer: boolean; | 176 | readonly shimmer: boolean; |
| ... | @@ -254,6 +256,18 @@ const turnFoldRowsCache = new WeakMap< | ... | @@ -254,6 +256,18 @@ const turnFoldRowsCache = new WeakMap< |
| 254 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> | 256 | Extract<ThreadFeedEntry, { readonly type: "turn-fold" }> |
| 255 | >(); | 257 | >(); |
| 256 | let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null; | 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 | export function isContextCompactionActivityGroup( | 272 | export function isContextCompactionActivityGroup( |
| 259 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, | 273 | entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| ... | @@ -1783,7 +1797,8 @@ export function deriveThreadFeedPresentation( | ... | @@ -1783,7 +1797,8 @@ export function deriveThreadFeedPresentation( |
| 1783 | } | 1797 | } |
| 1784 | 1798 | ||
| 1785 | const result: ThreadFeedEntry[] = []; | 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 | const isActiveTailGroup = | 1802 | const isActiveTailGroup = |
| 1788 | isWorking && | 1803 | isWorking && |
| 1789 | unsettledTurnId !== null && | 1804 | unsettledTurnId !== null && |
| ... | @@ -1815,6 +1830,31 @@ export function deriveThreadFeedPresentation( | ... | @@ -1815,6 +1830,31 @@ export function deriveThreadFeedPresentation( |
| 1815 | result.push(row); | 1830 | result.push(row); |
| 1816 | } | 1831 | } |
| 1817 | if (!collapsedEntryIds.has(entry.id)) { | 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 | appendPresentedFeedEntry( | 1858 | appendPresentedFeedEntry( |
| 1819 | result, | 1859 | result, |
| 1820 | entry, | 1860 | entry, |
| ... | @@ -1835,15 +1875,7 @@ export function deriveThreadFeedPresentation( | ... | @@ -1835,15 +1875,7 @@ export function deriveThreadFeedPresentation( |
| 1835 | !result.some( | 1875 | !result.some( |
| 1836 | (row) => | 1876 | (row) => |
| 1837 | (row.type === "work-toggle" && row.shimmer) || | 1877 | (row.type === "work-toggle" && row.shimmer) || |
| 1838 | // A live thinking block is the real version of this row, so it takes | 1878 | row.id === LIVE_ACTIVITY_ROW_ID || |
| 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) || | ||
| 1847 | // A working spawn card is the live activity: its status line shows | 1879 | // A working spawn card is the live activity: its status line shows |
| 1848 | // what the agents are doing, so a Thinking row under it would lie. | 1880 | // what the agents are doing, so a Thinking row under it would lie. |
| 1849 | (row.type === "agent-spawn" && | 1881 | (row.type === "agent-spawn" && |
| ... | @@ -1856,6 +1888,157 @@ export function deriveThreadFeedPresentation( | ... | @@ -1856,6 +1888,157 @@ export function deriveThreadFeedPresentation( |
| 1856 | return result; | 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 | * Shared by the trailing tool group's live row and the "Thinking" row so the | 2043 | * Shared by the trailing tool group's live row and the "Thinking" row so the |
| 1861 | * list keeps one mounted row for the turn's live slot (mirrors web's | 2044 | * list keeps one mounted row for the turn's live slot (mirrors web's |
| ... | @@ -1924,16 +2107,7 @@ function appendActivityGroupRows( | ... | @@ -1924,16 +2107,7 @@ function appendActivityGroupRows( |
| 1924 | isWorking: boolean, | 2107 | isWorking: boolean, |
| 1925 | activeTail: boolean, | 2108 | activeTail: boolean, |
| 1926 | ): void { | 2109 | ): void { |
| 1927 | const activities = omitSupersededLifecycleMarkers( | 2110 | const activities = visibleActivityGroupEntries(entry, unsettledTurnId, isWorking); |
| 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 | ); | ||
| 1937 | if (activities.length === 0) { | 2111 | if (activities.length === 0) { |
| 1938 | return; | 2112 | return; |
| 1939 | } | 2113 | } |
| ... | @@ -1984,6 +2158,28 @@ function appendActivityGroupRows( | ... | @@ -1984,6 +2158,28 @@ function appendActivityGroupRows( |
| 1984 | flushGroupableRun(true); | 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 | function appendToolGroupRows( | 2183 | function appendToolGroupRows( |
| 1988 | result: ThreadFeedEntry[], | 2184 | result: ThreadFeedEntry[], |
| 1989 | sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, | 2185 | sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>, |
| ... | @@ -1993,11 +2189,7 @@ function appendToolGroupRows( | ... | @@ -1993,11 +2189,7 @@ function appendToolGroupRows( |
| 1993 | isWorking: boolean, | 2189 | isWorking: boolean, |
| 1994 | activeTail: boolean, | 2190 | activeTail: boolean, |
| 1995 | ): void { | 2191 | ): void { |
| 1996 | const firstEntry = activities[0]!.workEntry; | 2192 | const groupId = toolActivityGroupId(activities[0]!); |
| 1997 | const identity = firstEntry.toolCallId | ||
| 1998 | ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}` | ||
| 1999 | : activities[0]!.id; | ||
| 2000 | const groupId = `work-group:${identity}`; | ||
| 2001 | const expanded = expandedWorkGroupIds.has(groupId); | 2193 | const expanded = expandedWorkGroupIds.has(groupId); |
| 2002 | const latestActiveActivity = activities.findLast( | 2194 | const latestActiveActivity = activities.findLast( |
| 2003 | (activity) => | 2195 | (activity) => |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+245-13| ... | @@ -1950,37 +1950,269 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -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 | const rows = deriveMessagesTimelineRows({ | 2099 | const rows = deriveMessagesTimelineRows({ |
| 1955 | timelineEntries: [ | 2100 | timelineEntries: thoughts, |
| 1956 | reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), | ||
| 1957 | answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1"), | ||
| 1958 | ], | ||
| 1959 | isWorking: false, | 2101 | isWorking: false, |
| 1960 | activeTurnStartedAt: null, | 2102 | activeTurnStartedAt: null, |
| 1961 | turnDiffSummaries: [], | 2103 | turnDiffSummaries: [], |
| 1962 | supportsConversationRollback: false, | 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 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); | 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 | const rows = deriveMessagesTimelineRows({ | 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 | timelineEntries: [ | 2188 | timelineEntries: [ |
| 1972 | reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), | 2189 | ...entries, |
| 1973 | toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), | 2190 | answerEntry("assistant-entry", "2026-01-01T00:00:04Z", "turn-1"), |
| 1974 | answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"), | ||
| 1975 | ], | 2191 | ], |
| 1976 | isWorking: false, | 2192 | isWorking: false, |
| 1977 | activeTurnStartedAt: null, | 2193 | activeTurnStartedAt: null, |
| 1978 | turnDiffSummaries: [], | 2194 | turnDiffSummaries: [], |
| 1979 | supportsConversationRollback: false, | 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 | 2203 | expect(expanded.filter((row) => row.kind === "activity-group")).toMatchObject([ | |
| 1982 | expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); | 2204 | { entries, expanded: false, active: false }, |
| 1983 | expect(rows.map((row) => row.id)).not.toContain("reasoning-entry"); | 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 | it("still folds a lone trailing tool call when a thought follows the answer", () => { | 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,9 +310,32 @@ export type TimelineLatestTurn = Pick< |
| 310 | "turnId" | "state" | "startedAt" | "completedAt" | 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 | export type MessagesTimelineRow = | 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 | kind: "work"; | 340 | kind: "work"; |
| 318 | id: string; | 341 | id: string; |
| ... | @@ -366,6 +389,7 @@ export type MessagesTimelineRow = | ... | @@ -366,6 +389,7 @@ export type MessagesTimelineRow = |
| 366 | createdAt: string; | 389 | createdAt: string; |
| 367 | message: ChatMessage; | 390 | message: ChatMessage; |
| 368 | durationStart: string; | 391 | durationStart: string; |
| 392 | reasoningMessages?: ReadonlyArray<ChatMessage>; | ||
| 369 | showAssistantMeta: boolean; | 393 | showAssistantMeta: boolean; |
| 370 | showAssistantCopyButton: boolean; | 394 | showAssistantCopyButton: boolean; |
| 371 | assistantCopyStreaming: boolean; | 395 | assistantCopyStreaming: boolean; |
| ... | @@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: { | ... | @@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: { |
| 587 | return turnIds; | 611 | return turnIds; |
| 588 | } | 612 | } |
| 589 | 613 | ||
| 590 | function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { | 614 | export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { |
| 591 | return ( | 615 | return ( |
| 592 | entry.toolLifecycleStatus === "inProgress" || | 616 | entry.toolLifecycleStatus === "inProgress" || |
| 593 | (entry.toolLifecycleStatus === undefined && | 617 | (entry.toolLifecycleStatus === undefined && |
| ... | @@ -819,7 +843,12 @@ function attachTrailingToolGroupsToAssistant( | ... | @@ -819,7 +843,12 @@ function attachTrailingToolGroupsToAssistant( |
| 819 | if (candidate.kind === "message") { | 843 | if (candidate.kind === "message") { |
| 820 | break; | 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 | hasTrailingToolGroup = true; | 852 | hasTrailingToolGroup = true; |
| 824 | lastTrailingWorkIndex = index; | 853 | lastTrailingWorkIndex = index; |
| 825 | continue; | 854 | continue; |
| ... | @@ -996,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -996,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: { |
| 996 | break; | 1025 | break; |
| 997 | } | 1026 | } |
| 998 | activeToolEntries.unshift(entry); | 1027 | activeToolEntries.unshift(entry); |
| 1028 | if (workEntryDisplayIndicatesToolFailure(entry.entry)) break; | ||
| 999 | } | 1029 | } |
| 1000 | const visibleActiveToolEntries = omitSupersededLifecycleMarkers( | 1030 | const visibleActiveToolEntries = omitSupersededLifecycleMarkers( |
| 1001 | activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), | 1031 | activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), |
| ... | @@ -1074,6 +1104,7 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1074,6 +1104,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1074 | ); | 1104 | ); |
| 1075 | }; | 1105 | }; |
| 1076 | 1106 | ||
| 1107 | let scannedActivityThrough = -1; | ||
| 1077 | for (let index = 0; index < input.timelineEntries.length; index += 1) { | 1108 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 1078 | const timelineEntry = input.timelineEntries[index]; | 1109 | const timelineEntry = input.timelineEntries[index]; |
| 1079 | if (!timelineEntry) { | 1110 | if (!timelineEntry) { |
| ... | @@ -1104,6 +1135,48 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1104,6 +1135,48 @@ export function deriveMessagesTimelineRows(input: { |
| 1104 | continue; | 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 | if (activeWorkEntryIds.has(timelineEntry.id)) { | 1180 | if (activeWorkEntryIds.has(timelineEntry.id)) { |
| 1108 | continue; | 1181 | continue; |
| 1109 | } | 1182 | } |
| ... | @@ -1364,29 +1437,13 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -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 | // A running setup owns the working slot above its card and shows no | 1440 | // A running setup owns the working slot above its card and shows no |
| 1379 | // activity row of its own; every other state gets the usual tail. | 1441 | // activity row of its own; every other state gets the usual tail. |
| 1380 | const hasWorkingRow = nextRows.some((row) => row.kind === "working"); | 1442 | const hasWorkingRow = nextRows.some((row) => row.kind === "working"); |
| 1381 | if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { | 1443 | if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1382 | appendWorkingRow(); | 1444 | appendWorkingRow(); |
| 1383 | } | 1445 | } |
| 1384 | if ( | 1446 | if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) { |
| 1385 | input.isWorking && | ||
| 1386 | !setupRunning && | ||
| 1387 | !hasStreamingReasoningRow && | ||
| 1388 | (!hasActivityRow || latestToolFailed) | ||
| 1389 | ) { | ||
| 1390 | nextRows.push({ | 1447 | nextRows.push({ |
| 1391 | kind: "thinking", | 1448 | kind: "thinking", |
| 1392 | id: LIVE_ACTIVITY_ROW_ID, | 1449 | id: LIVE_ACTIVITY_ROW_ID, |
| ... | @@ -1468,6 +1525,18 @@ function replaceStreamingMessageRows( | ... | @@ -1468,6 +1525,18 @@ function replaceStreamingMessageRows( |
| 1468 | } | 1525 | } |
| 1469 | if (replacements.size === 0) return previous.rows; | 1526 | if (replacements.size === 0) return previous.rows; |
| 1470 | return previous.rows.map((row) => { | 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 | if (row.kind !== "message" && row.kind !== "assistant-meta") return row; | 1540 | if (row.kind !== "message" && row.kind !== "assistant-meta") return row; |
| 1472 | const message = replacements.get(row.message); | 1541 | const message = replacements.get(row.message); |
| 1473 | return message ? { ...row, message } : row; | 1542 | return message ? { ...row, message } : row; |
| ... | @@ -1512,6 +1581,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -1512,6 +1581,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1512 | if (a.kind !== b.kind || a.id !== b.id) return false; | 1581 | if (a.kind !== b.kind || a.id !== b.id) return false; |
| 1513 | 1582 | ||
| 1514 | switch (a.kind) { | 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 | case "working": | 1594 | case "working": |
| 1516 | case "thinking": | 1595 | case "thinking": |
| 1517 | return a.createdAt === (b as typeof a).createdAt; | 1596 | return a.createdAt === (b as typeof a).createdAt; |
apps/web/src/components/chat/MessagesTimeline.tsx+147-26| ... | @@ -29,6 +29,8 @@ import { | ... | @@ -29,6 +29,8 @@ import { |
| 29 | resolveWorkEntryToolPresentation, | 29 | resolveWorkEntryToolPresentation, |
| 30 | resolveViewedImageAsset, | 30 | resolveViewedImageAsset, |
| 31 | workEntryViewedImagePath, | 31 | workEntryViewedImagePath, |
| 32 | summarizeToolGroup, | ||
| 33 | omitSupersededLifecycleMarkers, | ||
| 32 | } from "@t3tools/client-runtime/work-log/presentation"; | 34 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 33 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | 35 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; |
| 34 | import type { | 36 | import type { |
| ... | @@ -167,9 +169,11 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis | ... | @@ -167,9 +169,11 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis |
| 167 | import { | 169 | import { |
| 168 | computeStableMessagesTimelineRows, | 170 | computeStableMessagesTimelineRows, |
| 169 | deriveMessagesTimelineRowsWithState, | 171 | deriveMessagesTimelineRowsWithState, |
| 172 | LIVE_ACTIVITY_ROW_ID, | ||
| 170 | deriveUnsettledTurnId, | 173 | deriveUnsettledTurnId, |
| 171 | type MessagesTimelineRowsProjection, | 174 | type MessagesTimelineRowsProjection, |
| 172 | liveWorkEntryLabel, | 175 | liveWorkEntryLabel, |
| 176 | workEntryIsActiveTurnActivity, | ||
| 173 | resolveAssistantMessageCopyState, | 177 | resolveAssistantMessageCopyState, |
| 174 | resolveTimelineIsAtEnd, | 178 | resolveTimelineIsAtEnd, |
| 175 | resolveTimelineMinimapHasPersistentGutter, | 179 | resolveTimelineMinimapHasPersistentGutter, |
| ... | @@ -281,7 +285,7 @@ interface TimelineRowSharedState { | ... | @@ -281,7 +285,7 @@ interface TimelineRowSharedState { |
| 281 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; | 285 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 282 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; | 286 | onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; |
| 283 | onToggleSpawnRow: (entryId: string, expanded: boolean) => void; | 287 | onToggleSpawnRow: (entryId: string, expanded: boolean) => void; |
| 284 | onToggleReasoning: (messageId: string, expanded: boolean) => void; | 288 | onToggleReasoning: (messageId: string, expanded: boolean, anchorKey: string) => void; |
| 285 | expandedReasoningMessageIds: ReadonlySet<string>; | 289 | expandedReasoningMessageIds: ReadonlySet<string>; |
| 286 | workGroupViewState: WorkGroupViewState; | 290 | workGroupViewState: WorkGroupViewState; |
| 287 | agentPanelModel: AgentPanelModel; | 291 | agentPanelModel: AgentPanelModel; |
| ... | @@ -671,10 +675,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -671,10 +675,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 671 | [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], | 675 | [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], |
| 672 | ); | 676 | ); |
| 673 | const onToggleReasoning = useCallback( | 677 | const onToggleReasoning = useCallback( |
| 674 | (messageId: string, expanded: boolean) => { | 678 | (messageId: string, expanded: boolean, anchorKey: string) => { |
| 675 | // The anchor must be the timeline row id, which for a message row is the | 679 | suspendEndScrollMaintenanceForDisclosure(anchorKey, !expanded); |
| 676 | // message id, or position restoration is skipped for every row. | ||
| 677 | suspendEndScrollMaintenanceForDisclosure(messageId, !expanded); | ||
| 678 | setExpandedReasoningMessageIds((current) => { | 680 | setExpandedReasoningMessageIds((current) => { |
| 679 | if (current.has(messageId) === expanded) return current; | 681 | if (current.has(messageId) === expanded) return current; |
| 680 | const next = new Set(current); | 682 | const next = new Set(current); |
| ... | @@ -1494,6 +1496,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1494,6 +1496,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1494 | row.kind === "work" || | 1496 | row.kind === "work" || |
| 1495 | row.kind === "work-live" || | 1497 | row.kind === "work-live" || |
| 1496 | row.kind === "work-toggle" || | 1498 | row.kind === "work-toggle" || |
| 1499 | row.kind === "activity-group" || | ||
| 1497 | row.kind === "thinking" || | 1500 | row.kind === "thinking" || |
| 1498 | row.kind === "worktree-setup" | 1501 | row.kind === "worktree-setup" |
| 1499 | ? "pb-2" | 1502 | ? "pb-2" |
| ... | @@ -1519,6 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1519,6 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1519 | /> | 1522 | /> |
| 1520 | ) : null} | 1523 | ) : null} |
| 1521 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} | 1524 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| 1525 | {row.kind === "activity-group" ? <ActivityGroupTimelineRow row={row} /> : null} | ||
| 1522 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} | 1526 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} |
| 1523 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} | 1527 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| 1524 | {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null} | 1528 | {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null} |
| ... | @@ -2392,6 +2396,108 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap | ... | @@ -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 | function ThinkingTimelineRow() { | 2501 | function ThinkingTimelineRow() { |
| 2396 | const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); | 2502 | const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); |
| 2397 | // Reserve the activity row during setup so the handoff keeps the same height. | 2503 | // Reserve the activity row during setup so the handoff keeps the same height. |
| ... | @@ -2411,28 +2517,35 @@ function ThinkingTimelineRow() { | ... | @@ -2411,28 +2517,35 @@ function ThinkingTimelineRow() { |
| 2411 | */ | 2517 | */ |
| 2412 | const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ | 2518 | const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ |
| 2413 | row, | 2519 | row, |
| 2520 | disclosureAnchorKey = row.id, | ||
| 2414 | }: { | 2521 | }: { |
| 2415 | row: Extract<TimelineRow, { kind: "message" }>; | 2522 | row: Extract<TimelineRow, { kind: "message" }>; |
| 2523 | disclosureAnchorKey?: string; | ||
| 2416 | }) { | 2524 | }) { |
| 2417 | const ctx = use(TimelineRowCtx); | 2525 | const ctx = use(TimelineRowCtx); |
| 2418 | const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); | 2526 | const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); |
| 2419 | const { message } = row; | 2527 | const { message } = row; |
| 2528 | const messages = row.reasoningMessages ?? [message]; | ||
| 2420 | // A block left open by a crashed provider or a restarted server never gets | 2529 | // A block left open by a crashed provider or a restarted server never gets |
| 2421 | // its completion. Only the live turn may claim to still be thinking, so a | 2530 | // its completion. Only the live turn may claim to still be thinking, so a |
| 2422 | // settled turn cannot shimmer "Thinking" at the user forever. | 2531 | // settled turn cannot shimmer "Thinking" at the user forever. |
| 2423 | const streaming = | 2532 | const streaming = |
| 2424 | Boolean(message.streaming) && | 2533 | row.id === LIVE_ACTIVITY_ROW_ID && |
| 2534 | messages.some((reasoningMessage) => reasoningMessage.streaming) && | ||
| 2425 | isWorking && | 2535 | isWorking && |
| 2426 | message.turnId !== null && | 2536 | message.turnId !== null && |
| 2427 | message.turnId === unsettledTurnId; | 2537 | message.turnId === unsettledTurnId; |
| 2428 | const expanded = ctx.expandedReasoningMessageIds.has(message.id); | 2538 | const expanded = ctx.expandedReasoningMessageIds.has(message.id); |
| 2429 | const { onToggleReasoning } = ctx; | 2539 | const { onToggleReasoning } = ctx; |
| 2430 | const toggle = useCallback(() => { | 2540 | const toggle = useCallback(() => { |
| 2431 | onToggleReasoning(message.id, !expanded); | 2541 | onToggleReasoning(message.id, !expanded, disclosureAnchorKey); |
| 2432 | }, [expanded, message.id, onToggleReasoning]); | 2542 | }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]); |
| 2433 | const label = streaming ? "Thinking" : "Thought"; | 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 | return null; | 2549 | return null; |
| 2437 | } | 2550 | } |
| 2438 | 2551 | ||
| ... | @@ -2466,18 +2579,21 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ | ... | @@ -2466,18 +2579,21 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ |
| 2466 | </span> | 2579 | </span> |
| 2467 | </button> | 2580 | </button> |
| 2468 | {expanded ? ( | 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"> | 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"> |
| 2470 | <ChatMarkdown | 2583 | {messages.map((reasoningMessage) => ( |
| 2471 | text={message.text} | 2584 | <ChatMarkdown |
| 2472 | cwd={ctx.markdownCwd} | 2585 | key={reasoningMessage.id} |
| 2473 | threadRef={ctx.threadRef ?? undefined} | 2586 | text={reasoningMessage.text} |
| 2474 | isStreaming={streaming} | 2587 | cwd={ctx.markdownCwd} |
| 2475 | lineBreaks | 2588 | threadRef={ctx.threadRef ?? undefined} |
| 2476 | skills={ctx.skills} | 2589 | isStreaming={streaming && reasoningMessage.streaming} |
| 2477 | headingLevelOffset={MESSAGE_HEADING_LEVEL} | 2590 | lineBreaks |
| 2478 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} | 2591 | skills={ctx.skills} |
| 2479 | onImageExpand={ctx.onImageExpand} | 2592 | headingLevelOffset={MESSAGE_HEADING_LEVEL} |
| 2480 | /> | 2593 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} |
| 2594 | onImageExpand={ctx.onImageExpand} | ||
| 2595 | /> | ||
| 2596 | ))} | ||
| 2481 | </div> | 2597 | </div> |
| 2482 | ) : null} | 2598 | ) : null} |
| 2483 | </div> | 2599 | </div> |
| ... | @@ -2529,19 +2645,21 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { | ... | @@ -2529,19 +2645,21 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { |
| 2529 | /** Renders standalone activity or one bounded, virtualized expanded tool group. */ | 2645 | /** Renders standalone activity or one bounded, virtualized expanded tool group. */ |
| 2530 | const WorkGroupSection = memo(function WorkGroupSection({ | 2646 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 2531 | anchorKey, | 2647 | anchorKey, |
| 2648 | disclosureAnchorKey = anchorKey, | ||
| 2532 | groupedEntries, | 2649 | groupedEntries, |
| 2533 | isExpandedToolGroup, | 2650 | isExpandedToolGroup, |
| 2534 | displayLabel, | 2651 | displayLabel, |
| 2535 | }: { | 2652 | }: { |
| 2536 | anchorKey: string; | 2653 | anchorKey: string; |
| 2654 | disclosureAnchorKey?: string; | ||
| 2537 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; | 2655 | groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"]; |
| 2538 | isExpandedToolGroup: boolean; | 2656 | isExpandedToolGroup: boolean; |
| 2539 | displayLabel?: string | undefined; | 2657 | displayLabel?: string | undefined; |
| 2540 | }) { | 2658 | }) { |
| 2541 | const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); | 2659 | const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); |
| 2542 | const onToggleStandaloneEntry = useCallback( | 2660 | const onToggleStandaloneEntry = useCallback( |
| 2543 | (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), | 2661 | (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), |
| 2544 | [anchorKey, onToggleWorkEntry], | 2662 | [disclosureAnchorKey, onToggleWorkEntry], |
| 2545 | ); | 2663 | ); |
| 2546 | const nonEmptyEntries = useMemo( | 2664 | const nonEmptyEntries = useMemo( |
| 2547 | () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), | 2665 | () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), |
| ... | @@ -2554,6 +2672,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -2554,6 +2672,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 2554 | <ExpandedWorkGroupEntries | 2672 | <ExpandedWorkGroupEntries |
| 2555 | key={`${routeThreadKey}:${anchorKey}`} | 2673 | key={`${routeThreadKey}:${anchorKey}`} |
| 2556 | anchorKey={anchorKey} | 2674 | anchorKey={anchorKey} |
| 2675 | disclosureAnchorKey={disclosureAnchorKey} | ||
| 2557 | entries={nonEmptyEntries} | 2676 | entries={nonEmptyEntries} |
| 2558 | workspaceRoot={workspaceRoot} | 2677 | workspaceRoot={workspaceRoot} |
| 2559 | /> | 2678 | /> |
| ... | @@ -2580,10 +2699,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ | ... | @@ -2580,10 +2699,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ |
| 2580 | 2699 | ||
| 2581 | function ExpandedWorkGroupEntries({ | 2700 | function ExpandedWorkGroupEntries({ |
| 2582 | anchorKey, | 2701 | anchorKey, |
| 2702 | disclosureAnchorKey, | ||
| 2583 | entries, | 2703 | entries, |
| 2584 | workspaceRoot, | 2704 | workspaceRoot, |
| 2585 | }: { | 2705 | }: { |
| 2586 | anchorKey: string; | 2706 | anchorKey: string; |
| 2707 | disclosureAnchorKey: string; | ||
| 2587 | entries: TimelineWorkEntry[]; | 2708 | entries: TimelineWorkEntry[]; |
| 2588 | workspaceRoot: string | undefined; | 2709 | workspaceRoot: string | undefined; |
| 2589 | }) { | 2710 | }) { |
| ... | @@ -2609,9 +2730,9 @@ function ExpandedWorkGroupEntries({ | ... | @@ -2609,9 +2730,9 @@ function ExpandedWorkGroupEntries({ |
| 2609 | const groupView = useMemo( | 2730 | const groupView = useMemo( |
| 2610 | () => ({ | 2731 | () => ({ |
| 2611 | state: viewState, | 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 | const updateScrollFades = useCallback(() => { | 2737 | const updateScrollFades = useCallback(() => { |
| 2617 | const element = listRef.current?.getScrollableNode(); | 2738 | const element = listRef.current?.getScrollableNode(); |