authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-17 14:15:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 14:15:48-03:00
logfbc8b960026e408c163ea55db8075bbf1a42d628
treec0db6b51bc7feabc0fb153e8d1a85a906a32399f
parentd4d5d12e8ba086cfbf79ca3adeb4156b46ead665
signature Signed by PGP key B5690EEEBB952194

fix(web): keep thoughts and failed tool calls in one activity row (#12270)


5 files changed, 151 insertions(+), 82 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+5-3
......@@ -2513,7 +2513,7 @@ describe("buildThreadFeed", () => {
25132513 });
25142514
25152515 it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(
2516 "preserves a %s boundary in expanded activity history",
2516 "keeps thoughts in order across a %s in expanded activity history",
25172517 (boundary) => {
25182518 const turnId = TurnId.make("reasoning-boundary");
25192519 const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({
......@@ -2576,8 +2576,10 @@ describe("buildThreadFeed", () => {
25762576 (entry) => entry.type === "message" && entry.message.role === "reasoning",
25772577 );
25782578 if (boundary === "failed-tool") {
2579 expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3);
2580 expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true);
2579 // A failed call stays inside the run instead of splitting it.
2580 expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
2581 { hasFailure: true, hiddenCount: 3 },
2582 ]);
25812583 }
25822584 expect(reasoningRows).toEqual(
25832585 messages.map((message) => ({
apps/mobile/src/lib/threadActivity.ts+1-4
......@@ -1901,10 +1901,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null {
19011901 !isContextCompactionActivityGroup(entry) &&
19021902 !isUserInputActivityGroup(entry) &&
19031903 entry.activities.every(
1904 (activity) =>
1905 !activity.workEntry.agentSpawn &&
1906 activity.workEntry.tone !== "error" &&
1907 !workEntryIndicatesToolFailure(activity.workEntry),
1904 (activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error",
19081905 )
19091906 ) {
19101907 return entry.turnId;
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+49-15
......@@ -2137,7 +2137,7 @@ describe("deriveMessagesTimelineRows", () => {
21372137 expect(rows[1]).toMatchObject({ entries: [thought] });
21382138 });
21392139
2140 it("shows each tool once across expanded activity histories separated by a failed tool", () => {
2140 it("keeps thoughts and tools in one activity row across a failed tool", () => {
21412141 const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
21422142 const tools = ["a", "b", "c"].map((id, index) => {
21432143 const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1");
......@@ -2161,23 +2161,57 @@ describe("deriveMessagesTimelineRows", () => {
21612161 supportsConversationRollback: false,
21622162 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
21632163 const rows = deriveMessagesTimelineRows(input);
2164 const expanded = deriveMessagesTimelineRows({
2164 expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]);
2165 expect(rows.at(-1)).toMatchObject({
2166 id: "live-activity-row",
2167 entries: [thought, ...tools],
2168 active: true,
2169 });
2170 const settled = deriveMessagesTimelineRows({
21652171 ...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 ]);
2172 timelineEntries: [
2173 thought,
2174 ...tools,
2175 reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"),
2176 { ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } },
2177 ],
2178 isWorking: false,
2179 activeTurnStartedAt: null,
2180 });
2181 expect(settled.map((row) => row.kind)).toEqual(["activity-group"]);
21792182 });
21802183
2184 it.each(["failed", "declined"] as const)(
2185 "settles the activity row while the latest tool is %s",
2186 (status) => {
2187 const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
2188 const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1");
2189 const rows = deriveMessagesTimelineRows({
2190 timelineEntries: [
2191 thought,
2192 {
2193 ...tool,
2194 entry: {
2195 ...tool.entry,
2196 command: "echo nope",
2197 toolCallId: "last-tool",
2198 toolLifecycleStatus: status,
2199 sourceActivityKind: "tool.completed" as const,
2200 },
2201 },
2202 ],
2203 runningTurnId: TurnId.make("turn-1"),
2204 isWorking: true,
2205 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2206 turnDiffSummaries: [],
2207 supportsConversationRollback: false,
2208 });
2209 expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]);
2210 expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false });
2211 expect(rows[2]).toMatchObject({ id: "live-activity-row" });
2212 },
2213 );
2214
21812215 it("folds mixed activity under worked-for and restores ordered details when expanded", () => {
21822216 const entries = [
21832217 reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),
apps/web/src/components/chat/MessagesTimeline.logic.ts+5-5
......@@ -312,7 +312,7 @@ export type TimelineLatestTurn = Pick<
312312 "turnId" | "state" | "startedAt" | "completedAt"
313313>;
314314
315export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
315const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
316316
317317type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>;
318318
......@@ -323,8 +323,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry {
323323 entry.entry.agentSpawn === undefined &&
324324 entry.entry.questionAnswer === undefined &&
325325 entry.entry.sourceActivityKind !== "context-compaction" &&
326 entry.entry.tone !== "error" &&
327 !workEntryDisplayIndicatesToolFailure(entry.entry);
326 entry.entry.tone !== "error";
328327}
329328
330329export type MessagesTimelineRow =
......@@ -391,7 +390,6 @@ export type MessagesTimelineRow =
391390 createdAt: string;
392391 message: ChatMessage;
393392 durationStart: string;
394 reasoningMessages?: ReadonlyArray<ChatMessage>;
395393 showAssistantMeta: boolean;
396394 showAssistantCopyButton: boolean;
397395 assistantCopyStreaming: boolean;
......@@ -1158,7 +1156,9 @@ export function deriveMessagesTimelineRows(input: {
11581156 const active =
11591157 input.isWorking &&
11601158 activityTurnId === unsettledTurnId &&
1161 cursor === input.timelineEntries.length;
1159 cursor === input.timelineEntries.length &&
1160 !latestToolFailed &&
1161 (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive);
11621162 const groupId =
11631163 timelineEntry.kind === "work"
11641164 ? workGroupId(timelineEntry.id, timelineEntry.entry)
apps/web/src/components/chat/MessagesTimeline.tsx+91-55
......@@ -171,7 +171,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
171171import {
172172 computeStableMessagesTimelineRows,
173173 deriveMessagesTimelineRowsWithState,
174 LIVE_ACTIVITY_ROW_ID,
175174 deriveUnsettledTurnId,
176175 type MessagesTimelineRowsProjection,
177176 liveWorkEntryLabel,
......@@ -2606,6 +2605,7 @@ function ActivityGroupTimelineRow({
26062605 const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1);
26072606 const thinking = row.active && liveWork === undefined;
26082607 const iconWork = row.active ? liveWork : work.at(-1);
2608 const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork);
26092609 const label = row.active
26102610 ? liveWork
26112611 ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true)
......@@ -2639,20 +2639,11 @@ function ActivityGroupTimelineRow({
26392639 if (next.kind === "message") messages.push(next.message);
26402640 }
26412641 details.push(
2642 <ReasoningTimelineRow
2642 <ReasoningTraceBlock
26432643 key={entry.id}
2644 disclosureAnchorKey={row.id}
2645 row={{
2646 kind: "message",
2647 id: row.active && index === row.entries.length - 1 ? LIVE_ACTIVITY_ROW_ID : entry.id,
2648 createdAt: entry.createdAt,
2649 message: entry.message,
2650 reasoningMessages: messages,
2651 durationStart: entry.createdAt,
2652 showAssistantMeta: false,
2653 showAssistantCopyButton: false,
2654 assistantCopyStreaming: false,
2655 }}
2644 messages={messages}
2645 live={row.active && index === row.entries.length - 1}
2646 showHeader={work.length > 0}
26562647 />,
26572648 );
26582649 }
......@@ -2663,6 +2654,7 @@ function ActivityGroupTimelineRow({
26632654 <button
26642655 type="button"
26652656 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"
2657 aria-label={failed ? `${label}, tool call failed` : undefined}
26662658 aria-expanded={row.expanded}
26672659 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
26682660 >
......@@ -2670,6 +2662,7 @@ function ActivityGroupTimelineRow({
26702662 label={label}
26712663 iconName={iconWork ? workEntryIconName(iconWork) : "brain"}
26722664 toolIcon={iconWork?.toolIcon ?? iconWork?.toolSource?.icon}
2665 failed={failed}
26732666 active={row.active}
26742667 shimmer={thinking}
26752668 />
......@@ -2691,6 +2684,73 @@ function ThinkingTimelineRow() {
26912684 );
26922685}
26932686
2687/**
2688 * Thinking inside an expanded activity group: the trace is already one click
2689 * deep, so the text renders under its "Thought" header without another toggle.
2690 * A group whose row already reads "Thought" (no visible tool) skips the header.
2691 */
2692function ReasoningTraceBlock({
2693 messages,
2694 live,
2695 showHeader,
2696}: {
2697 messages: ReadonlyArray<ChatMessage>;
2698 live: boolean;
2699 showHeader: boolean;
2700}) {
2701 const ctx = use(TimelineRowCtx);
2702 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
2703 const first = messages[0]!;
2704 const streaming =
2705 live &&
2706 messages.some((reasoningMessage) => reasoningMessage.streaming) &&
2707 isWorking &&
2708 first.turnId !== null &&
2709 first.turnId === unsettledTurnId;
2710 if (
2711 messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
2712 !streaming
2713 ) {
2714 return null;
2715 }
2716 const label = streaming ? "Thinking" : "Thought";
2717 return (
2718 <div className="flex flex-col">
2719 {showHeader ? (
2720 <div className="flex min-h-6 select-none items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed">
2721 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
2722 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
2723 </span>
2724 <span
2725 ref={streaming ? observeVisibleAnimation : undefined}
2726 className="relative min-w-0 flex-1 truncate text-secondary-label"
2727 >
2728 {label}
2729 {streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2730 </span>
2731 </div>
2732 ) : null}
2733 <div className="ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
2734 {messages.map((reasoningMessage) => (
2735 <ChatMarkdown
2736 key={reasoningMessage.id}
2737 className="text-foreground"
2738 text={reasoningMessage.text}
2739 cwd={ctx.markdownCwd}
2740 threadRef={ctx.threadRef ?? undefined}
2741 isStreaming={streaming && reasoningMessage.streaming}
2742 lineBreaks
2743 skills={ctx.skills}
2744 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2745 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2746 onImageExpand={ctx.onImageExpand}
2747 />
2748 ))}
2749 </div>
2750 </div>
2751 );
2752}
2753
26942754/**
26952755 * A provider's thinking trace. Collapsed by default: reasoning is context for
26962756 * the answer, not the answer. The open/closed flag lives on the list so it
......@@ -2698,35 +2758,18 @@ function ThinkingTimelineRow() {
26982758 */
26992759const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
27002760 row,
2701 disclosureAnchorKey = row.id,
27022761}: {
27032762 row: Extract<TimelineRow, { kind: "message" }>;
2704 disclosureAnchorKey?: string;
27052763}) {
27062764 const ctx = use(TimelineRowCtx);
2707 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
27082765 const { message } = row;
2709 const messages = row.reasoningMessages ?? [message];
2710 // A block left open by a crashed provider or a restarted server never gets
2711 // its completion. Only the live turn may claim to still be thinking, so a
2712 // settled turn cannot shimmer "Thinking" at the user forever.
2713 const streaming =
2714 row.id === LIVE_ACTIVITY_ROW_ID &&
2715 messages.some((reasoningMessage) => reasoningMessage.streaming) &&
2716 isWorking &&
2717 message.turnId !== null &&
2718 message.turnId === unsettledTurnId;
27192766 const expanded = ctx.expandedReasoningMessageIds.has(message.id);
27202767 const { onToggleReasoning } = ctx;
27212768 const toggle = useCallback(() => {
2722 onToggleReasoning(message.id, !expanded, disclosureAnchorKey);
2723 }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]);
2724 const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`;
2769 onToggleReasoning(message.id, !expanded, row.id);
2770 }, [expanded, message.id, row.id, onToggleReasoning]);
27252771
2726 if (
2727 messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
2728 !streaming
2729 ) {
2772 if (message.text.trim().length === 0) {
27302773 return null;
27312774 }
27322775
......@@ -2742,12 +2785,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
27422785 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
27432786 </span>
27442787 <span className="flex min-w-0 flex-1 items-center gap-1.5">
2745 <span
2746 ref={streaming ? observeVisibleAnimation : undefined}
2747 className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed"
2748 >
2749 {label}
2750 {streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2788 <span className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed">
2789 Thought
27512790 </span>
27522791 <span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
27532792 <ChevronRightIcon
......@@ -2760,21 +2799,18 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
27602799 </span>
27612800 </button>
27622801 {expanded ? (
2763 <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">
2764 {messages.map((reasoningMessage) => (
2765 <ChatMarkdown
2766 key={reasoningMessage.id}
2767 text={reasoningMessage.text}
2768 cwd={ctx.markdownCwd}
2769 threadRef={ctx.threadRef ?? undefined}
2770 isStreaming={streaming && reasoningMessage.streaming}
2771 lineBreaks
2772 skills={ctx.skills}
2773 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2774 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2775 onImageExpand={ctx.onImageExpand}
2776 />
2777 ))}
2802 <div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
2803 <ChatMarkdown
2804 className="text-foreground"
2805 text={message.text}
2806 cwd={ctx.markdownCwd}
2807 threadRef={ctx.threadRef ?? undefined}
2808 lineBreaks
2809 skills={ctx.skills}
2810 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2811 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2812 onImageExpand={ctx.onImageExpand}
2813 />
27782814 </div>
27792815 ) : null}
27802816 </div>