| 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,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 === null | 2692 | 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 | } |
| 1746 | 1773 | ||
| 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 |
| 1952 | 1979 | ||
| 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; |
| 1959 | 1989 |
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; | ||
| 1745 | 1746 | ||
| 1746 | return ( | 1747 | return ( |
| 1747 | <div | 1748 | <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 | } |
| 3463 | 3464 | ||
| 3464 | function ThinkingTimelineRow() { | 3465 | function 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 | } |
| 3473 | 3484 |