authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-03 16:41:56-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 16:41:56-03:00
log70e5a40c20ddbaab7831ab36e17e64c9908269a5
tree479c67d6975211fb484d01152ad43787e3d8b510
parentcfdff56f7b3c9c931ae368224041aa14825649fd
signature Signed by PGP key B5690EEEBB952194

fix(web): thinking row after a failed tool expands the run's tool calls (#15056)


3 files changed, 69 insertions(+), 13 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+15
......@@ -2684,8 +2684,10 @@ describe("deriveMessagesTimelineRows", () => {
26842684 it("reuses one activity row for initial thinking and the latest tool", () => {
26852685 const deriveRows = (
26862686 toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,
2687 expandedWorkGroupIds?: ReadonlySet<string>,
26872688 ) =>
26882689 deriveMessagesTimelineRows({
2690 ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}),
26892691 timelineEntries:
26902692 toolLifecycleStatus === null
26912693 ? []
......@@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => {
27332735 expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
27342736 expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);
27352737 expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
2738 const failedThinkingRow = failedRows.at(-1);
2739 const failedGroupId =
2740 failedThinkingRow?.kind === "thinking" ? failedThinkingRow.groupId : undefined;
2741 expect(failedGroupId).toBeDefined();
2742 const expandedFailedRows = deriveRows("failed", new Set([failedGroupId!]));
2743 expect(expandedFailedRows.slice(-2)).toMatchObject([
2744 { kind: "thinking", id: "live-activity-row", expanded: true },
2745 {
2746 kind: "work",
2747 isExpandedToolGroup: true,
2748 groupedEntries: [{ id: "latest-command" }],
2749 },
2750 ]);
27362751 expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
27372752 expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
27382753 expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
apps/web/src/components/chat/MessagesTimeline.logic.ts+36-6
......@@ -509,6 +509,9 @@ type MessagesTimelineRowContent =
509509 kind: "thinking";
510510 id: string;
511511 createdAt: string | null;
512 /** Tool calls this row stands in for after the latest one failed. */
513 groupId?: string;
514 expanded?: boolean;
512515 }
513516 | {
514517 kind: "work-toggle";
......@@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: {
17371740 !hasActiveCompaction &&
17381741 (!hasActivityRow || latestToolFailed)
17391742 ) {
1740 nextRows.push({
1741 kind: "thinking",
1742 id: LIVE_ACTIVITY_ROW_ID,
1743 createdAt: input.activeTurnStartedAt ?? null,
1744 });
1743 // A failed latest tool hands the row back to thinking, but its group
1744 // stays reachable through the same disclosure the live row offers.
1745 const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined;
1746 if (failedGroupAnchor) {
1747 const groupId = workGroupId(failedGroupAnchor.id);
1748 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
1749 nextRows.push({
1750 kind: "thinking",
1751 id: LIVE_ACTIVITY_ROW_ID,
1752 createdAt: input.activeTurnStartedAt ?? null,
1753 groupId,
1754 expanded,
1755 });
1756 if (expanded) {
1757 nextRows.push(
1758 expandedWorkGroupRow(
1759 groupId,
1760 failedGroupAnchor.createdAt,
1761 visibleActiveToolEntries.map((entry) => entry.entry),
1762 ),
1763 );
1764 }
1765 } else {
1766 nextRows.push({
1767 kind: "thinking",
1768 id: LIVE_ACTIVITY_ROW_ID,
1769 createdAt: input.activeTurnStartedAt ?? null,
1770 });
1771 }
17451772 }
17461773
17471774 const result = attachTrailingToolGroupsToAssistant(
......@@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
19521979
19531980 switch (a.kind) {
19541981 case "working":
1955 case "thinking":
19561982 return a.createdAt === (b as typeof a).createdAt;
1983 case "thinking": {
1984 const bt = b as typeof a;
1985 return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;
1986 }
19571987 case "worktree-setup":
19581988 return a.snapshot === (b as typeof a).snapshot;
19591989
apps/web/src/components/chat/MessagesTimeline.tsx+18-7
......@@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
17411741 row.kind === "work-toggle" ||
17421742 row.kind === "thinking";
17431743 const isExpandedToolGroupHeader =
1744 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
1744 (row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") &&
1745 row.expanded === true;
17451746
17461747 return (
17471748 <div
......@@ -1793,7 +1794,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
17931794 ) : null}
17941795 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
17951796 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
1796 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
1797 {row.kind === "thinking" ? <ThinkingTimelineRow row={row} /> : null}
17971798 </WorkLogBlock>
17981799 ) : null}
17991800 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
......@@ -3461,13 +3462,23 @@ function CompactingLabel() {
34613462 );
34623463}
34633464
3464function ThinkingTimelineRow() {
3465function ThinkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "thinking" }> }) {
3466 const ctx = use(TimelineRowCtx);
34653467 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
34663468 // Reserve the activity row during setup so the handoff keeps the same height.
3467 return isPreparingWorktree || isCompacting ? (
3468 <WorkLogRow label="" />
3469 ) : (
3470 <LiveActivityRow label="Thinking" iconName="brain" active shimmer />
3469 if (isPreparingWorktree || isCompacting) return <WorkLogRow label="" />;
3470 const activity = <LiveActivityRow label="Thinking" iconName="brain" active shimmer />;
3471 const { groupId } = row;
3472 if (groupId === undefined) return activity;
3473 return (
3474 <button
3475 type="button"
3476 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"
3477 aria-expanded={row.expanded === true}
3478 onClick={() => ctx.onToggleWorkGroup(groupId, row.id)}
3479 >
3480 {activity}
3481 </button>
34713482 );
34723483}
34733484