| author | |
| committer | |
| log | 70e5a40c20ddbaab7831ab36e17e64c9908269a5 |
| tree | 479c67d6975211fb484d01152ad43787e3d8b510 |
| parent | cfdff56f7b3c9c931ae368224041aa14825649fd |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 69 insertions(+), 13 deletions(-)
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+15| ... | ... | @@ -2684,8 +2684,10 @@ describe("deriveMessagesTimelineRows", () => { |
| 2684 | 2684 | it("reuses one activity row for initial thinking and the latest tool", () => { |
| 2685 | 2685 | const deriveRows = ( |
| 2686 | 2686 | toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null, |
| 2687 | expandedWorkGroupIds?: ReadonlySet<string>, | |
| 2687 | 2688 | ) => |
| 2688 | 2689 | deriveMessagesTimelineRows({ |
| 2690 | ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}), | |
| 2689 | 2691 | timelineEntries: |
| 2690 | 2692 | toolLifecycleStatus === null |
| 2691 | 2693 | ? [] |
| ... | ... | @@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => { |
| 2733 | 2735 | expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); |
| 2734 | 2736 | expect(failedRows.some((row) => row.kind === "work-live")).toBe(false); |
| 2735 | 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 | 2751 | expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); |
| 2737 | 2752 | expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); |
| 2738 | 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 | 509 | kind: "thinking"; |
| 510 | 510 | id: string; |
| 511 | 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 | 517 | kind: "work-toggle"; |
| ... | ... | @@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: { |
| 1737 | 1740 | !hasActiveCompaction && |
| 1738 | 1741 | (!hasActivityRow || latestToolFailed) |
| 1739 | 1742 | ) { |
| 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 | } | |
| 1745 | 1772 | } |
| 1746 | 1773 | |
| 1747 | 1774 | const result = attachTrailingToolGroupsToAssistant( |
| ... | ... | @@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 1952 | 1979 | |
| 1953 | 1980 | switch (a.kind) { |
| 1954 | 1981 | case "working": |
| 1955 | case "thinking": | |
| 1956 | 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 | 1987 | case "worktree-setup": |
| 1958 | 1988 | return a.snapshot === (b as typeof a).snapshot; |
| 1959 | 1989 |
apps/web/src/components/chat/MessagesTimeline.tsx+18-7| ... | ... | @@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1741 | 1741 | row.kind === "work-toggle" || |
| 1742 | 1742 | row.kind === "thinking"; |
| 1743 | 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; | |
| 1745 | 1746 | |
| 1746 | 1747 | return ( |
| 1747 | 1748 | <div |
| ... | ... | @@ -1793,7 +1794,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1793 | 1794 | ) : null} |
| 1794 | 1795 | {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null} |
| 1795 | 1796 | {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null} |
| 1796 | {row.kind === "thinking" ? <ThinkingTimelineRow /> : null} | |
| 1797 | {row.kind === "thinking" ? <ThinkingTimelineRow row={row} /> : null} | |
| 1797 | 1798 | </WorkLogBlock> |
| 1798 | 1799 | ) : null} |
| 1799 | 1800 | {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null} |
| ... | ... | @@ -3461,13 +3462,23 @@ function CompactingLabel() { |
| 3461 | 3462 | ); |
| 3462 | 3463 | } |
| 3463 | 3464 | |
| 3464 | function ThinkingTimelineRow() { | |
| 3465 | function ThinkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "thinking" }> }) { | |
| 3466 | const ctx = use(TimelineRowCtx); | |
| 3465 | 3467 | const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); |
| 3466 | 3468 | // 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> | |
| 3471 | 3482 | ); |
| 3472 | 3483 | } |
| 3473 | 3484 |