From 49b5bb71a5414ac44a08f72fa4f47681fce0624d Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 23 Jul 2026 12:25:32 +0530 Subject: [PATCH] feat(conversations): unify agent activity into one live rail (#5141) --- .../components/AgentProcessSourcePanel.tsx | 10 +- .../components/ChatThreadView.tsx | 72 ++- .../components/ProcessingTranscriptView.tsx | 84 ++- .../components/ToolTimelineBlock.tsx | 365 +++++++---- .../__tests__/ToolTimelineBlock.test.tsx | 596 +++++++++++++++++- .../utils/interimNarration.test.ts | 84 +++ .../conversations/utils/interimNarration.ts | 62 ++ app/src/providers/ChatRuntimeProvider.tsx | 49 +- .../__tests__/ChatRuntimeProvider.test.tsx | 60 +- .../tinyagents/payload_summarizer.rs | 29 +- 10 files changed, 1199 insertions(+), 212 deletions(-) create mode 100644 app/src/features/conversations/utils/interimNarration.test.ts create mode 100644 app/src/features/conversations/utils/interimNarration.ts diff --git a/app/src/features/conversations/components/AgentProcessSourcePanel.tsx b/app/src/features/conversations/components/AgentProcessSourcePanel.tsx index da25b8461..957afd715 100644 --- a/app/src/features/conversations/components/AgentProcessSourcePanel.tsx +++ b/app/src/features/conversations/components/AgentProcessSourcePanel.tsx @@ -168,7 +168,15 @@ export function AgentProcessSourcePanel({ ) ) : transcript.length > 0 ? ( // Hermes-style interleaved narration + grouped, human-labeled steps. - + // `renderSubagent` restores the nested child-run activity the + // legacy fallback below always had — without it a delegated + // sub-agent collapsed to a single line here, hiding every tool + // call it made. + } + /> ) : entries.length > 0 ? ( // Legacy snapshot (no transcript): fall back to the tool timeline, // which already nests each sub-agent's full activity inline. diff --git a/app/src/features/conversations/components/ChatThreadView.tsx b/app/src/features/conversations/components/ChatThreadView.tsx index 3fcded12b..c72cc3262 100644 --- a/app/src/features/conversations/components/ChatThreadView.tsx +++ b/app/src/features/conversations/components/ChatThreadView.tsx @@ -26,6 +26,7 @@ import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; import { ShareMessageButton } from '../../share/ShareMessageButton'; import { buildThreadTimeline } from '../timeline/selectors'; import { type AgentBubblePosition, formatRelativeTime } from '../utils/format'; +import { supersededInterimIndexes } from '../utils/interimNarration'; import { AgentMessageBubble, AgentMessageText, BubbleMarkdown } from './AgentMessageBubble'; import { AgentProcessSourcePanel } from './AgentProcessSourcePanel'; import { BackgroundProcessesPanel, selectBackgroundProcesses } from './BackgroundProcessesPanel'; @@ -265,7 +266,19 @@ export const ChatThreadView = forwardRef entry.subagent?.taskId === openSubagentTaskId) : undefined; - const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); + // Interim narration bubbles ("Let me get the data for both.", "The HTML is + // hard to parse. Let me search for a clean table.") are live progress, not + // content: once the turn delivers its real answer they are superseded, and + // because they were never filtered they piled up permanently between the + // question and the answer. Hidden once their own turn produced a final, + // non-interim agent message — so a turn in flight keeps them visible, an + // answered turn drops them, and a turn that died before answering keeps + // them (they are its only record). They remain reachable via "View full + // agent process Source"; the Flows copilot drops them outright. + const supersededInterim = useMemo(() => supersededInterimIndexes(messages), [messages]); + const visibleMessages = messages.filter( + (msg, index) => !msg.extraMetadata?.hidden && !supersededInterim.has(index) + ); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; const latestVisibleAgentMessage = [...visibleMessages] @@ -441,6 +454,11 @@ export const ChatThreadView = forwardRef ) : ( // Transcript-only turn: reasoning/narration was streamed but no tool @@ -954,37 +972,29 @@ export const ChatThreadView = forwardRef 0 || - selectedStreamingAssistant.content.length > 0) && ( -
-
- {selectedStreamingAssistant.thinking.length > 0 && ( -
- - - {t('chat.thinking')} - -
-                            {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
-                          
-
- )} - {selectedStreamingAssistant.content.length > 0 && ( -
-

- {selectedStreamingAssistant.content.length > - STREAMING_PREVIEW_CHARS && ( - - )} - {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} - -

-
- )} -
+ {selectedStreamingAssistant && selectedStreamingAssistant.content.length > 0 && ( +
+
+ {/* Reasoning is not rendered here — the rail above renders + the same reasoning through `ProcessingTranscriptView`, + interleaved with the narration and tool steps it + happened between. A separate bubble would show it twice + with two lifetimes. The in-flight ANSWER preview below + stays: that is the terminal response streaming in. */} + {selectedStreamingAssistant.content.length > 0 && ( +
+

+ {selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && ( + + )} + {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + +

+
+ )}
- )} +
+ )} {/* Interrupted turn's partial answer (restore-fidelity fix 2): a settled, marked-interrupted bubble surfaced on restore. Only when NOT streaming live (the buffer is cleared by any live turn diff --git a/app/src/features/conversations/components/ProcessingTranscriptView.tsx b/app/src/features/conversations/components/ProcessingTranscriptView.tsx index 321d57ab5..a559037e2 100644 --- a/app/src/features/conversations/components/ProcessingTranscriptView.tsx +++ b/app/src/features/conversations/components/ProcessingTranscriptView.tsx @@ -27,9 +27,22 @@ import { ToolFailureLines } from './ToolFailureLines'; export function ProcessingTranscriptView({ transcript, entries, + renderSubagent, }: { transcript: ProcessingTranscriptItem[]; entries: ToolTimelineEntry[]; + /** + * Renders a delegated sub-agent's nested activity (its own child tool calls, + * transcript and thoughts) under the row that spawned it. + * + * Injected rather than imported because `SubagentActivityBlock` lives in + * `ToolTimelineBlock`, which imports THIS component for the inline rail — + * importing it back would be a cycle. Without this, a `subagent:*` row + * rendered as a bare one-line step and every child tool call it made was + * invisible, even though the entry carries them. Omit it and rows degrade to + * that one-line form rather than breaking. + */ + renderSubagent?: (subagent: NonNullable) => React.ReactNode; }) { const blocks = buildProcessingBlocks(transcript, entries); if (blocks.length === 0) return null; @@ -50,7 +63,14 @@ export function ProcessingTranscriptView({ if (block.kind === 'thinking') { return ; } - return ; + return ( + + ); })}
); @@ -84,7 +104,15 @@ function ThinkingBlock({ text }: { text: string }) { } /** A collapsible group of consecutive tool rows under a human summary. */ -function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTimelineEntry[] }) { +function ToolGroupBlock({ + summary, + entries, + renderSubagent, +}: { + summary: string; + entries: ToolTimelineEntry[]; + renderSubagent?: (subagent: NonNullable) => React.ReactNode; +}) { const { t } = useT(); const allSettled = entries.every(e => e.status !== 'running'); const anyError = entries.some(e => e.status === 'error'); @@ -98,7 +126,7 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi
    {entries.map(entry => ( - + ))} {allSettled ? (
  • @@ -114,24 +142,42 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi } /** One tool step: type icon + human sentence + contextual detail chip. */ -function ToolRow({ entry }: { entry: ToolTimelineEntry }) { +function ToolRow({ + entry, + renderSubagent, +}: { + entry: ToolTimelineEntry; + renderSubagent?: (subagent: NonNullable) => React.ReactNode; +}) { const { title, detail } = formatTimelineEntry(entry); return ( -
  • - - - - - {title} - {detail ? ( - - {detail} - - ) : null} - {entry.status === 'error' && entry.failure ? ( - - ) : null} - +
  • +
    + + + + + {title} + {detail ? ( + + {detail} + + ) : null} + {entry.status === 'error' && entry.failure ? ( + + ) : null} + +
    + {/* A delegated sub-agent's own tool calls hang off the parent entry, so + without this the whole child run collapsed into this single line. + Rendered as a `
    ` SIBLING under the `
  • ` (indented past the + icon), not nested inside the label `` — SubagentActivityBlock + renders a `
    `, and `
    `-inside-`` is invalid nesting. */} + {entry.subagent && renderSubagent ? ( +
    + {renderSubagent(entry.subagent)} +
    + ) : null}
  • ); } diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index 33249b07f..a3109d3b4 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,9 +1,10 @@ import createDebug from 'debug'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; import type { + ProcessingTranscriptItem, SubagentActivity, ToolFailureExplanation, ToolTimelineEntry, @@ -18,6 +19,7 @@ import { import { parseWorkerThreadRef } from '../utils/workerThreadRef'; import { BubbleMarkdown } from './AgentMessageBubble'; import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail'; +import { ProcessingTranscriptView } from './ProcessingTranscriptView'; import { ToolFailureLines } from './ToolFailureLines'; import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard'; @@ -447,6 +449,18 @@ const BODY_SURFACE = 'bg-surface-muted'; * collapsible "⚙️ Working… / Agentic task insights" header so the user can * fold the live activity away. */ +/** + * Height of the in-flight timeline viewport. While a turn is active the row + * list is windowed to this height and auto-follows the newest activity, so a + * long run (dozens of steps, each able to expand) can no longer grow without + * bound and shove the composer + message list around mid-turn. Settled turns + * are untouched — they still collapse to the group summary as before. + */ +const TIMELINE_VIEWPORT_CLASS = 'max-h-64 overflow-y-auto overscroll-contain'; + +/** Distance from the bottom (px) still treated as "pinned to the live edge". */ +const STICK_TO_BOTTOM_SLACK_PX = 24; + export function ToolTimelineBlock({ entries, onViewSubagent, @@ -455,6 +469,7 @@ export function ToolTimelineBlock({ expandAllRows = false, liveResponse, turnActive, + transcript, }: { entries: ToolTimelineEntry[]; /** Opens the full-transcript drawer for a subagent row. When omitted, @@ -490,6 +505,14 @@ export function ToolTimelineBlock({ * omitted, which is correct for a settled/past-turn render (there is no * turn left to track). */ turnActive?: boolean; + /** The turn's interleaved processing transcript (narration + thinking + tool + * pointers, in stream order). When non-empty the rail renders it through + * {@link ProcessingTranscriptView} — the SAME component the Agent Process + * Source panel uses — so the agent's prose and its tool steps appear in one + * surface instead of narration living in the chat stream and thinking in a + * separate bubble. Omitted/empty falls back to the tool-row list, which is + * still correct for legacy snapshots that predate the transcript. */ + transcript?: ProcessingTranscriptItem[]; }) { const { t } = useT(); @@ -550,7 +573,82 @@ export function ToolTimelineBlock({ setPrevSettleSignal(settleSignal); } - if (entries.length === 0) return null; + // ── In-flight viewport: fixed height + auto-follow ────────────────────── + // Windowed ONLY while the turn is in flight, and never under + // `expandAllRows` (the Agent Process Source panel wants the full list, not + // a 16rem porthole). + // + // Keyed off `turnActive` directly rather than `settleSignal` — the latter + // falls back to `isRunning`, which flips once per SUB-AGENT inside a single + // turn and would re-window + re-pin the viewport repeatedly mid-turn (the + // same failure class as the #5008 collapse flicker). Callers that pass no + // `turnActive` (settled/past-turn renders) never window at all, so nothing + // about historical turns changes. + const windowed = turnActive === true && !expandAllRows; + const viewportRef = useRef(null); + // Whether to keep pinning to the newest activity. A plain ref, not state: + // no render output depends on it, and mutating it from the scroll handler + // must not trigger a re-render on every wheel tick. + const followTailRef = useRef(true); + + // Re-pin whenever a new turn starts windowing, so a user who scrolled up + // during the previous turn isn't stuck detached for the next one. + useEffect(() => { + if (windowed) followTailRef.current = true; + }, [windowed]); + + // Detach on scroll-up so reading an earlier step doesn't get yanked back + // down by the next tool event; re-attach when they return to the bottom. + const handleViewportScroll = () => { + const el = viewportRef.current; + if (!el) return; + followTailRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_TO_BOTTOM_SLACK_PX; + }; + + // Follow the live edge. A ResizeObserver on the row list (rather than an + // effect keyed on row count) catches every way the content grows: a new row + // arriving, the running row auto-expanding, and `tool_args_delta` streaming + // into an already-expanded row — the last of which changes no React key at + // all and would otherwise silently stop following mid-tool. + // + // Attached via a CALLBACK REF, not a `useEffect([windowed])`. The effect + // version never attached in a real turn: `windowed` flips true at the START + // of the turn, when there is nothing to show yet, so the component returned + // null, `viewportRef.current` was null, and the effect bailed. Rows arriving + // afterwards re-rendered the viewport but did not change `windowed`, so the + // effect never re-ran and no observer was ever created. A callback ref fires + // whenever the node itself mounts or changes, which is exactly the event we + // care about, and is immune to that ordering entirely. + const observerRef = useRef(null); + const windowedRef = useRef(windowed); + windowedRef.current = windowed; + const attachViewport = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + viewportRef.current = node; + if (!node || typeof ResizeObserver === 'undefined') return; + // Children are in the DOM by the time a parent's ref callback runs. + const inner = node.firstElementChild; + if (!inner) return; + const observer = new ResizeObserver(() => { + // Read the live values through refs so the observer survives a settle → + // re-arm without being torn down and rebuilt. + if (!windowedRef.current || !followTailRef.current) return; + // `auto`, not `smooth`: streaming deltas fire these back-to-back, and + // queued smooth scrolls visibly lag behind the content. + node.scrollTop = node.scrollHeight; + }); + observer.observe(inner); + observerRef.current = observer; + }, []); + useEffect(() => () => observerRef.current?.disconnect(), []); + + // Render whenever there is EITHER a tool row or transcript prose. Gating on + // `entries` alone blanked the rail for the opening stretch of every turn — + // narration streams before the first tool call — and hid a tool-less turn + // (pure reasoning/narration) completely. + if (entries.length === 0 && !(transcript && transcript.length > 0)) return null; // The rows + the parent's streaming response — shared by both the collapsible // (in-flight) and static (settled) header layouts below. @@ -594,115 +692,153 @@ export function ToolTimelineBlock({ const body = ( <> -
    - {rows.map(({ entry, count }, index) => { - const formatted = formatTimelineEntry(entry); - const detailContent = - normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer); - const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail); - const subagent = entry.subagent; - const resultContent = normalizeToolBody(entry.result); - // A subagent row should always render the expandable details so - // its live activity is visible — even when there is no prompt - // detail to show. Mirrors the rule that a non-subagent row only - // expands when it has detail content (or a result to show). - const expandable = detailContent != null || subagent != null || resultContent != null; - const isLatestRunning = latestRunningEntryId != null && latestRunningEntryId === entry.id; - const shouldAutoExpand = expandAllRows || isLatestRunning; - const nameTone = agentNameTone(entry.status); - // Chat mode: the currently-running step stays expanded inline in the - // main UI; finished steps collapse to a compact "View details →" link - // (their full activity lives in the side panel). - const compact = onViewDetails != null && !isLatestRunning; + {/* Viewport wrapper. Stays in the tree in both modes so the row list is + never remounted (and its
    state never reset) when a turn + settles — only the height/scroll classes toggle. */} +
    + {transcript && transcript.length > 0 ? ( + ( + onViewSubagent(subagent) : undefined} + /> + )} + /> + ) : ( +
    + {rows.map(({ entry, count }, index) => { + const formatted = formatTimelineEntry(entry); + const detailContent = + normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer); + const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail); + const subagent = entry.subagent; + const resultContent = normalizeToolBody(entry.result); + // A subagent row should always render the expandable details so + // its live activity is visible — even when there is no prompt + // detail to show. Mirrors the rule that a non-subagent row only + // expands when it has detail content (or a result to show). + const expandable = detailContent != null || subagent != null || resultContent != null; + const isLatestRunning = + latestRunningEntryId != null && latestRunningEntryId === entry.id; + const shouldAutoExpand = expandAllRows || isLatestRunning; + const nameTone = agentNameTone(entry.status); + // Chat mode: the currently-running step stays expanded inline in the + // main UI; finished steps collapse to a compact "View details →" link + // (their full activity lives in the side panel). + const compact = onViewDetails != null && !isLatestRunning; - return ( - - {compact ? ( - // Collapsed step: the whole label is the link — "Run Code →" - // opens the full-run panel scoped to this step. A collapsed row - // is backgrounded, so it never pulses — only the single active - // (expanded) step blinks. Strip `animate-pulse` from the tone. -
    - - {resultContent ? ( -
    -                      {resultContent}
    -                    
    - ) : null} -
    - ) : expandable ? ( -
    - - {formatted.title} - - ▶ - - - {workerRef ? ( -
    - {workerRef.before} - - {workerRef.after ?
    {workerRef.after}
    : null} + return ( + + {compact ? ( + // Collapsed step: the whole label is the link — "Run Code →" + // opens the full-run panel scoped to this step. A collapsed row + // is backgrounded, so it never pulses — only the single active + // (expanded) step blinks. Strip `animate-pulse` from the tone. +
    + + {/* Output stays inline for FAILED steps only. On a success + the agent's final answer is already the compression of + what the tool returned, so repeating the raw result here + just duplicates it — and a multi-tool turn stacked a + scrollable
     per step above the answer. A failure is
    +                        the case where the answer is least trustworthy (or may
    +                        not mention the failure at all), so the evidence earns
    +                        its space. Successful output is still one click away via
    +                        this row's "→" and "View full agent process Source", and
    +                        expanded rows / the process panel are unchanged. */}
    +                      {resultContent && entry.status === 'error' ? (
    +                        
    +                          {resultContent}
    +                        
    + ) : null}
    - ) : formatted.detail ? ( -
    - {formatted.detail} + ) : expandable ? ( +
    + + + {formatted.title} + + + ▶ + + + {workerRef ? ( +
    + {workerRef.before} + + {workerRef.after ?
    {workerRef.after}
    : null} +
    + ) : formatted.detail ? ( +
    + {formatted.detail} +
    + ) : detailContent ? ( +
    +                          {detailContent}
    +                        
    + ) : null} + {resultContent ? ( + // What the tool returned (size-capped upstream). Scrolls + // inside its own box so a long result never floods the + // timeline. +
    +                          {resultContent}
    +                        
    + ) : null} + {subagent ? ( + onViewSubagent(subagent) : undefined} + /> + ) : null} +
    + ) : ( +
    + + {formatted.title} + +
    - ) : detailContent ? ( -
    -                      {detailContent}
    -                    
    - ) : null} - {resultContent ? ( - // What the tool returned (size-capped upstream). Scrolls - // inside its own box so a long result never floods the - // timeline. -
    -                      {resultContent}
    -                    
    - ) : null} - {subagent ? ( - onViewSubagent(subagent) : undefined} - /> - ) : null} -
    - ) : ( -
    - {formatted.title} - -
    - )} -
    - ); - })} + )} + + ); + })} +
    + )}
    {liveResponse ? : null} @@ -719,7 +855,18 @@ export function ToolTimelineBlock({ // otherwise a new turn streaming onto an already-mounted block (settling, // or starting a fresh run) would silently flip `open` out from under the // user's manual choice on every turn. - const autoOpen = isRunning || expandAllRows; + // + // Driven by `settleSignal` (i.e. `turnActive` when the caller supplies it), + // NOT by `isRunning`. `isRunning` means "a tool is executing *this instant*", + // which goes false in every gap BETWEEN tools — while the agent reasons about + // a result before issuing the next call. Keyed off that, the group snapped + // shut a beat after each tool result and reopened when the next call started, + // so a multi-tool turn flickered and a just-delivered result looked like it + // had been wiped. #5008 already established `turnActive` as the correct + // whole-turn signal and applied it to the override reset above; `autoOpen` + // was left behind on `isRunning`. Same signal now drives both, so the group + // stays open for the WHOLE turn and collapses once, at settle. + const autoOpen = settleSignal || expandAllRows; const open = userOverrideOpen ?? autoOpen; // Fully own the disclosure via React state rather than letting the browser diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index a054d60ff..ca94f0e97 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { describe, expect, it, vi } from 'vitest'; @@ -603,10 +603,14 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); // Sub-agent A settles: `isRunning` flips true→false, but the whole - // TURN is still active. Pre-fix this alone would have reset the - // (nonexistent, still null) override — here there's nothing to reset, - // but the auto rule alone already collapses it (autoOpen tracks - // `isRunning`, unchanged by this fix). + // TURN is still active, so the group STAYS OPEN. + // + // This expectation was inverted deliberately. #5008 moved the override + // reset onto `turnActive` but left `autoOpen` on `isRunning`, so the + // group still auto-collapsed in every gap between tools/sub-agents — + // a just-delivered tool result appeared to be wiped a beat later, and a + // multi-tool turn flickered. `autoOpen` now tracks the same whole-turn + // signal as the reset, so the group collapses exactly once, at settle. const subagentASettled: ToolTimelineEntry[] = [ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, ]; @@ -615,14 +619,19 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); - expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); - - // The user manually expands it while the turn is still in flight. - fireEvent.click(screen.getByText('Agentic task insights')); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + // The user manually COLLAPSES it while the turn is still in flight. + // (Pre-change the auto rule had already closed it here, so this click + // was an expand; the override mechanic under test is identical either + // way — what matters is that the explicit choice survives the toggles + // below.) + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + // Sub-agent B spawns: `isRunning` flips false→true again. Still one - // turn (`turnActive` unchanged) — the user's override must hold. + // turn (`turnActive` unchanged) — the user's override must hold, so the + // group stays COLLAPSED despite the auto rule wanting it open. const subagentBRunning: ToolTimelineEntry[] = [ ...subagentASettled, { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' }, @@ -632,12 +641,12 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); - expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); // Sub-agent B settles: `isRunning` flips true→false a second time // within the SAME turn. This is exactly the edge that used to reset // the override and cause the flicker (#5008 regression) — with - // `turnActive` supplied it must NOT reset; the user's expand sticks. + // `turnActive` supplied it must NOT reset; the user's collapse sticks. const subagentBSettled: ToolTimelineEntry[] = [ ...subagentASettled, { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' }, @@ -647,7 +656,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); - expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); // Only when the TURN itself ends (`turnActive` true→false) does the // override reset — the panel then auto-collapses since the run is done. @@ -981,9 +990,11 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { // (its activity is visible) — and shows no "View details" link itself. const activity = screen.getByTestId('subagent-activity'); expect(activity.textContent).toContain('pondering'); - expect(screen.getByTestId('tool-result-output').textContent).toContain( - 'Prepared context from 3 sources.' - ); + // The finished step SUCCEEDED, so its raw output is no longer duplicated + // inline — the final answer already compresses it, and it stays reachable + // through this row's "→". (Previously asserted present; see the + // failure-only rule in the compact branch of ToolTimelineBlock.) + expect(screen.queryByTestId('tool-result-output')).toBeNull(); // Clicking the finished step's link opens the full-run panel. fireEvent.click(links[0]); @@ -1024,3 +1035,556 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { expect(screen.queryByTestId('view-details')).toBeNull(); }); }); + +// The in-flight viewport: while a turn is active the row list is windowed to +// a fixed height and auto-follows the newest activity, so a long run can't +// grow without bound and shove the composer around mid-turn. Settled turns +// keep their previous full-height behaviour. +describe('ToolTimelineBlock — in-flight viewport windowing', () => { + const runningEntries: ToolTimelineEntry[] = [ + { id: 'w-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' }, + { id: 'w-2', name: 'code_executor', round: 1, seq: 1, status: 'running', detail: 'run' }, + ]; + + it('windows the row list while the turn is active', () => { + renderInStore(); + const viewport = screen.getByTestId('tool-timeline-viewport'); + expect(viewport.getAttribute('data-windowed')).toBe('true'); + expect(viewport.className).toContain('overflow-y-auto'); + }); + + it('does not window once the turn has settled', () => { + renderInStore(); + const viewport = screen.getByTestId('tool-timeline-viewport'); + expect(viewport.getAttribute('data-windowed')).toBe('false'); + expect(viewport.className).not.toContain('overflow-y-auto'); + }); + + // Callers with no turn lifecycle to hand (settled / past-turn renders) must + // be completely unaffected — windowing is opt-in via `turnActive`. + it('does not window when the caller passes no turnActive', () => { + renderInStore(); + expect(screen.getByTestId('tool-timeline-viewport').getAttribute('data-windowed')).toBe( + 'false' + ); + }); + + // The Agent Process Source panel wants the whole list, not a porthole. + it('never windows under expandAllRows, even mid-turn', () => { + renderInStore(); + expect(screen.getByTestId('tool-timeline-viewport').getAttribute('data-windowed')).toBe( + 'false' + ); + }); + + // Scrolling up detaches the auto-follow so reading an earlier step isn't + // interrupted; returning to the bottom re-attaches it. + it('detaches and re-attaches tail-following as the user scrolls', () => { + renderInStore(); + const viewport = screen.getByTestId('tool-timeline-viewport'); + // jsdom reports 0 for all layout metrics, so drive them explicitly. + Object.defineProperty(viewport, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(viewport, 'clientHeight', { value: 100, configurable: true }); + + viewport.scrollTop = 0; // scrolled to the top — detached + expect(() => fireEvent.scroll(viewport)).not.toThrow(); + + viewport.scrollTop = 400; // back at the bottom — re-attached + expect(() => fireEvent.scroll(viewport)).not.toThrow(); + }); + + // The row list must not remount when a turn settles, or every
    + // the user opened mid-turn would snap shut. + it('keeps the row list mounted across the settle transition', () => { + const { rerender } = renderInStore(); + const before = screen.getByTestId('tool-timeline-viewport').firstElementChild; + rerender( + + + + ); + const after = screen.getByTestId('tool-timeline-viewport').firstElementChild; + expect(after).toBe(before); + }); +}); + +// Regression: the group used to auto-collapse in the GAP BETWEEN tools — +// `autoOpen` keyed off `isRunning` ("a tool is executing right now"), which +// goes false while the agent reasons about a result before issuing the next +// call. A just-delivered tool result appeared to be wiped a beat later, and a +// multi-tool turn flickered open/closed. The whole-turn signal (`turnActive`) +// now drives it, so the group collapses exactly once, at settle. +describe('ToolTimelineBlock — stays open between tools within a turn', () => { + const settledRows: ToolTimelineEntry[] = [ + { id: 'g-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' }, + { + id: 'g-2', + name: 'code_executor', + round: 1, + seq: 1, + status: 'success', + detail: 'run', + result: 'exit 0', + }, + ]; + + it('stays open between tool calls while the turn is still active', () => { + // No entry is `running` — the agent is reasoning before its next call. + renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true); + }); + + it('collapses once the turn itself settles', () => { + renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', false); + }); + + // Callers with no turn lifecycle fall back to `isRunning`, unchanged. + it('falls back to isRunning when the caller passes no turnActive', () => { + const running: ToolTimelineEntry[] = [ + { id: 'g-3', name: 'code_executor', round: 1, seq: 0, status: 'running' }, + ]; + renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true); + renderInStore(); + expect(screen.getAllByTestId('agent-task-insights')[1]).toHaveProperty('open', false); + }); + + // The rows were never deleted — the group was merely shut. Prove the content + // is still mounted so "wiped" can be ruled out for good. + it('keeps the rows mounted even while collapsed', () => { + renderInStore(); + const group = screen.getByTestId('agent-task-insights'); + expect(group).toHaveProperty('open', false); + expect(within(group).getByTestId('tool-timeline-viewport')).toBeInTheDocument(); + }); +}); + +// The settled-turn contract: once the final result has landed the timeline +// folds itself away so a long run never dominates the conversation, but the +// escape hatch stays reachable — "View full agent process Source →" lives in +// the always-visible , not in the collapsed body. Collapsing is only +// acceptable BECAUSE that link survives, so both halves are asserted together. +describe('ToolTimelineBlock — settled turn keeps the process-source escape hatch', () => { + const settled: ToolTimelineEntry[] = [ + { id: 's-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' }, + { id: 's-2', name: 'code_executor', round: 1, seq: 1, status: 'success', result: 'exit 0' }, + ]; + + it('collapses after the final result but still exposes the process-source link', () => { + const onViewWholeRun = vi.fn(); + renderInStore( + + ); + + const group = screen.getByTestId('agent-task-insights'); + expect(group).not.toHaveAttribute('open'); + + // Link is in the , so it is reachable while collapsed. + const link = screen.getByTestId('view-process-source'); + expect(link).toBeInTheDocument(); + + // Clicking it opens the full-run panel and must NOT toggle the disclosure + // (the handler stops propagation to the summary's own click). + fireEvent.click(link); + expect(onViewWholeRun).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('still exposes the link while the turn is in flight', () => { + const onViewWholeRun = vi.fn(); + renderInStore( + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + expect(screen.getByTestId('view-process-source')).toBeInTheDocument(); + }); +}); + +// Compact chat rows show raw tool output for FAILED steps only. A successful +// step's output is already compressed into the agent's final answer, so +// repeating it inline duplicated the answer and stacked one scrollable
    +// per tool above it. A failure is where the answer is least trustworthy (it may
    +// not mention the failure at all), so that evidence stays inline.
    +describe('ToolTimelineBlock — compact rows show output only on failure', () => {
    +  const succeeded: ToolTimelineEntry = {
    +    id: 'r-ok',
    +    name: 'code_executor',
    +    round: 1,
    +    seq: 0,
    +    status: 'success',
    +    result: 'exit 0 — 42 passed',
    +  };
    +  const failed: ToolTimelineEntry = {
    +    id: 'r-err',
    +    name: 'code_executor',
    +    round: 1,
    +    seq: 1,
    +    status: 'error',
    +    result: 'exit 1 — 3 failed',
    +  };
    +
    +  it('omits the output blob for a successful compact row', () => {
    +    renderInStore();
    +    // Still collapsed to its link — the output is reachable, just not inline.
    +    expect(screen.getByTestId('view-details')).toBeInTheDocument();
    +    expect(screen.queryByTestId('tool-result-output')).toBeNull();
    +  });
    +
    +  it('keeps the output blob for a failed compact row', () => {
    +    renderInStore();
    +    expect(screen.getByTestId('tool-result-output').textContent).toContain('exit 1 — 3 failed');
    +  });
    +
    +  it('shows only the failure when a turn mixes successful and failed steps', () => {
    +    renderInStore();
    +    const outputs = screen.getAllByTestId('tool-result-output');
    +    expect(outputs).toHaveLength(1);
    +    expect(outputs[0].textContent).toContain('exit 1 — 3 failed');
    +  });
    +
    +  // The panel/expanded path is the full record and must be unaffected — a
    +  // successful result is still shown there.
    +  it('still shows successful output in the expanded/panel path', () => {
    +    renderInStore();
    +    expect(screen.getByTestId('tool-result-output').textContent).toContain('exit 0 — 42 passed');
    +  });
    +});
    +
    +// The rail renders the turn's interleaved processing transcript — narration,
    +// reasoning and tool steps in stream order — through the SAME
    +// `ProcessingTranscriptView` the Agent Process Source panel uses, so the rail
    +// is a windowed view of the panel rather than a second, divergent rendering.
    +// Narration no longer lives in the chat stream and reasoning no longer has its
    +// own bubble; both surface here.
    +describe('ToolTimelineBlock — renders the processing transcript inline', () => {
    +  const entries: ToolTimelineEntry[] = [
    +    { id: 'tx-1', name: 'web_fetch', round: 1, seq: 0, status: 'success', detail: 'example.com' },
    +  ];
    +
    +  it('renders narration and tool steps from the transcript', () => {
    +    renderInStore(
    +      
    +    );
    +    const view = screen.getByTestId('processing-transcript');
    +    expect(view).toBeInTheDocument();
    +    expect(screen.getByTestId('processing-narration').textContent).toContain(
    +      'Let me get the data for both.'
    +    );
    +  });
    +
    +  it('keeps the transcript inside the windowed viewport during a turn', () => {
    +    renderInStore(
    +      
    +    );
    +    const viewport = screen.getByTestId('tool-timeline-viewport');
    +    expect(viewport.getAttribute('data-windowed')).toBe('true');
    +    expect(within(viewport).getByTestId('processing-transcript')).toBeInTheDocument();
    +  });
    +
    +  // Legacy snapshots predate the transcript — those turns must still render.
    +  it('falls back to the tool-row list when no transcript is present', () => {
    +    renderInStore();
    +    expect(screen.queryByTestId('processing-transcript')).toBeNull();
    +    expect(screen.getByTestId('agent-task-insights')).toBeInTheDocument();
    +  });
    +
    +  it('falls back when the transcript is present but empty', () => {
    +    renderInStore();
    +    expect(screen.queryByTestId('processing-transcript')).toBeNull();
    +  });
    +});
    +
    +// Regression: swapping the rail's body to `ProcessingTranscriptView` dropped
    +// nested sub-agent activity, because its `ToolRow` renders only title/detail/
    +// failure and never reads `entry.subagent`. A delegated run collapsed to one
    +// line and every child tool call it made became invisible — visible as the
    +// process-source panel (which fell back to the row list) showing more tool
    +// calls than the inline rail. `renderSubagent` injects the block back in;
    +// injected rather than imported because ToolTimelineBlock already imports
    +// ProcessingTranscriptView, so importing back would be a cycle.
    +describe('ToolTimelineBlock — sub-agent activity survives the transcript path', () => {
    +  const subagentEntry: ToolTimelineEntry = {
    +    id: 'sa-tx',
    +    name: 'subagent:researcher',
    +    round: 1,
    +    seq: 0,
    +    status: 'running',
    +    subagent: {
    +      taskId: 'task-9',
    +      agentId: 'researcher',
    +      toolCalls: [
    +        { callId: 'c1', toolName: 'web_search', status: 'success', elapsedMs: 120 },
    +        { callId: 'c2', toolName: 'web_fetch', status: 'running' },
    +      ],
    +    },
    +  };
    +
    +  it('renders the sub-agent child tool calls inside the transcript rail', () => {
    +    renderInStore(
    +      
    +    );
    +    // Rendering through the transcript path…
    +    expect(screen.getByTestId('processing-transcript')).toBeInTheDocument();
    +    // …and the nested child run is present, not collapsed to one line.
    +    expect(screen.getByTestId('processing-subagent')).toBeInTheDocument();
    +    const calls = screen.getAllByTestId('subagent-tool-call');
    +    expect(calls).toHaveLength(2);
    +    expect(calls[0].textContent).toContain('Searching the web');
    +    expect(calls[0].textContent).toContain('Done');
    +    // Human label, not the raw `web_fetch` slug.
    +    expect(calls[1].textContent).toContain('Fetching');
    +    expect(calls[1].textContent).toContain('Running');
    +  });
    +
    +  it('still renders child tool calls on the legacy row path (no transcript)', () => {
    +    renderInStore();
    +    expect(screen.queryByTestId('processing-transcript')).toBeNull();
    +    expect(screen.getAllByTestId('subagent-tool-call')).toHaveLength(2);
    +  });
    +
    +  // The nested child run must live INSIDE the windowed viewport, and must not
    +  // introduce a scroll container of its own. A nested scroller would clamp its
    +  // own height, so a streaming child run would stop changing the outer content
    +  // height — the ResizeObserver would never fire and auto-follow would silently
    +  // stall mid-subagent, with the window pinned to stale content.
    +  it('nests the sub-agent inside the sliding window with no scroller of its own', () => {
    +    renderInStore(
    +      
    +    );
    +    const viewport = screen.getByTestId('tool-timeline-viewport');
    +    expect(viewport.getAttribute('data-windowed')).toBe('true');
    +
    +    const subagent = within(viewport).getByTestId('processing-subagent');
    +    expect(subagent).toBeInTheDocument();
    +
    +    // Walk from the sub-agent up to the viewport: nothing between them may
    +    // scroll, or the outer window stops seeing the child run grow.
    +    for (let node = subagent; node && node !== viewport; node = node.parentElement!) {
    +      expect(node.className).not.toMatch(/overflow-(y-)?auto|overflow-(y-)?scroll/);
    +    }
    +  });
    +});
    +
    +// Auto-follow: the window pins to the newest activity as the turn streams.
    +// jsdom ships no ResizeObserver, so the effect early-returns and this behaviour
    +// is invisible to every other test in this file — stub one and drive it
    +// directly, otherwise the single most user-visible property of the windowed
    +// rail has no coverage at all.
    +describe('ToolTimelineBlock — auto-follows the live edge', () => {
    +  const entries: ToolTimelineEntry[] = [
    +    { id: 'af-1', name: 'web_fetch', round: 1, seq: 0, status: 'running', detail: 'example.com' },
    +  ];
    +  const transcript = [
    +    { kind: 'narration' as const, round: 1, seq: 0, text: 'Let me get the data.' },
    +    { kind: 'toolCall' as const, round: 1, seq: 1, callId: 'af-1' },
    +  ];
    +
    +  /** Installs a fake ResizeObserver and returns a trigger for its callback. */
    +  function stubResizeObserver() {
    +    const callbacks: Array<() => void> = [];
    +    class FakeResizeObserver {
    +      constructor(cb: () => void) {
    +        callbacks.push(cb);
    +      }
    +      observe() {}
    +      disconnect() {}
    +      unobserve() {}
    +    }
    +    (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver;
    +    return {
    +      fire: () => callbacks.forEach(cb => cb()),
    +      restore: () => {
    +        delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
    +      },
    +    };
    +  }
    +
    +  /** jsdom reports 0 for all layout metrics — drive them explicitly. */
    +  function sizeViewport(el: HTMLElement, { scrollHeight = 600, clientHeight = 200 } = {}) {
    +    Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
    +    Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
    +  }
    +
    +  it('scrolls to the newest content when the transcript grows', () => {
    +    const ro = stubResizeObserver();
    +    try {
    +      renderInStore();
    +      const viewport = screen.getByTestId('tool-timeline-viewport');
    +      sizeViewport(viewport);
    +      viewport.scrollTop = 0;
    +
    +      ro.fire();
    +
    +      // Pinned to the live edge.
    +      expect(viewport.scrollTop).toBe(600);
    +    } finally {
    +      ro.restore();
    +    }
    +  });
    +
    +  it('stops following once the user scrolls away from the bottom', () => {
    +    const ro = stubResizeObserver();
    +    try {
    +      renderInStore();
    +      const viewport = screen.getByTestId('tool-timeline-viewport');
    +      sizeViewport(viewport);
    +
    +      // User scrolls up to read an earlier step (well outside the 24px slack).
    +      viewport.scrollTop = 100;
    +      fireEvent.scroll(viewport);
    +
    +      ro.fire();
    +
    +      // Left where the user put it — not yanked back down.
    +      expect(viewport.scrollTop).toBe(100);
    +    } finally {
    +      ro.restore();
    +    }
    +  });
    +
    +  it('resumes following when the user scrolls back to the bottom', () => {
    +    const ro = stubResizeObserver();
    +    try {
    +      renderInStore();
    +      const viewport = screen.getByTestId('tool-timeline-viewport');
    +      sizeViewport(viewport);
    +
    +      viewport.scrollTop = 100; // detach
    +      fireEvent.scroll(viewport);
    +      viewport.scrollTop = 400; // back at the bottom (600 - 200 = 400)
    +      fireEvent.scroll(viewport);
    +
    +      ro.fire();
    +
    +      expect(viewport.scrollTop).toBe(600);
    +    } finally {
    +      ro.restore();
    +    }
    +  });
    +
    +  it('does not follow when the turn has settled (not windowed)', () => {
    +    const ro = stubResizeObserver();
    +    try {
    +      renderInStore(
    +        
    +      );
    +      const viewport = screen.getByTestId('tool-timeline-viewport');
    +      sizeViewport(viewport);
    +      viewport.scrollTop = 0;
    +
    +      ro.fire();
    +
    +      expect(viewport.scrollTop).toBe(0);
    +    } finally {
    +      ro.restore();
    +    }
    +  });
    +});
    +
    +// Regression: auto-follow silently never armed in a real turn.
    +//
    +// The observer used to attach in `useEffect(..., [windowed])`. `windowed` flips
    +// true at the START of a turn — when there is no content yet, so the component
    +// returned null, the ref was null, and the effect bailed. Content arriving
    +// afterwards re-rendered the viewport but did not change `windowed`, so the
    +// effect never re-ran and no observer was ever created. Every earlier test
    +// passed because it rendered with content already present at mount, which is
    +// precisely the case that never happens live.
    +describe('ToolTimelineBlock — auto-follow arms when content arrives after mount', () => {
    +  function stubResizeObserver() {
    +    const callbacks: Array<() => void> = [];
    +    class FakeResizeObserver {
    +      constructor(cb: () => void) {
    +        callbacks.push(cb);
    +      }
    +      observe() {}
    +      disconnect() {}
    +      unobserve() {}
    +    }
    +    (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver;
    +    return {
    +      fire: () => callbacks.forEach(cb => cb()),
    +      count: () => callbacks.length,
    +      restore: () => {
    +        delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
    +      },
    +    };
    +  }
    +
    +  it('follows content that only appears on a later render', () => {
    +    const ro = stubResizeObserver();
    +    try {
    +      // Turn starts: windowed, but nothing to show yet → renders nothing.
    +      const { rerender } = renderInStore(
    +        
    +      );
    +      expect(screen.queryByTestId('tool-timeline-viewport')).toBeNull();
    +      expect(ro.count()).toBe(0);
    +
    +      // …then the first tool row lands.
    +      rerender(
    +        
    +          
    +        
    +      );
    +
    +      const viewport = screen.getByTestId('tool-timeline-viewport');
    +      Object.defineProperty(viewport, 'scrollHeight', { value: 500, configurable: true });
    +      Object.defineProperty(viewport, 'clientHeight', { value: 200, configurable: true });
    +      viewport.scrollTop = 0;
    +
    +      // The observer must have been created for the node that appeared late.
    +      expect(ro.count()).toBeGreaterThan(0);
    +      ro.fire();
    +      expect(viewport.scrollTop).toBe(500);
    +    } finally {
    +      ro.restore();
    +    }
    +  });
    +
    +  // Narration streams before the first tool call, so gating the render on
    +  // `entries` alone blanked the rail for the opening stretch of every turn and
    +  // hid tool-less turns entirely.
    +  it('renders on transcript alone, with no tool rows yet', () => {
    +    renderInStore(
    +      
    +    );
    +    expect(screen.getByTestId('tool-timeline-viewport')).toBeInTheDocument();
    +    expect(screen.getByTestId('processing-narration').textContent).toContain(
    +      'Let me get the data.'
    +    );
    +  });
    +
    +  it('still renders nothing when there is neither a row nor transcript prose', () => {
    +    renderInStore();
    +    expect(screen.queryByTestId('agent-task-insights')).toBeNull();
    +  });
    +});
    diff --git a/app/src/features/conversations/utils/interimNarration.test.ts b/app/src/features/conversations/utils/interimNarration.test.ts
    new file mode 100644
    index 000000000..a882891df
    --- /dev/null
    +++ b/app/src/features/conversations/utils/interimNarration.test.ts
    @@ -0,0 +1,84 @@
    +import { describe, expect, it } from 'vitest';
    +
    +import type { ThreadMessage } from '../../../types/thread';
    +import { supersededInterimIndexes } from './interimNarration';
    +
    +function msg(
    +  sender: 'user' | 'agent',
    +  content: string,
    +  extraMetadata?: Record
    +): ThreadMessage {
    +  return {
    +    id: `${sender}-${content.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}`,
    +    sender,
    +    content,
    +    createdAt: new Date(0).toISOString(),
    +    ...(extraMetadata ? { extraMetadata } : {}),
    +  } as unknown as ThreadMessage;
    +}
    +
    +const interim = (text: string) => msg('agent', text, { isInterim: true, requestId: 'r1' });
    +const answer = (text: string) => msg('agent', text, { citations: [] });
    +
    +describe('supersededInterimIndexes', () => {
    +  it('hides narration once the turn produced its answer', () => {
    +    const messages = [
    +      msg('user', 'how many goals?'),
    +      interim('Let me get the data for both.'),
    +      interim('The HTML is hard to parse. Let me search for a clean table.'),
    +      answer('He scored 11 goals.'),
    +    ];
    +    expect([...supersededInterimIndexes(messages)].sort()).toEqual([1, 2]);
    +  });
    +
    +  it('keeps narration while the turn is still in flight (no answer yet)', () => {
    +    const messages = [
    +      msg('user', 'how many goals?'),
    +      interim('Let me get the data for both.'),
    +      interim('Let me get a cleaner source.'),
    +    ];
    +    expect(supersededInterimIndexes(messages).size).toBe(0);
    +  });
    +
    +  // A turn that errored before answering: its narration is the only record of
    +  // what actually ran, so it must survive.
    +  it('keeps narration for a turn that died before answering', () => {
    +    const messages = [
    +      msg('user', 'first question'),
    +      interim('Let me check.'),
    +      answer('Here is the answer.'),
    +      msg('user', 'second question'),
    +      interim('Let me search.'),
    +    ];
    +    // Only the FIRST turn's narration is superseded.
    +    expect([...supersededInterimIndexes(messages)]).toEqual([1]);
    +  });
    +
    +  it('scopes per turn across a multi-turn thread', () => {
    +    const messages = [
    +      msg('user', 'q1'),
    +      interim('n1'),
    +      answer('a1'),
    +      msg('user', 'q2'),
    +      interim('n2'),
    +      interim('n3'),
    +      answer('a2'),
    +    ];
    +    expect([...supersededInterimIndexes(messages)].sort((a, b) => a - b)).toEqual([1, 4, 5]);
    +  });
    +
    +  it('never hides real answers or user messages', () => {
    +    const messages = [msg('user', 'q'), interim('n'), answer('a')];
    +    const hidden = supersededInterimIndexes(messages);
    +    expect(hidden.has(0)).toBe(false);
    +    expect(hidden.has(2)).toBe(false);
    +  });
    +
    +  it('handles an empty thread and an interim-only thread with no user message', () => {
    +    expect(supersededInterimIndexes([]).size).toBe(0);
    +    // Proactive-only run: narration with no answer yet stays visible.
    +    expect(supersededInterimIndexes([interim('working…')]).size).toBe(0);
    +    // Proactive-only run that did answer: narration is superseded.
    +    expect([...supersededInterimIndexes([interim('working…'), answer('done')])]).toEqual([0]);
    +  });
    +});
    diff --git a/app/src/features/conversations/utils/interimNarration.ts b/app/src/features/conversations/utils/interimNarration.ts
    new file mode 100644
    index 000000000..299226ed5
    --- /dev/null
    +++ b/app/src/features/conversations/utils/interimNarration.ts
    @@ -0,0 +1,62 @@
    +/**
    + * Which interim narration bubbles a finished turn has superseded.
    + *
    + * The agent emits `extraMetadata.isInterim` messages while it works ("Let me
    + * get the data for both.", "The HTML is hard to parse. Let me search for a
    + * clean table.") — these are live progress, not content. Once the turn delivers
    + * its real answer they are superseded; left unfiltered they pile up
    + * permanently, wedging several stale "Let me…" bubbles between the question and
    + * the answer on every multi-tool turn.
    + *
    + * The rule is per TURN, keyed on that turn having produced a final
    + * (non-interim) agent message:
    + *
    + * - turn still in flight → no final message yet → narration stays visible
    + * - turn answered        → narration hidden, the answer speaks for it
    + * - turn died first      → narration kept; it is the only record of what ran
    + *
    + * Deriving this from the answer's existence rather than a turn-active flag is
    + * what makes the third case work, and keeps the helper pure (no lifecycle
    + * plumbing, trivially testable).
    + *
    + * Nothing is deleted — callers use this to filter the rendered list only. The
    + * messages stay persisted and reachable via "View full agent process Source".
    + */
    +import type { ThreadMessage } from '../../../types/thread';
    +
    +/**
    + * Indexes into `messages` whose interim narration is superseded and should not
    + * render. Returns indexes (not ids) so callers can filter positionally without
    + * assuming ids are present or unique.
    + */
    +export function supersededInterimIndexes(messages: readonly ThreadMessage[]): Set {
    +  const hidden = new Set();
    +  let segmentStart = 0;
    +
    +  // A turn spans (previous user message, next user message]. Closing a segment
    +  // hides its narration only when that segment also produced a real answer.
    +  const closeSegment = (end: number) => {
    +    let hasFinalAnswer = false;
    +    for (let i = segmentStart; i < end; i += 1) {
    +      const msg = messages[i];
    +      if (msg.sender === 'agent' && !msg.extraMetadata?.isInterim) {
    +        hasFinalAnswer = true;
    +        break;
    +      }
    +    }
    +    if (!hasFinalAnswer) return;
    +    for (let i = segmentStart; i < end; i += 1) {
    +      if (messages[i].extraMetadata?.isInterim) hidden.add(i);
    +    }
    +  };
    +
    +  messages.forEach((msg, index) => {
    +    if (msg.sender === 'user') {
    +      closeSegment(index);
    +      segmentStart = index + 1;
    +    }
    +  });
    +  closeSegment(messages.length);
    +
    +  return hidden;
    +}
    diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
    index da8643790..3a73ccf02 100644
    --- a/app/src/providers/ChatRuntimeProvider.tsx
    +++ b/app/src/providers/ChatRuntimeProvider.tsx
    @@ -827,33 +827,36 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
               return;
             const content = event.full_response?.trim() ?? '';
             if (!content) return;
    -        // Persist this round's leading narration as its own interleaved bubble,
    -        // stamped with the producing turn's request id (Phase 4 anchoring).
    -        // `isInterim: true` marks this as between-tool narration rather than a
    -        // turn's terminal answer — the main chat still renders it as a bubble
    -        // (unchanged), but callers that only want the terminal turn (e.g. the
    -        // Flows copilot's `displayMessages`, see `useWorkflowBuilderChat`) can
    -        // filter it out.
    -        rtLog('interim_narration_tagged', {
    +        // Narration is NOT promoted to a chat message any more.
    +        //
    +        // It used to be persisted here via `addInferenceResponse({ isInterim })`,
    +        // which gave it a lifetime no other progress signal has: thinking is
    +        // wiped at `chat_done`, tool rows collapse, but narration bubbles
    +        // ("Let me get the data for both.", "The HTML is hard to parse…")
    +        // stayed in the thread forever, wedged between the question and the
    +        // answer they were superseded by.
    +        //
    +        // It is already captured twice over without this: `streamDeltaReceived`
    +        // coalesces every `content` delta into `processingByThread` as a
    +        // `narration` transcript item (chatRuntimeSlice), and the core persists
    +        // the same thing server-side as `TranscriptItem::Narration`, kept after
    +        // completion so a reload replays it. The inline rail and the Agent
    +        // Process Source panel both render that transcript — so narration is
    +        // still fully visible while the turn runs, and still inspectable after,
    +        // just not as a permanent chat bubble.
    +        //
    +        // The event is still consumed (not dropped upstream) for its dedup key
    +        // and the preview reset below, both of which are round-scoped.
    +        rtLog('interim_narration_observed', {
               thread: event.thread_id,
               request: event.request_id,
               round: event.round,
             });
    -        void dispatch(
    -          addInferenceResponse({
    -            content,
    -            threadId: event.thread_id,
    -            extraMetadata: {
    -              isInterim: true,
    -              ...(event.request_id ? { requestId: event.request_id } : {}),
    -            },
    -          })
    -        );
    -        // The narration has now become a bubble, so drop it from the live
    -        // streaming preview (which accumulates across the whole turn under one
    -        // request_id) — otherwise the same text lingers in the preview tail and
    -        // reads as a duplicate for the full duration of the tool call. Reset
    -        // synchronously so the next round's deltas start from an empty buffer.
    +        // Drop the round's narration from the live streaming preview, which
    +        // accumulates across the whole turn under one request_id. This matters
    +        // MORE now: without it the same text renders both in the rail (as a
    +        // transcript item) and in the preview tail. Reset synchronously so the
    +        // next round's deltas start from an empty buffer.
             const cr = store.getState().chatRuntime;
             const existing = cr.streamingAssistantByThread[event.thread_id];
             if (existing && existing.requestId === event.request_id) {
    diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
    index ed2500185..a6aa97d9c 100644
    --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
    +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
    @@ -1018,7 +1018,15 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
           expect(streaming?.content).toBe('bbb');
         });
     
    -    it('persists interim narration as a bubble and clears it from the live preview', async () => {
    +    // Narration is NOT promoted to a chat message. It used to be persisted via
    +    // `addInferenceResponse({ isInterim: true })`, which gave it a lifetime no
    +    // other progress signal has — thinking is wiped at `chat_done` and tool rows
    +    // collapse, but narration bubbles stayed in the thread forever, between the
    +    // question and the answer that superseded them. It reaches the UI through
    +    // the processing transcript instead (written by `streamDeltaReceived`, and
    +    // persisted core-side as `TranscriptItem::Narration`), which the inline rail
    +    // and the Agent Process Source panel both render.
    +    it('records interim narration in the transcript, not as a message, and clears the preview', async () => {
           const listeners = renderProvider();
     
           // Round-0 narration streams into the live preview…
    @@ -1033,6 +1041,11 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
           expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe(
             'Let me check your calendar first.'
           );
    +      // …and is already captured as a narration transcript item by the delta
    +      // reducer — this is what the rail renders.
    +      expect(store.getState().chatRuntime.processingByThread['t-interim']).toEqual([
    +        expect.objectContaining({ kind: 'narration', text: 'Let me check your calendar first.' }),
    +      ]);
     
           // …then a tool call closes the round → interim flush.
           act(() => {
    @@ -1044,30 +1057,49 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
             });
           });
     
    -      // The narration is persisted as its own bubble…
    -      await waitFor(() =>
    -        expect(threadApi.appendMessage).toHaveBeenCalledWith(
    -          't-interim',
    -          expect.objectContaining({ content: 'Let me check your calendar first.', sender: 'agent' })
    -        )
    -      );
    -      // …and cleared from the live preview so it isn't shown twice.
    +      // No message is appended for narration.
    +      expect(threadApi.appendMessage).not.toHaveBeenCalled();
    +      // The preview is still cleared, so the rail and the preview don't both
    +      // show the same text for the duration of the tool call.
           expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe(
             ''
           );
         });
     
    -    it('dedupes a re-delivered interim event by round', async () => {
    +    // The round dedup key outlives the message-promotion it was written for:
    +    // it now guards the streaming-preview reset. A replayed frame must not wipe
    +    // text the agent streamed AFTER the original flush.
    +    it('dedupes a re-delivered interim event by round', () => {
           const listeners = renderProvider();
    +      const streamingContent = () =>
    +        store.getState().chatRuntime.streamingAssistantByThread['t-interim-dup']?.content;
     
           act(() => {
    +        listeners.onTextDelta?.({
    +          thread_id: 't-interim-dup',
    +          request_id: 'r1',
    +          round: 1,
    +          delta: 'Working on it now — pulling the data.',
    +        });
             listeners.onInterim?.({
               thread_id: 't-interim-dup',
               request_id: 'r1',
               round: 1,
               full_response: 'Working on it now — pulling the data.',
             });
    -        // Reconnect/replay re-delivers the same round.
    +      });
    +      // First delivery flushes the round and clears the preview.
    +      expect(streamingContent()).toBe('');
    +
    +      act(() => {
    +        // The agent streams the next chunk…
    +        listeners.onTextDelta?.({
    +          thread_id: 't-interim-dup',
    +          request_id: 'r1',
    +          round: 1,
    +          delta: 'Here is what I found.',
    +        });
    +        // …and a reconnect/replay re-delivers the SAME round.
             listeners.onInterim?.({
               thread_id: 't-interim-dup',
               request_id: 'r1',
    @@ -1076,7 +1108,11 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
             });
           });
     
    -      await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1));
    +      // Deduped: the replay is ignored, so the newer text survives. Without the
    +      // guard the preview would have been cleared a second time.
    +      expect(streamingContent()).toBe('Here is what I found.');
    +      // And narration still never becomes a message, on either delivery.
    +      expect(threadApi.appendMessage).not.toHaveBeenCalled();
         });
     
         it('sets inference status to thinking on inference_start and clears it on chat_done', () => {
    diff --git a/src/openhuman/tinyagents/payload_summarizer.rs b/src/openhuman/tinyagents/payload_summarizer.rs
    index 825c331ed..6b610e479 100644
    --- a/src/openhuman/tinyagents/payload_summarizer.rs
    +++ b/src/openhuman/tinyagents/payload_summarizer.rs
    @@ -299,7 +299,34 @@ impl SubagentPayloadSummarizer {
                 Arc::new(harness),
             )
             .with_system_prompt(system_prompt);
    -        let run = child.invoke_in_parent(&(), (), parent_ctx, prompt).await?;
    +        // Run the summarizer UNARY (non-streaming), not via `invoke_in_parent`.
    +        //
    +        // `invoke_in_parent` inherits `parent.streaming`, which is `true` for a
    +        // chat turn, so the child runs the streaming loop and its per-token
    +        // deltas land on the shared `EventSink` as parent `AgentProgress::
    +        // TextDelta`. The web bridge buffers those into `pending_narration`
    +        // and `flush_interim_narration` publishes them as a `chat_interim`
    +        // bubble, which is persisted as an `isInterim` agent message — so the
    +        // internal "[Tool output summary — ]" text was rendered to the
    +        // user as if it were part of the answer. That defeats the point of the
    +        // summarizer: it exists to compress a payload for the ORCHESTRATOR'S
    +        // CONTEXT, and its only consumer here is `run.text()` below.
    +        //
    +        // `invoke_with_events` runs the child through the unary path
    +        // (`run_child(.., streaming = false)`), which per its own contract
    +        // "leav[es] the parent's event stream unchanged", while still sharing
    +        // the sink so the sub-agent lifecycle events (started/completed) keep
    +        // reaching observers. Mirrors `reprompt_for_required_block`, which is
    +        // likewise deliberately silent about an internal repair call.
    +        //
    +        // Two bits of config that `invoke_in_parent` threaded are dropped by
    +        // this entry point and neither matters here: the child `thread_id` (only
    +        // used to attribute events we no longer stream) and the inherited
    +        // `max_turn_output_tokens` (already enforced independently by the
    +        // `MaxTokensModel` wrapper above).
    +        let run = child
    +            .invoke_with_events(&(), (), parent_ctx.depth(), prompt, &parent_ctx.events)
    +            .await?;
             Ok(run.text().unwrap_or_default())
         }