diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index db02f8839b..50dba8b580 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -257,7 +257,12 @@ describe("buildConversationItems", () => { ); // Spinner row (now complete) + the failure row. expect(statusItems.map((i) => i.update)).toEqual([ - { sessionUpdate: "status", status: "compacting", isComplete: true }, + { + sessionUpdate: "status", + status: "compacting", + isComplete: true, + startedAt: 2, + }, { sessionUpdate: "status", status: "compacting_failed", diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index c46a2db492..d4386b921e 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -599,6 +599,7 @@ function handleNotification( sessionUpdate: "status", status: params.status, isComplete: params.isComplete, + startedAt: ts, }); return; } @@ -689,9 +690,15 @@ function markCompactingStatusComplete(b: ItemBuilder) { if ( item.type === "session_update" && item.update.sessionUpdate === "status" && - item.update.status === "compacting" + item.update.status === "compacting" && + !item.update.isComplete ) { - item.update.isComplete = true; + // Replace the row and its update with fresh objects rather than mutating + // in place. The incremental builder reuses item identity so memoized rows + // skip re-render; an in-place flip can be missed, leaving the finished row + // stuck with its spinner and a still-ticking timer. A new reference forces + // the completion to render (and the row to unmount). + b.items[i] = { ...item, update: { ...item.update, isComplete: true } }; return; } } diff --git a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index 1de866d66b..c4236c5799 100644 --- a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -33,6 +33,8 @@ export type RenderItem = sessionUpdate: "status"; status: string; isComplete?: boolean; + /** Epoch ms a `compacting` status began; drives the elapsed timer. */ + startedAt?: number; /** Set when a status ends in failure (e.g. a failed compaction) so the row renders the error. */ error?: string; /** Refusal statuses: display-only stop_details.explanation from the API. */ @@ -130,6 +132,7 @@ export const SessionUpdateView = memo(function SessionUpdateView({ - - - - Compacting conversation history... - - - - ); + return ; } // Generic status display for other statuses @@ -128,3 +125,43 @@ export function StatusNotificationView({ ); } + +/** + * In-flight compaction row. Compaction is a single streaming summarization call + * with no measurable percentage, so we pair the spinner with an indeterminate + * progress bar (constant motion, so it never reads as frozen) and a live + * elapsed-time counter, which is the one honest progress signal we have. + */ +function CompactingStatusView({ startedAt }: { startedAt?: number }) { + const [elapsed, setElapsed] = useState(() => + startedAt ? Date.now() - startedAt : 0, + ); + + useEffect(() => { + // Anchor to the persisted compaction start time so remounting this row + // (e.g. scrolling it out of and back into the virtualized list while + // compaction runs) keeps counting from when compaction began rather than + // resetting to zero. Fall back to mount time only if it's missing. + const start = startedAt ?? Date.now(); + const tick = () => setElapsed(Date.now() - start); + tick(); + const interval = setInterval(tick, 100); + return () => clearInterval(interval); + }, [startedAt]); + + return ( + + + + + Compacting conversation history... + + + {formatDuration(elapsed, 1)} + + + {/* Decorative: the spinner and the text above carry the accessible status. */} +