Skip to content
21 changes: 19 additions & 2 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,18 @@ const Conversations = ({
const shouldRenderTimelineBeforeLatestAgentMessage =
selectedThreadToolTimeline.length > 0 && !isSending && Boolean(latestVisibleAgentMessage);

// Live agent activity that must stay visible even before the thread's
// message history has loaded: an in-flight turn, recorded tool steps, a
// processing transcript, or streamed prose. Without this, switching to a
// thread mid-turn rendered a blank pane (the message list is gated on
// `hasVisibleMessages`) until `loadThreadMessages` resolved — tool calls and
// streaming output silently invisible despite landing in Redux.
const hasLiveAgentActivity =
isSending ||
selectedThreadToolTimeline.length > 0 ||
selectedThreadProcessing.length > 0 ||
Boolean(selectedStreamingAssistant);

// Anchor the "Agentic task insights" panel right after the latest turn's user
// message — processing happens *before* the answer, so it reads above the
// result (for both the live streaming preview and the settled agent bubbles).
Expand Down Expand Up @@ -1815,7 +1827,12 @@ const Conversations = ({
// centered composer; the moment the first message lands, hasVisibleMessages
// flips true and this collapses back to the normal conversation layout.
const isNewWindow =
!isSidebar && !isLoadingMessages && !messagesError && !hasVisibleMessages && !hasTaskBoard;
!isSidebar &&
!isLoadingMessages &&
!messagesError &&
!hasVisibleMessages &&
!hasTaskBoard &&
!hasLiveAgentActivity;

// Track the floating composer footer's height so the message list can reserve
// matching bottom padding. In the page variant the footer is absolutely
Expand Down Expand Up @@ -2140,7 +2157,7 @@ const Conversations = ({
{t('common.reload')}
</button>
</div>
) : hasVisibleMessages || hasTaskBoard ? (
) : hasVisibleMessages || hasTaskBoard || hasLiveAgentActivity ? (
<div
data-testid="chat-message-list"
className={`mx-auto w-full max-w-[48.75rem] space-y-3 px-5 pt-4 ${
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ function AgentSourceRow({ source }: { source: AgentSource }) {
);
}

function normalizeScopedBody(value: string | undefined | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/**
* The consolidated "Agent Process Source" side panel from the Figma Chat
* design — slid in from the right (~600px) when the user clicks
Expand Down Expand Up @@ -106,7 +111,9 @@ export function AgentProcessSourcePanel({
const subagentEntries = entries.filter(entry => entry.subagent);
// For a scoped *non*-sub-agent step, the detail (args / output) to show.
const scopedDetail = scopedEntry
? (formatTimelineEntry(scopedEntry).detail ?? scopedEntry.argsBuffer)
? (normalizeScopedBody(scopedEntry.result) ??
normalizeScopedBody(formatTimelineEntry(scopedEntry).detail) ??
normalizeScopedBody(scopedEntry.argsBuffer))
: undefined;

return (
Expand Down
50 changes: 35 additions & 15 deletions app/src/pages/conversations/components/ToolTimelineBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,12 @@ export function ToolTimelineBlock({
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.
const expandable = detailContent != null || subagent != null;
// 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);
Expand All @@ -470,19 +471,28 @@ export function ToolTimelineBlock({
// 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.
<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>
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
</span>
</button>
<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>
<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">
Expand Down Expand Up @@ -512,6 +522,16 @@ export function ToolTimelineBlock({
{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>
Comment thread
senamakel marked this conversation as resolved.
) : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,23 @@ describe('AgentProcessSourcePanel', () => {
expect(screen.queryByText('whole-run narration')).toBeNull();
});

it('prefers scoped tool result output over captured args/detail', () => {
const scoped: ToolTimelineEntry = {
id: 'tool-result-only',
name: 'run_code',
round: 1,
status: 'success',
argsBuffer: '{"command":"pnpm test"}',
result: 'exit 0\nAll checks passed.',
};
renderPanel(
<AgentProcessSourcePanel open entries={[scoped]} scopedEntry={scoped} onClose={() => {}} />
);
expect(screen.getByText('Run Code')).toBeInTheDocument();
expect(screen.getByText(/All checks passed/)).toBeInTheDocument();
expect(screen.queryByText(/pnpm test/)).toBeNull();
});

it('renders no source rows when no web tools were used', () => {
renderPanel(
<AgentProcessSourcePanel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,34 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull();
});

it('renders the tool result output inside the expanded row', () => {
const entries: ToolTimelineEntry[] = [
{
id: 'd',
name: 'web_search',
round: 1,
status: 'success',
argsBuffer: '{"query":"f1"}',
result: 'Top result: https://openhuman.dev',
},
];
renderInStore(<ToolTimelineBlock entries={entries} expandAllRows />);
const output = screen.getByTestId('tool-result-output');
expect(output.textContent).toContain('Top result: https://openhuman.dev');
});

it('makes a row expandable on a result alone and omits the block without one', () => {
const entries: ToolTimelineEntry[] = [
// No argsBuffer / detail / subagent — the result is the only body.
{ id: 'a', name: 'run_code', round: 1, status: 'success', result: 'exit 0' },
{ id: 'b', name: 'run_code', round: 2, status: 'success' },
];
renderInStore(<ToolTimelineBlock entries={entries} expandAllRows />);
const outputs = screen.getAllByTestId('tool-result-output');
expect(outputs).toHaveLength(1);
expect(outputs[0].textContent).toBe('exit 0');
});

it('renders the parent live response inside the panel under a Response heading', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' },
Expand Down Expand Up @@ -464,7 +492,14 @@ describe('ToolTimelineBlock — worker thread ref status propagation', () => {
describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
const entries: ToolTimelineEntry[] = [
// A finished step.
{ id: 'tl-1', name: 'agent_prepare_context', round: 1, status: 'success', detail: 'fetch X' },
{
id: 'tl-1',
name: 'agent_prepare_context',
round: 1,
status: 'success',
detail: 'fetch X',
result: 'Prepared context from 3 sources.',
},
// The currently-running sub-agent (latest running).
{
id: 'sa-1',
Expand Down Expand Up @@ -492,6 +527,9 @@ 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.'
);

// Clicking the finished step's link opens the full-run panel.
fireEvent.click(links[0]);
Expand Down
14 changes: 13 additions & 1 deletion app/src/providers/ChatRuntimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,13 +644,20 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const nextEntries = [...existing];
let changed = false;

// The core forwards the (size-capped) tool result text on `output`;
// keep it on the row so the timeline can show what the tool
// returned. Older cores sent a metadata stub here — accept only
// non-empty payloads so a stub-less row stays `undefined`.
const result = event.output && event.output.length > 0 ? event.output : undefined;

if (event.tool_call_id) {
const idx = nextEntries.findIndex(entry => entry.id === event.tool_call_id);
if (idx >= 0) {
nextEntries[idx] = {
...nextEntries[idx],
status: event.success ? 'success' : 'error',
failure,
result,
};
changed = true;
}
Expand All @@ -664,7 +671,12 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
entry.name === event.tool_name &&
entry.round === event.round
) {
nextEntries[i] = { ...entry, status: event.success ? 'success' : 'error', failure };
nextEntries[i] = {
...entry,
status: event.success ? 'success' : 'error',
failure,
result,
};
changed = true;
break;
}
Expand Down
60 changes: 60 additions & 0 deletions app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,66 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
expect(timeline[0]?.status).toBe('running');
});

it('attaches the tool_result output to the timeline row as its result', () => {
const listeners = renderProvider();

act(() => {
listeners.onToolCall?.({
thread_id: 't-res',
request_id: 'r1',
round: 0,
tool_name: 'web_search',
skill_id: 'web_channel',
args: {},
tool_call_id: 'call-res',
});
listeners.onToolResult?.({
thread_id: 't-res',
request_id: 'r1',
round: 0,
tool_name: 'web_search',
skill_id: 'web_channel',
output: 'Top hit: openhuman.dev',
success: true,
tool_call_id: 'call-res',
});
});

const row = store.getState().chatRuntime.toolTimelineByThread['t-res']?.[0];
expect(row?.status).toBe('success');
expect(row?.result).toBe('Top hit: openhuman.dev');
});

it('leaves result unset when the tool_result carries no output text', () => {
const listeners = renderProvider();

act(() => {
listeners.onToolCall?.({
thread_id: 't-res2',
request_id: 'r1',
round: 0,
tool_name: 'web_search',
skill_id: 'web_channel',
args: {},
tool_call_id: 'call-res2',
});
listeners.onToolResult?.({
thread_id: 't-res2',
request_id: 'r1',
round: 0,
tool_name: 'web_search',
skill_id: 'web_channel',
output: '',
success: true,
tool_call_id: 'call-res2',
});
});

const row = store.getState().chatRuntime.toolTimelineByThread['t-res2']?.[0];
expect(row?.status).toBe('success');
expect(row?.result).toBeUndefined();
});

it('collapses a spawn_subagent tool-call row into the subagent row', () => {
const listeners = renderProvider();

Expand Down
Loading
Loading