authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 19:27:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 19:27:48-03:00
log22539f2a428947ab218589c984b2af44e434012e
tree836ae462ef5803c1069624d3a74834f2f6aa0137
parentdda44ed33280106aa46612fd9e1f1475bb5a0712
signature Signed by PGP key B5690EEEBB952194

fix(chat): group thoughts into the changing tool activity line (#12147)


7 files changed, 1005 insertions(+), 110 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+19-23
......@@ -1476,33 +1476,31 @@ function renderFeedEntry(
14761476 if (entry.type === "message") {
14771477 const { message } = entry;
14781478 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];
14871480 return (
14881481 <ThreadReasoningRow
14891482 rowSizing={props.workRowSizing}
14901483 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)}
14951488 >
14961489 <MarkdownImageAvailableWidthContext
14971490 value={props.markdownContentWidth - REASONING_CONTENT_INSET}
14981491 >
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>
15061504 </MarkdownImageAvailableWidthContext>
15071505 </ThreadReasoningRow>
15081506 );
......@@ -2656,8 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26562654
26572655 const onToggleReasoning = useCallback(
26582656 (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.
26612658 suspendEndScrollMaintenanceForDisclosure(messageId);
26622659 setInteractionState((current) => {
26632660 const next = new Set(current.expandedReasoningMessageIds);
......@@ -2700,8 +2697,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27002697 switch (entry.type) {
27012698 case "message":
27022699 // 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)
27052701 ? WORK_GROUP_TOGGLE_HEIGHT
27062702 : undefined;
27072703 case "turn-fold":
apps/mobile/src/features/threads/thread-work-log.tsx+1-1
......@@ -920,7 +920,7 @@ export function ThreadWorkGroupToggle(props: {
920920 readonly iconSubtleColor: import("react-native").ColorValue;
921921 readonly summary: string;
922922 readonly summaryKind: ToolGroupSummaryKind;
923 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
923 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
924924 readonly themeAppearance: "light" | "dark";
925925 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
926926 readonly toolIcon?: ToolActivityIcon;
apps/mobile/src/lib/threadActivity.test.ts+275
......@@ -2295,6 +2295,281 @@ describe("buildThreadFeed", () => {
22952295 },
22962296 );
22972297
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
22982573 it("shows one Thinking row while a turn works without live tool activity", () => {
22992574 const turnId = TurnId.make("turn-thinking");
23002575 const latestTurn = {
apps/mobile/src/lib/threadActivity.ts+219-27
......@@ -148,7 +148,9 @@ type RawThreadFeedEntry =
148148 };
149149
150150export type ThreadFeedEntry =
151 | Extract<RawThreadFeedEntry, { type: "message" }>
151 | (Extract<RawThreadFeedEntry, { type: "message" }> & {
152 readonly reasoningMessages?: OrchestrationThread["messages"];
153 })
152154 | {
153155 readonly type: "activity-group";
154156 readonly id: string;
......@@ -168,7 +170,7 @@ export type ThreadFeedEntry =
168170 readonly summaryKind: ToolGroupSummaryKind;
169171 readonly toolSurface?: WorkLogEntry["toolSurface"];
170172 readonly toolIcon?: WorkLogEntry["toolIcon"];
171 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
173 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
172174 readonly hasFailure: boolean;
173175 readonly live: boolean;
174176 readonly shimmer: boolean;
......@@ -254,6 +256,18 @@ const turnFoldRowsCache = new WeakMap<
254256 Extract<ThreadFeedEntry, { readonly type: "turn-fold" }>
255257>();
256258let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null;
259const reasoningGroupsCache = new WeakMap<
260 ThreadFeedEntry,
261 Extract<ThreadFeedEntry, { readonly type: "message" }>
262>();
263const activityRunsCache = new WeakMap<
264 ThreadFeedEntry,
265 {
266 readonly source: ReadonlyArray<ThreadFeedEntry>;
267 readonly state: string;
268 readonly rows: ThreadFeedEntry[];
269 }
270>();
257271
258272export function isContextCompactionActivityGroup(
259273 entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
......@@ -1783,7 +1797,8 @@ export function deriveThreadFeedPresentation(
17831797 }
17841798
17851799 const result: ThreadFeedEntry[] = [];
1786 for (const entry of sourceFeed) {
1800 for (let index = 0; index < sourceFeed.length; index += 1) {
1801 const entry = sourceFeed[index]!;
17871802 const isActiveTailGroup =
17881803 isWorking &&
17891804 unsettledTurnId !== null &&
......@@ -1815,6 +1830,31 @@ export function deriveThreadFeedPresentation(
18151830 result.push(row);
18161831 }
18171832 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 }
18181858 appendPresentedFeedEntry(
18191859 result,
18201860 entry,
......@@ -1835,15 +1875,7 @@ export function deriveThreadFeedPresentation(
18351875 !result.some(
18361876 (row) =>
18371877 (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 ||
18471879 // A working spawn card is the live activity: its status line shows
18481880 // what the agents are doing, so a Thinking row under it would lie.
18491881 (row.type === "agent-spawn" &&
......@@ -1856,6 +1888,157 @@ export function deriveThreadFeedPresentation(
18561888 return result;
18571889}
18581890
1891function 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
1911function 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
1999function 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
18592042/**
18602043 * Shared by the trailing tool group's live row and the "Thinking" row so the
18612044 * list keeps one mounted row for the turn's live slot (mirrors web's
......@@ -1924,16 +2107,7 @@ function appendActivityGroupRows(
19242107 isWorking: boolean,
19252108 activeTail: boolean,
19262109): 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);
19372111 if (activities.length === 0) {
19382112 return;
19392113 }
......@@ -1984,6 +2158,28 @@ function appendActivityGroupRows(
19842158 flushGroupableRun(true);
19852159}
19862160
2161function 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
2178function 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
19872183function appendToolGroupRows(
19882184 result: ThreadFeedEntry[],
19892185 sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
......@@ -1993,11 +2189,7 @@ function appendToolGroupRows(
19932189 isWorking: boolean,
19942190 activeTail: boolean,
19952191): 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]!);
20012193 const expanded = expandedWorkGroupIds.has(groupId);
20022194 const latestActiveActivity = activities.findLast(
20032195 (activity) =>
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+245-13
......@@ -1950,37 +1950,269 @@ describe("deriveMessagesTimelineRows", () => {
19501950 },
19511951 });
19521952
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 );
19542099 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,
19592101 isWorking: false,
19602102 activeTurnStartedAt: null,
19612103 turnDiffSummaries: [],
19622104 supportsConversationRollback: false,
19632105 });
2106 expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id));
2107 });
19642108
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 });
19652118 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 });
19672123 });
19682124
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");
19702128 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 = {
19712188 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"),
19752191 ],
19762192 isWorking: false,
19772193 activeTurnStartedAt: null,
19782194 turnDiffSummaries: [],
19792195 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")]),
19802202 });
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);
19842216 });
19852217
19862218 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<
310310 "turnId" | "state" | "startedAt" | "completedAt"
311311>;
312312
313const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
313export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
314
315type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>;
316
317function 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}
314327
315328export 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 }
316339 | {
317340 kind: "work";
318341 id: string;
......@@ -366,6 +389,7 @@ export type MessagesTimelineRow =
366389 createdAt: string;
367390 message: ChatMessage;
368391 durationStart: string;
392 reasoningMessages?: ReadonlyArray<ChatMessage>;
369393 showAssistantMeta: boolean;
370394 showAssistantCopyButton: boolean;
371395 assistantCopyStreaming: boolean;
......@@ -587,7 +611,7 @@ function deriveActiveVisualResponseTurnIds(input: {
587611 return turnIds;
588612}
589613
590function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
614export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean {
591615 return (
592616 entry.toolLifecycleStatus === "inProgress" ||
593617 (entry.toolLifecycleStatus === undefined &&
......@@ -819,7 +843,12 @@ function attachTrailingToolGroupsToAssistant(
819843 if (candidate.kind === "message") {
820844 break;
821845 }
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 ) {
823852 hasTrailingToolGroup = true;
824853 lastTrailingWorkIndex = index;
825854 continue;
......@@ -996,6 +1025,7 @@ export function deriveMessagesTimelineRows(input: {
9961025 break;
9971026 }
9981027 activeToolEntries.unshift(entry);
1028 if (workEntryDisplayIndicatesToolFailure(entry.entry)) break;
9991029 }
10001030 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
10011031 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
......@@ -1074,6 +1104,7 @@ export function deriveMessagesTimelineRows(input: {
10741104 );
10751105 };
10761106
1107 let scannedActivityThrough = -1;
10771108 for (let index = 0; index < input.timelineEntries.length; index += 1) {
10781109 const timelineEntry = input.timelineEntries[index];
10791110 if (!timelineEntry) {
......@@ -1104,6 +1135,48 @@ export function deriveMessagesTimelineRows(input: {
11041135 continue;
11051136 }
11061137
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
11071180 if (activeWorkEntryIds.has(timelineEntry.id)) {
11081181 continue;
11091182 }
......@@ -1364,29 +1437,13 @@ export function deriveMessagesTimelineRows(input: {
13641437 );
13651438 }
13661439 }
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
13781440 // A running setup owns the working slot above its card and shows no
13791441 // activity row of its own; every other state gets the usual tail.
13801442 const hasWorkingRow = nextRows.some((row) => row.kind === "working");
13811443 if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) {
13821444 appendWorkingRow();
13831445 }
1384 if (
1385 input.isWorking &&
1386 !setupRunning &&
1387 !hasStreamingReasoningRow &&
1388 (!hasActivityRow || latestToolFailed)
1389 ) {
1446 if (input.isWorking && !setupRunning && (!hasActivityRow || latestToolFailed)) {
13901447 nextRows.push({
13911448 kind: "thinking",
13921449 id: LIVE_ACTIVITY_ROW_ID,
......@@ -1468,6 +1525,18 @@ function replaceStreamingMessageRows(
14681525 }
14691526 if (replacements.size === 0) return previous.rows;
14701527 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 }
14711540 if (row.kind !== "message" && row.kind !== "assistant-meta") return row;
14721541 const message = replacements.get(row.message);
14731542 return message ? { ...row, message } : row;
......@@ -1512,6 +1581,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
15121581 if (a.kind !== b.kind || a.id !== b.id) return false;
15131582
15141583 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 }
15151594 case "working":
15161595 case "thinking":
15171596 return a.createdAt === (b as typeof a).createdAt;
apps/web/src/components/chat/MessagesTimeline.tsx+147-26
......@@ -29,6 +29,8 @@ import {
2929 resolveWorkEntryToolPresentation,
3030 resolveViewedImageAsset,
3131 workEntryViewedImagePath,
32 summarizeToolGroup,
33 omitSupersededLifecycleMarkers,
3234} from "@t3tools/client-runtime/work-log/presentation";
3335import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
3436import type {
......@@ -167,9 +169,11 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
167169import {
168170 computeStableMessagesTimelineRows,
169171 deriveMessagesTimelineRowsWithState,
172 LIVE_ACTIVITY_ROW_ID,
170173 deriveUnsettledTurnId,
171174 type MessagesTimelineRowsProjection,
172175 liveWorkEntryLabel,
176 workEntryIsActiveTurnActivity,
173177 resolveAssistantMessageCopyState,
174178 resolveTimelineIsAtEnd,
175179 resolveTimelineMinimapHasPersistentGutter,
......@@ -281,7 +285,7 @@ interface TimelineRowSharedState {
281285 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
282286 onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void;
283287 onToggleSpawnRow: (entryId: string, expanded: boolean) => void;
284 onToggleReasoning: (messageId: string, expanded: boolean) => void;
288 onToggleReasoning: (messageId: string, expanded: boolean, anchorKey: string) => void;
285289 expandedReasoningMessageIds: ReadonlySet<string>;
286290 workGroupViewState: WorkGroupViewState;
287291 agentPanelModel: AgentPanelModel;
......@@ -671,10 +675,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
671675 [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure],
672676 );
673677 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);
678680 setExpandedReasoningMessageIds((current) => {
679681 if (current.has(messageId) === expanded) return current;
680682 const next = new Set(current);
......@@ -1494,6 +1496,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
14941496 row.kind === "work" ||
14951497 row.kind === "work-live" ||
14961498 row.kind === "work-toggle" ||
1499 row.kind === "activity-group" ||
14971500 row.kind === "thinking" ||
14981501 row.kind === "worktree-setup"
14991502 ? "pb-2"
......@@ -1519,6 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
15191522 />
15201523 ) : null}
15211524 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
1525 {row.kind === "activity-group" ? <ActivityGroupTimelineRow row={row} /> : null}
15221526 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
15231527 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
15241528 {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null}
......@@ -2392,6 +2396,108 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap
23922396 );
23932397}
23942398
2399function 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
23952501function ThinkingTimelineRow() {
23962502 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
23972503 // Reserve the activity row during setup so the handoff keeps the same height.
......@@ -2411,28 +2517,35 @@ function ThinkingTimelineRow() {
24112517 */
24122518const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
24132519 row,
2520 disclosureAnchorKey = row.id,
24142521}: {
24152522 row: Extract<TimelineRow, { kind: "message" }>;
2523 disclosureAnchorKey?: string;
24162524}) {
24172525 const ctx = use(TimelineRowCtx);
24182526 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
24192527 const { message } = row;
2528 const messages = row.reasoningMessages ?? [message];
24202529 // A block left open by a crashed provider or a restarted server never gets
24212530 // its completion. Only the live turn may claim to still be thinking, so a
24222531 // settled turn cannot shimmer "Thinking" at the user forever.
24232532 const streaming =
2424 Boolean(message.streaming) &&
2533 row.id === LIVE_ACTIVITY_ROW_ID &&
2534 messages.some((reasoningMessage) => reasoningMessage.streaming) &&
24252535 isWorking &&
24262536 message.turnId !== null &&
24272537 message.turnId === unsettledTurnId;
24282538 const expanded = ctx.expandedReasoningMessageIds.has(message.id);
24292539 const { onToggleReasoning } = ctx;
24302540 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})` : ""}`;
24342544
2435 if (message.text.trim().length === 0 && !streaming) {
2545 if (
2546 messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
2547 !streaming
2548 ) {
24362549 return null;
24372550 }
24382551
......@@ -2466,18 +2579,21 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
24662579 </span>
24672580 </button>
24682581 {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 ))}
24812597 </div>
24822598 ) : null}
24832599 </div>
......@@ -2529,19 +2645,21 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
25292645/** Renders standalone activity or one bounded, virtualized expanded tool group. */
25302646const WorkGroupSection = memo(function WorkGroupSection({
25312647 anchorKey,
2648 disclosureAnchorKey = anchorKey,
25322649 groupedEntries,
25332650 isExpandedToolGroup,
25342651 displayLabel,
25352652}: {
25362653 anchorKey: string;
2654 disclosureAnchorKey?: string;
25372655 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
25382656 isExpandedToolGroup: boolean;
25392657 displayLabel?: string | undefined;
25402658}) {
25412659 const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx);
25422660 const onToggleStandaloneEntry = useCallback(
2543 (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed),
2544 [anchorKey, onToggleWorkEntry],
2661 (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed),
2662 [disclosureAnchorKey, onToggleWorkEntry],
25452663 );
25462664 const nonEmptyEntries = useMemo(
25472665 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
......@@ -2554,6 +2672,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
25542672 <ExpandedWorkGroupEntries
25552673 key={`${routeThreadKey}:${anchorKey}`}
25562674 anchorKey={anchorKey}
2675 disclosureAnchorKey={disclosureAnchorKey}
25572676 entries={nonEmptyEntries}
25582677 workspaceRoot={workspaceRoot}
25592678 />
......@@ -2580,10 +2699,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
25802699
25812700function ExpandedWorkGroupEntries({
25822701 anchorKey,
2702 disclosureAnchorKey,
25832703 entries,
25842704 workspaceRoot,
25852705}: {
25862706 anchorKey: string;
2707 disclosureAnchorKey: string;
25872708 entries: TimelineWorkEntry[];
25882709 workspaceRoot: string | undefined;
25892710}) {
......@@ -2609,9 +2730,9 @@ function ExpandedWorkGroupEntries({
26092730 const groupView = useMemo(
26102731 () => ({
26112732 state: viewState,
2612 onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed),
2733 onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed),
26132734 }),
2614 [anchorKey, onToggleWorkEntry, viewState],
2735 [disclosureAnchorKey, onToggleWorkEntry, viewState],
26152736 );
26162737 const updateScrollFades = useCallback(() => {
26172738 const element = listRef.current?.getScrollableNode();