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", () => {...@@ -2684,8 +2684,10 @@ describe("deriveMessagesTimelineRows", () => {
2684 it("reuses one activity row for initial thinking and the latest tool", () => {2684 it("reuses one activity row for initial thinking and the latest tool", () => {
2685 const deriveRows = (2685 const deriveRows = (
2686 toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,2686 toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,
2687 expandedWorkGroupIds?: ReadonlySet<string>,
2687 ) =>2688 ) =>
2688 deriveMessagesTimelineRows({2689 deriveMessagesTimelineRows({
2690 ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}),
2689 timelineEntries:2691 timelineEntries:
2690 toolLifecycleStatus === null2692 toolLifecycleStatus === null
2691 ? []2693 ? []
...@@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => {
2733 expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });2735 expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
2734 expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);2736 expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);
2735 expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });2737 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 ]);
2736 expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });2751 expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
2737 expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });2752 expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
2738 expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);2753 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 =...@@ -509,6 +509,9 @@ type MessagesTimelineRowContent =
509 kind: "thinking";509 kind: "thinking";
510 id: string;510 id: string;
511 createdAt: string | null;511 createdAt: string | null;
512 /** Tool calls this row stands in for after the latest one failed. */
513 groupId?: string;
514 expanded?: boolean;
512 }515 }
513 | {516 | {
514 kind: "work-toggle";517 kind: "work-toggle";
...@@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: {...@@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: {
1737 !hasActiveCompaction &&1740 !hasActiveCompaction &&
1738 (!hasActivityRow || latestToolFailed)1741 (!hasActivityRow || latestToolFailed)
1739 ) {1742 ) {
1740 nextRows.push({1743 // A failed latest tool hands the row back to thinking, but its group
1741 kind: "thinking",1744 // stays reachable through the same disclosure the live row offers.
1742 id: LIVE_ACTIVITY_ROW_ID,1745 const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined;
1743 createdAt: input.activeTurnStartedAt ?? null,1746 if (failedGroupAnchor) {
1744 });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 }
1745 }1772 }
17461773
1747 const result = attachTrailingToolGroupsToAssistant(1774 const result = attachTrailingToolGroupsToAssistant(
...@@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
19521979
1953 switch (a.kind) {1980 switch (a.kind) {
1954 case "working":1981 case "working":
1955 case "thinking":
1956 return a.createdAt === (b as typeof a).createdAt;1982 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 }
1957 case "worktree-setup":1987 case "worktree-setup":
1958 return a.snapshot === (b as typeof a).snapshot;1988 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...@@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1741 row.kind === "work-toggle" ||1741 row.kind === "work-toggle" ||
1742 row.kind === "thinking";1742 row.kind === "thinking";
1743 const isExpandedToolGroupHeader =1743 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
1746 return (1747 return (
1747 <div1748 <div
...@@ -1793,7 +1794,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1793,7 +1794,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1793 ) : null}1794 ) : null}
1794 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}1795 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
1795 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}1796 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
1796 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}1797 {row.kind === "thinking" ? <ThinkingTimelineRow row={row} /> : null}
1797 </WorkLogBlock>1798 </WorkLogBlock>
1798 ) : null}1799 ) : null}
1799 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}1800 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
...@@ -3461,13 +3462,23 @@ function CompactingLabel() {...@@ -3461,13 +3462,23 @@ function CompactingLabel() {
3461 );3462 );
3462}3463}
34633464
3464function ThinkingTimelineRow() {3465function ThinkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "thinking" }> }) {
3466 const ctx = use(TimelineRowCtx);
3465 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);3467 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
3466 // Reserve the activity row during setup so the handoff keeps the same height.3468 // Reserve the activity row during setup so the handoff keeps the same height.
3467 return isPreparingWorktree || isCompacting ? (3469 if (isPreparingWorktree || isCompacting) return <WorkLogRow label="" />;
3468 <WorkLogRow label="" />3470 const activity = <LiveActivityRow label="Thinking" iconName="brain" active shimmer />;
3469 ) : (3471 const { groupId } = row;
3470 <LiveActivityRow label="Thinking" iconName="brain" active shimmer />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>
3471 );3482 );
3472}3483}
34733484