feat(conversations): unify agent activity into one live rail (#5141)

This commit is contained in:
sanil-23
2026-07-23 09:55:32 +03:00
committed by GitHub
parent 87fe420fdd
commit 49b5bb71a5
10 changed files with 1199 additions and 212 deletions
@@ -168,7 +168,15 @@ export function AgentProcessSourcePanel({
)
) : transcript.length > 0 ? (
// Hermes-style interleaved narration + grouped, human-labeled steps.
<ProcessingTranscriptView transcript={transcript} entries={entries} />
// `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.
<ProcessingTranscriptView
transcript={transcript}
entries={entries}
renderSubagent={subagent => <SubagentActivityBlock subagent={subagent} />}
/>
) : entries.length > 0 ? (
// Legacy snapshot (no transcript): fall back to the tool timeline,
// which already nests each sub-agent's full activity inline.
@@ -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<ChatThreadViewHandle, ChatThreadViewPro
const openSubagentEntry = openSubagentTaskId
? selectedThreadToolTimeline.find(entry => 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<ChatThreadViewHandle, ChatThreadViewPro
// retry. `isSending` already excludes it (only `'started'` /
// `'streaming'`, same as this component's own live-turn checks).
turnActive={isSending}
// Interleaved narration + thinking + tool steps in stream order.
// Renders 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 of the turn.
transcript={selectedThreadProcessing}
/>
) : (
// Transcript-only turn: reasoning/narration was streamed but no tool
@@ -954,37 +972,29 @@ export const ChatThreadView = forwardRef<ChatThreadViewHandle, ChatThreadViewPro
in-flight response. Rendered as plain text (not Markdown) to
avoid jitter from partially-parsed fences. The final bubble
replaces this via addInferenceResponse on chat_done. */}
{selectedStreamingAssistant &&
(selectedStreamingAssistant.thinking.length > 0 ||
selectedStreamingAssistant.content.length > 0) && (
<div className="flex justify-start">
<div className="relative w-fit max-w-[75%]">
{selectedStreamingAssistant.thinking.length > 0 && (
<details className="mb-1.5 bg-surface-subtle rounded-lg px-3 py-1.5 text-xs text-content-secondary open:bg-stone-100 dark:bg-surface-muted dark:open:bg-neutral-800">
<summary className="cursor-pointer select-none flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary-400 animate-pulse" />
<span>{t('chat.thinking')}</span>
</summary>
<pre className="whitespace-pre-wrap break-words mt-1.5 font-sans text-[11px] text-content-muted">
{selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
</pre>
</details>
)}
{selectedStreamingAssistant.content.length > 0 && (
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-surface-strong/80 dark:bg-surface-muted text-content">
<p className="text-xs text-content-secondary font-mono whitespace-pre-wrap break-words leading-snug">
{selectedStreamingAssistant.content.length >
STREAMING_PREVIEW_CHARS && (
<span className="text-content-faint"></span>
)}
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
</p>
</div>
)}
</div>
{selectedStreamingAssistant && selectedStreamingAssistant.content.length > 0 && (
<div className="flex justify-start">
<div className="relative w-fit max-w-[75%]">
{/* 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 && (
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-surface-strong/80 dark:bg-surface-muted text-content">
<p className="text-xs text-content-secondary font-mono whitespace-pre-wrap break-words leading-snug">
{selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && (
<span className="text-content-faint"></span>
)}
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
</p>
</div>
)}
</div>
)}
</div>
)}
{/* 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
@@ -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<ToolTimelineEntry['subagent']>) => 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 <ThinkingBlock key={block.key} text={block.text} />;
}
return <ToolGroupBlock key={block.key} summary={block.summary} entries={block.entries} />;
return (
<ToolGroupBlock
key={block.key}
summary={block.summary}
entries={block.entries}
renderSubagent={renderSubagent}
/>
);
})}
</div>
);
@@ -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<ToolTimelineEntry['subagent']>) => 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
</summary>
<ul className="mt-1 ml-1 space-y-1 border-l border-line pl-3">
{entries.map(entry => (
<ToolRow key={entry.id} entry={entry} />
<ToolRow key={entry.id} entry={entry} renderSubagent={renderSubagent} />
))}
{allSettled ? (
<li className="flex items-center gap-1.5 pt-0.5">
@@ -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<ToolTimelineEntry['subagent']>) => React.ReactNode;
}) {
const { title, detail } = formatTimelineEntry(entry);
return (
<li className="flex items-start gap-1.5" data-testid="processing-tool-row">
<span className="mt-0.5 shrink-0 text-content-faint">
<CategoryIcon category={categorizeTool(entry.name)} />
</span>
<span className="min-w-0 text-[12px] text-content-secondary">
{title}
{detail ? (
<span className="ml-1 rounded bg-surface-subtle px-1 py-px font-mono text-[10px] text-content-muted">
{detail}
</span>
) : null}
{entry.status === 'error' && entry.failure ? (
<ToolFailureLines failure={entry.failure} />
) : null}
</span>
<li className="flex flex-col gap-1" data-testid="processing-tool-row">
<div className="flex items-start gap-1.5">
<span className="mt-0.5 shrink-0 text-content-faint">
<CategoryIcon category={categorizeTool(entry.name)} />
</span>
<span className="min-w-0 text-[12px] text-content-secondary">
{title}
{detail ? (
<span className="ml-1 rounded bg-surface-subtle px-1 py-px font-mono text-[10px] text-content-muted">
{detail}
</span>
) : null}
{entry.status === 'error' && entry.failure ? (
<ToolFailureLines failure={entry.failure} />
) : null}
</span>
</div>
{/* 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 `<div>` SIBLING under the `<li>` (indented past the
icon), not nested inside the label `<span>` — SubagentActivityBlock
renders a `<div>`, and `<div>`-inside-`<span>` is invalid nesting. */}
{entry.subagent && renderSubagent ? (
<div className="ml-5" data-testid="processing-subagent">
{renderSubagent(entry.subagent)}
</div>
) : null}
</li>
);
}
@@ -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<HTMLDivElement | null>(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<ResizeObserver | null>(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 = (
<>
<div className="text-sm text-content-faint">
{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 <details> state never reset) when a turn
settles — only the height/scroll classes toggle. */}
<div
ref={attachViewport}
onScroll={windowed ? handleViewportScroll : undefined}
data-testid="tool-timeline-viewport"
data-windowed={windowed ? 'true' : 'false'}
className={windowed ? TIMELINE_VIEWPORT_CLASS : undefined}>
{transcript && transcript.length > 0 ? (
<ProcessingTranscriptView
transcript={transcript}
entries={ordered}
renderSubagent={subagent => (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
)}
/>
) : (
<div className="text-sm text-content-faint">
{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 (
<AgentTimelineRail
key={entry.id}
isFirst={index === 0}
isLast={index === rows.length - 1}>
{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.
<div className="space-y-1">
<button
type="button"
onClick={() => onViewDetails(entry)}
data-testid="view-details"
className="group/details flex items-center gap-1.5 text-left">
<span
className={`text-[13px] font-medium ${nameTone.replace('animate-pulse ', '')} group-hover/details:underline`}>
{formatted.title}
</span>
<RepeatCount count={count} />
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
</span>
</button>
{resultContent ? (
<pre
data-testid="tool-result-output"
className={`max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{resultContent}
</pre>
) : null}
</div>
) : expandable ? (
<details open={shouldAutoExpand} className="group/row">
<summary className="flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
<span className={`text-[13px] font-medium ${nameTone}`}>{formatted.title}</span>
<span className="text-[11px] text-content-faint transition-transform group-open/row:rotate-90 dark:text-neutral-600">
</span>
</summary>
{workerRef ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
{workerRef.before}
<WorkerThreadRefCard
ref={workerRef.ref}
status={workerStatusFromEntry(entry.status)}
/>
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
return (
<AgentTimelineRail
key={entry.id}
isFirst={index === 0}
isLast={index === rows.length - 1}>
{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.
<div className="space-y-1">
<button
type="button"
onClick={() => onViewDetails(entry)}
data-testid="view-details"
className="group/details flex items-center gap-1.5 text-left">
<span
className={`text-[13px] font-medium ${nameTone.replace('animate-pulse ', '')} group-hover/details:underline`}>
{formatted.title}
</span>
<RepeatCount count={count} />
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
</span>
</button>
{/* 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 <pre> 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' ? (
<pre
data-testid="tool-result-output"
className={`max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{resultContent}
</pre>
) : null}
</div>
) : formatted.detail ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
{formatted.detail}
) : expandable ? (
<details open={shouldAutoExpand} className="group/row">
<summary className="flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
<span className={`text-[13px] font-medium ${nameTone}`}>
{formatted.title}
</span>
<span className="text-[11px] text-content-faint transition-transform group-open/row:rotate-90 dark:text-neutral-600">
</span>
</summary>
{workerRef ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
{workerRef.before}
<WorkerThreadRefCard
ref={workerRef.ref}
status={workerStatusFromEntry(entry.status)}
/>
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
</div>
) : formatted.detail ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
{formatted.detail}
</div>
) : detailContent ? (
<pre
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{detailContent}
</pre>
) : null}
{resultContent ? (
// What the tool returned (size-capped upstream). Scrolls
// inside its own box so a long result never floods the
// timeline.
<pre
data-testid="tool-result-output"
className={`mt-1 max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{resultContent}
</pre>
) : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
) : null}
</details>
) : (
<div className="flex items-center gap-1.5">
<span className={`text-[13px] font-medium ${nameTone}`}>
{formatted.title}
</span>
<RepeatCount count={count} />
</div>
) : detailContent ? (
<pre
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{detailContent}
</pre>
) : null}
{resultContent ? (
// What the tool returned (size-capped upstream). Scrolls
// inside its own box so a long result never floods the
// timeline.
<pre
data-testid="tool-result-output"
className={`mt-1 max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
{resultContent}
</pre>
) : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
) : null}
</details>
) : (
<div className="flex items-center gap-1.5">
<span className={`text-[13px] font-medium ${nameTone}`}>{formatted.title}</span>
<RepeatCount count={count} />
</div>
)}
</AgentTimelineRail>
);
})}
)}
</AgentTimelineRail>
);
})}
</div>
)}
</div>
{liveResponse ? <LiveResponseBlock text={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
@@ -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', () => {
<ToolTimelineBlock entries={subagentASettled} turnActive />
</Provider>
);
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', () => {
<ToolTimelineBlock entries={subagentBRunning} turnActive />
</Provider>
);
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', () => {
<ToolTimelineBlock entries={subagentBSettled} turnActive />
</Provider>
);
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(<ToolTimelineBlock entries={runningEntries} turnActive />);
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(<ToolTimelineBlock entries={runningEntries} turnActive={false} />);
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(<ToolTimelineBlock entries={runningEntries} />);
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(<ToolTimelineBlock entries={runningEntries} turnActive expandAllRows />);
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(<ToolTimelineBlock entries={runningEntries} turnActive />);
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 <details>
// the user opened mid-turn would snap shut.
it('keeps the row list mounted across the settle transition', () => {
const { rerender } = renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive />);
const before = screen.getByTestId('tool-timeline-viewport').firstElementChild;
rerender(
<Provider store={store}>
<ToolTimelineBlock entries={runningEntries} turnActive={false} />
</Provider>
);
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(<ToolTimelineBlock entries={settledRows} turnActive />);
expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true);
});
it('collapses once the turn itself settles', () => {
renderInStore(<ToolTimelineBlock entries={settledRows} turnActive={false} />);
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(<ToolTimelineBlock entries={running} />);
expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true);
renderInStore(<ToolTimelineBlock entries={settledRows} />);
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(<ToolTimelineBlock entries={settledRows} turnActive={false} />);
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 <summary>, 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(
<ToolTimelineBlock entries={settled} turnActive={false} onViewWholeRun={onViewWholeRun} />
);
const group = screen.getByTestId('agent-task-insights');
expect(group).not.toHaveAttribute('open');
// Link is in the <summary>, 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(
<ToolTimelineBlock entries={settled} turnActive onViewWholeRun={onViewWholeRun} />
);
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 <pre>
// 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(<ToolTimelineBlock entries={[succeeded]} onViewDetails={vi.fn()} />);
// 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(<ToolTimelineBlock entries={[failed]} onViewDetails={vi.fn()} />);
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(<ToolTimelineBlock entries={[succeeded, failed]} onViewDetails={vi.fn()} />);
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(<ToolTimelineBlock entries={[succeeded]} expandAllRows />);
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(
<ToolTimelineBlock
entries={entries}
turnActive
transcript={[
{ kind: 'narration', round: 1, seq: 0, text: 'Let me get the data for both.' },
{ kind: 'toolCall', round: 1, seq: 1, callId: 'tx-1' },
]}
/>
);
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(
<ToolTimelineBlock
entries={entries}
turnActive
transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'Working…' }]}
/>
);
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(<ToolTimelineBlock entries={entries} turnActive />);
expect(screen.queryByTestId('processing-transcript')).toBeNull();
expect(screen.getByTestId('agent-task-insights')).toBeInTheDocument();
});
it('falls back when the transcript is present but empty', () => {
renderInStore(<ToolTimelineBlock entries={entries} turnActive transcript={[]} />);
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(
<ToolTimelineBlock
entries={[subagentEntry]}
turnActive
transcript={[{ kind: 'toolCall', round: 1, seq: 0, callId: 'sa-tx' }]}
/>
);
// 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(<ToolTimelineBlock entries={[subagentEntry]} turnActive />);
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(
<ToolTimelineBlock
entries={[subagentEntry]}
turnActive
transcript={[{ kind: 'toolCall', round: 1, seq: 0, callId: 'sa-tx' }]}
/>
);
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(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
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(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
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(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
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(
<ToolTimelineBlock entries={entries} turnActive={false} transcript={transcript} />
);
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(
<ToolTimelineBlock entries={[]} turnActive transcript={[]} />
);
expect(screen.queryByTestId('tool-timeline-viewport')).toBeNull();
expect(ro.count()).toBe(0);
// …then the first tool row lands.
rerender(
<Provider store={store}>
<ToolTimelineBlock
entries={[{ id: 'late-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }]}
turnActive
transcript={[]}
/>
</Provider>
);
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(
<ToolTimelineBlock
entries={[]}
turnActive
transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'Let me get the data.' }]}
/>
);
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(<ToolTimelineBlock entries={[]} turnActive transcript={[]} />);
expect(screen.queryByTestId('agent-task-insights')).toBeNull();
});
});
@@ -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<string, unknown>
): 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]);
});
});
@@ -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<number> {
const hidden = new Set<number>();
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;
}
+26 -23
View File
@@ -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) {
@@ -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', () => {
+28 -1
View File
@@ -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 — <tool>]" 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())
}