Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1807,7 +1807,6 @@ const Conversations = ({
entries={selectedThreadToolTimeline}
onViewDetails={openScopedDetail}
onViewWholeRun={openWholeRunSource}
liveResponse={selectedStreamingAssistant?.content}
/>
) : (
// Transcript-only turn: reasoning/narration was streamed but no tool
Expand Down Expand Up @@ -2560,8 +2559,7 @@ const Conversations = ({
replaces this via addInferenceResponse on chat_done. */}
{selectedStreamingAssistant &&
(selectedStreamingAssistant.thinking.length > 0 ||
(selectedStreamingAssistant.content.length > 0 &&
(selectedThreadToolTimeline.length === 0 || hideAgentInsights))) && (
selectedStreamingAssistant.content.length > 0) && (
<div className="flex justify-start">
<div className="relative w-fit max-w-[75%]">
{selectedStreamingAssistant.thinking.length > 0 && (
Expand All @@ -2575,19 +2573,17 @@ const Conversations = ({
</pre>
</details>
)}
{selectedStreamingAssistant.content.length > 0 &&
(selectedThreadToolTimeline.length === 0 || hideAgentInsights) && (
<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>
)}
{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>
)}
Expand Down
124 changes: 89 additions & 35 deletions app/src/pages/conversations/components/ToolTimelineBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,75 @@ function normalizeToolBody(value?: string): string | undefined {
return value;
}

/**
* Whether a timeline entry carries any unique body worth its own row — a
* sub-agent's live activity, a returned result, a prompt/detail bubble, or a
* structured failure. A row with none of these renders as a bare label + status
* and is therefore indistinguishable from any sibling with the same title, so it
* is safe to coalesce (see {@link coalesceTimelineEntries}). Mirrors the
* `expandable` predicate in the row renderer so the two never disagree.
*/
function entryHasUniqueBody(entry: ToolTimelineEntry): boolean {
const formatted = formatTimelineEntry(entry);
const detailContent = normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
const resultContent = normalizeToolBody(entry.result);
return (
detailContent != null ||
resultContent != null ||
entry.subagent != null ||
entry.failure != null
);
}

/** A rendered timeline row: a representative entry plus how many identical,
* body-less entries it stands in for (`count === 1` for an ordinary row). */
interface CoalescedRow {
entry: ToolTimelineEntry;
count: number;
}

/**
* Collapse runs of consecutive, identical, body-less rows into a single row
* carrying an `×N` count. A retry loop (e.g. the orchestrator re-spawning the
* integrations agent 25×, each surfacing the same "Checking your connected app"
* label with no distinguishing detail) would otherwise flood the timeline with
* indistinguishable nodes. Only truly interchangeable rows merge: same title,
* same status, no unique body (result/detail/sub-agent/failure), and never the
* live `running` row — so no information is lost, only duplication.
*/
export function coalesceTimelineEntries(entries: ToolTimelineEntry[]): CoalescedRow[] {
const rows: CoalescedRow[] = [];
for (const entry of entries) {
const mergeable = entry.status !== 'running' && !entryHasUniqueBody(entry);
const previous = rows[rows.length - 1];
if (
mergeable &&
previous != null &&
previous.entry.status === entry.status &&
!entryHasUniqueBody(previous.entry) &&
previous.entry.status !== 'running' &&
formatTimelineEntry(previous.entry).title === formatTimelineEntry(entry).title
) {
previous.count += 1;
continue;
}
rows.push({ entry, count: 1 });
}
return rows;
}

/** Compact "×N" badge appended to a coalesced row's label. */
function RepeatCount({ count }: { count: number }) {
if (count <= 1) return null;
return (
<span
className="shrink-0 rounded-full bg-surface-subtle px-1.5 py-0.5 text-[10px] font-medium text-content-muted"
data-testid="timeline-repeat-count">
×{count}
</span>
);
}

/**
* Neutral surface tones for an expanded row's body (worker-thread card,
* detail bubble, code block). Per the Figma "Agentic task insights"
Expand Down Expand Up @@ -438,10 +507,14 @@ export function ToolTimelineBlock({

// The rows + the parent's streaming response — shared by both the collapsible
// (in-flight) and static (settled) header layouts below.
// Coalesce runs of identical, body-less rows (e.g. a retry loop that spawns
// the same integrations step 25×) into single `×N` rows before rendering.
const rows = coalesceTimelineEntries(entries);

const body = (
<>
<div className="text-sm text-content-faint">
{entries.map((entry, index) => {
{rows.map(({ entry, count }, index) => {
const formatted = formatTimelineEntry(entry);
const detailContent =
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
Expand All @@ -465,7 +538,7 @@ export function ToolTimelineBlock({
<AgentTimelineRail
key={entry.id}
isFirst={index === 0}
isLast={index === entries.length - 1}>
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
Expand All @@ -481,6 +554,7 @@ export function ToolTimelineBlock({
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>
Expand Down Expand Up @@ -540,8 +614,9 @@ export function ToolTimelineBlock({
) : null}
</details>
) : (
<div className="flex items-center">
<div className="flex items-center gap-1.5">
<span className={`text-[13px] font-medium ${nameTone}`}>{formatted.title}</span>
<RepeatCount count={count} />
</div>
)}
</AgentTimelineRail>
Expand All @@ -554,40 +629,19 @@ export function ToolTimelineBlock({

// The group header is a static section label — the live "working" state is
// conveyed by the pulsing agent-name rows, so it never repeats a "Working…"
// string. While the run is in flight the group is collapsible; once it
// settles the chevron/collapse is dropped and the header renders static —
// matching the finished sub-agent steps, which also drop their collapse when
// done.
if (!isRunning) {
return (
<div className="mb-2 px-1 py-0" data-testid="agent-task-insights">
<div className="mb-1.5 flex items-center">
{onViewWholeRun ? (
// Settled: the whole title is the link — "Agentic task insights →"
// opens the full-run panel (matches the collapsed step rows).
<button
type="button"
onClick={onViewWholeRun}
data-testid="view-process-source"
className="group/insights-link flex items-center gap-1.5 text-left">
<span className="text-[13px] font-medium text-content-muted group-hover/insights-link:underline">
{t('conversations.agentTaskInsights.title')}
</span>
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
→
</span>
</button>
) : (
titleLabel
)}
</div>
{body}
</div>
);
}
// string. The group is always collapsible: while the run is in flight it is
// open so the live activity is visible; once it settles it collapses to a
// single-line opener by default so a finished run (which can be dozens of
// steps) never dominates the conversation — the rows stay one click away, and
// the whole-run side panel is still reachable via the header link. The full
// "Agent Process Source" panel forces every row open via `expandAllRows`.
const open = isRunning || expandAllRows;

return (
<details open className="group/insights mb-2 px-1 py-0" data-testid="agent-task-insights">
<details
open={open}
className="group/insights mb-2 px-1 py-0"
data-testid="agent-task-insights">
<summary className="mb-1.5 flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
{titleLabel}
<span className="text-[11px] text-content-faint transition-transform group-open/insights:rotate-90">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,35 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull();
});

it('stays open while running and collapses once settled so a finished run does not dominate', () => {
const running: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'running' },
];
const { rerender } = renderInStore(<ToolTimelineBlock entries={running} />);
// In flight → the group is open so the live activity is visible.
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');

// Settled (no running row) → collapsed by default; the rows stay in the DOM
// one click away, but no longer flood the conversation.
const settled: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'success' },
];
rerender(
<Provider store={store}>
<ToolTimelineBlock entries={settled} />
</Provider>
);
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');

// The side panel still forces every row open via expandAllRows.
rerender(
<Provider store={store}>
<ToolTimelineBlock entries={settled} expandAllRows />
</Provider>
);
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
});

it('renders the tool result output inside the expanded row', () => {
const entries: ToolTimelineEntry[] = [
{
Expand Down Expand Up @@ -396,6 +425,52 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
});
});

describe('ToolTimelineBlock — coalescing repeated rows', () => {
it('collapses consecutive identical body-less rows into one ×N row', () => {
// A retry loop that spawns the same integrations step five times, each
// surfacing the generic "Checking your connected app" label with no
// distinguishing detail/result/subagent.
const entries: ToolTimelineEntry[] = Array.from({ length: 5 }, (_, i) => ({
id: `dup-${i}`,
name: 'integrations_agent',
round: 1,
status: 'success' as const,
}));
renderInStore(<ToolTimelineBlock entries={entries} />);
// Five entries render as a single rail row carrying an ×5 badge.
expect(screen.getAllByTestId('agent-timeline-row')).toHaveLength(1);
expect(screen.getByTestId('timeline-repeat-count').textContent).toBe('×5');
});

it('does not merge across differing status or the live running row', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'a', name: 'integrations_agent', round: 1, status: 'success' },
{ id: 'b', name: 'integrations_agent', round: 1, status: 'success' },
// Different status breaks the run.
{ id: 'c', name: 'integrations_agent', round: 1, status: 'error' },
// The live running row is never folded away.
{ id: 'd', name: 'integrations_agent', round: 1, status: 'running' },
];
renderInStore(<ToolTimelineBlock entries={entries} />);
// success×2 (merged) + error (single) + running (single) = 3 rows.
expect(screen.getAllByTestId('agent-timeline-row')).toHaveLength(3);
const counts = screen.getAllByTestId('timeline-repeat-count');
expect(counts).toHaveLength(1);
expect(counts[0].textContent).toBe('×2');
});

it('never merges rows that carry a unique result body', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' },
{ id: 'b', name: 'run_code', round: 1, status: 'success', result: 'exit 1' },
];
renderInStore(<ToolTimelineBlock entries={entries} expandAllRows />);
// Both keep their own row — distinct results are never coalesced.
expect(screen.getAllByTestId('agent-timeline-row')).toHaveLength(2);
expect(screen.queryByTestId('timeline-repeat-count')).toBeNull();
});
});

describe('ToolTimelineBlock — subagent rendering', () => {
it('expands a subagent row even without prompt detail and shows child tool calls', () => {
const entry: ToolTimelineEntry = {
Expand Down
34 changes: 34 additions & 0 deletions app/src/providers/ChatRuntimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ChatDoneEvent,
type ChatInferenceHeartbeatEvent,
type ChatInferenceStartEvent,
type ChatInterimEvent,
type ChatIterationStartEvent,
type ChatPlanReviewRequestEvent,
type ChatSegmentEvent,
Expand Down Expand Up @@ -1040,6 +1041,39 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
})
);
},
onInterim: (event: ChatInterimEvent) => {
// One interim per round — `round` is a stable per-turn dedup key that
// survives socket reconnect/replay (a re-delivered frame must not
// append the narration bubble twice).
const eventKey = `interim:${event.thread_id}:${event.request_id}:${event.round}`;
if (
!markChatEventSeen(eventKey, { threadId: event.thread_id, requestId: event.request_id })
)
return;
const content = event.full_response?.trim() ?? '';
if (!content) return;
// Persist this round's leading narration as its own interleaved bubble.
void dispatch(addInferenceResponse({ content, threadId: event.thread_id }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize interim appends before final responses

When a tool returns quickly after chat_interim, this fire-and-forget append can still be in flight when chat_done starts its own addInferenceResponse append. Since the thunk updates the local cache only on fulfillment and the core thread store appends messages in call-arrival order, the final answer can render/persist before the interim narration bubble, which breaks the intended interleaving for fast tool rounds; queue or await per-thread interim appends before appending the final response.

Useful? React with 👍 / 👎.

// 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.
const cr = store.getState().chatRuntime;
const existing = cr.streamingAssistantByThread[event.thread_id];
if (existing && existing.requestId === event.request_id) {
dispatch(
setStreamingAssistantForThread({
threadId: event.thread_id,
streaming: {
requestId: existing.requestId,
content: '',
thinking: existing.thinking,
},
})
);
}
},
onTextDelta: event => {
const cr = store.getState().chatRuntime;
// A parallel (forked) turn streams into its own lane so it doesn't
Expand Down
Loading
Loading