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
15 changes: 13 additions & 2 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 Down Expand Up @@ -512,6 +513,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 @@ -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
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
12 changes: 10 additions & 2 deletions app/src/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,16 @@ export interface ChatEventListeners {
}

export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
const socket = socketService.getSocket();
if (!socket) return () => {};
// Register through the socketService wrapper (not the raw socket instance)
// so chat listeners get the same lifecycle guarantees as every other
// subscription: queued while the socket is still connecting (the raw-socket
// path silently no-opped when `getSocket()` was null, dropping the whole
// chat event stream until the next re-subscribe) and re-attached when the
// service flushes pending listeners on (re)connect.
const socket = {
on: (event: string, cb: (...args: unknown[]) => void) => socketService.on(event, cb),
off: (event: string, cb: (...args: unknown[]) => void) => socketService.off(event, cb),
};

const handlers: Array<[string, (...args: unknown[]) => void]> = [];
// Canonical convention for web-channel events is snake_case.
Expand Down
Loading
Loading