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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 41 additions & 31 deletions app/src/features/conversations/components/ChatThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>
);
Expand Down Expand Up @@ -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');
Expand All @@ -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">
Expand All @@ -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>
);
}
Expand Down
Loading
Loading