fix(conversations): render task insights panel once after full response (#3717 Bug 2) (#3726)

This commit is contained in:
sanil-23
2026-06-17 13:09:31 +05:30
committed by GitHub
parent f3086ca90c
commit bd5db5e95f
2 changed files with 155 additions and 25 deletions
+28 -25
View File
@@ -1859,13 +1859,6 @@ const Conversations = ({
const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text';
return (
<div key={msg.id}>
{shouldRenderTimelineBeforeLatestAgentMessage &&
latestVisibleAgentMessage?.id === msg.id && (
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
/>
)}
<div
className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
@@ -1913,16 +1906,6 @@ const Conversations = ({
if (citations.length === 0) return null;
return <CitationChips citations={citations} />;
})()}
{shouldRenderTimelineBeforeLatestAgentMessage &&
latestVisibleAgentMessage?.id === msg.id && (
<button
type="button"
onClick={() => setShowProcessSource(true)}
data-testid="view-process-source"
className="px-1 text-[11px] font-medium text-primary-600 hover:underline dark:text-primary-300">
{t('conversations.agentTaskInsights.viewProcessSource')}
</button>
)}
{latestVisibleMessage?.id === msg.id && (
<p className="px-1 text-[10px] text-stone-400 dark:text-neutral-500">
{formatRelativeTime(msg.createdAt)}
@@ -2254,14 +2237,34 @@ const Conversations = ({
</span>
</div>
)}
{/* Tool call timeline */}
{selectedThreadToolTimeline.length > 0 &&
!shouldRenderTimelineBeforeLatestAgentMessage && (
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
/>
)}
{/* Agentic task insights — rendered exactly once AFTER the full
message list. A single logical assistant turn can be persisted
as multiple agent ThreadMessages; anchoring the panel before the
last agent message split the response into two disconnected
chunks (issue #3717, Bug 2). Hoisting it here keeps the panel
after the complete response regardless of how many agent
messages the turn produced — both for the settled/inline case
(shouldRenderTimelineBeforeLatestAgentMessage) and the live
in-flight fallback. */}
{selectedThreadToolTimeline.length > 0 && (
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
/>
)}
{/* "View full agent process" — only in the settled/inline state
(turn finished, an agent message exists). Hoisted out of the
per-message map alongside the panel above so it renders once
after the response, never interleaved between bubbles. */}
{shouldRenderTimelineBeforeLatestAgentMessage && (
<button
type="button"
onClick={() => setShowProcessSource(true)}
data-testid="view-process-source"
className="px-1 text-[11px] font-medium text-primary-600 hover:underline dark:text-primary-300">
{t('conversations.agentTaskInsights.viewProcessSource')}
</button>
)}
{isSending && rustChat && (
<div className="flex justify-start px-1">
<button
@@ -1557,6 +1557,133 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', ()
// toolbar (#3611) — those tests removed; the pill no longer renders here.
});
// #3717 (Bug 2) — A single logical assistant turn can be persisted as multiple
// agent ThreadMessages. The "Agentic task insights" panel used to be anchored
// inside the per-message map, immediately before the LAST agent message, which
// dropped it BETWEEN the earlier agent content and the final message — splitting
// one response into two disconnected chunks. The panel (and the "View full agent
// process" button) are now hoisted out of the map so they render exactly once,
// AFTER the complete response, regardless of how many agent messages the turn
// produced.
describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
mockUseUsageState.mockReturnValue({
teamUsage: null,
currentPlan: null,
currentTier: 'FREE' as const,
isFreeTier: true,
usagePct: 0,
isNearLimit: false,
isAtLimit: false,
isBudgetExhausted: false,
shouldShowBudgetCompletedMessage: false,
isLoading: false,
refresh: vi.fn(),
});
});
it('renders the insights panel exactly once, after the last agent message of a multi-message turn', async () => {
const thread = makeThread({ id: 'multi-agent-thread', title: 'Multi-message turn' });
// One logical assistant turn persisted as TWO agent ThreadMessages.
const messages: ThreadMessage[] = [
{
id: 'm-user',
sender: 'user',
type: 'text',
content: 'Plan and then summarize.',
extraMetadata: {},
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'm-agent-1',
sender: 'agent',
type: 'text',
content: 'First part of the answer.',
extraMetadata: {},
createdAt: '2026-01-01T00:01:00.000Z',
},
{
id: 'm-agent-2',
sender: 'agent',
type: 'text',
content: 'Second part of the answer.',
extraMetadata: {},
createdAt: '2026-01-01T00:02:00.000Z',
},
];
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });
let store: ReturnType<typeof buildStore> | undefined;
await act(async () => {
store = await renderConversations({
thread: {
...selectedThreadState(thread),
messagesByThreadId: { [thread.id]: messages },
messages,
},
socket: socketState('connected'),
});
});
// Seed the tool timeline after mount settles (mount-time turn-state
// hydration would otherwise clobber a preloaded timeline). Include a
// running subagent row so the panel exposes its "view full processing"
// affordance (drives the onViewSubagent callback below).
await screen.findByText('Second part of the answer.');
await act(async () => {
store!.dispatch(
setToolTimelineForThread({
threadId: thread.id,
entries: [
{ id: 'tl-1', name: 'web_fetch', round: 1, status: 'success' },
{
id: 'sa-1',
name: 'subagent:researcher',
round: 1,
status: 'running',
subagent: { taskId: 'task-1', agentId: 'researcher', toolCalls: [] },
},
],
})
);
});
// Panel renders once — not once per agent message.
const panels = await screen.findAllByTestId('agent-task-insights');
expect(panels).toHaveLength(1);
const panel = panels[0];
// The "View full agent process" button is hoisted alongside it — also once.
expect(screen.getAllByTestId('view-process-source')).toHaveLength(1);
// DOM order: the panel must follow the LAST agent message's content, never
// sit between the two agent bubbles.
const lastAgentText = screen.getByText('Second part of the answer.');
const firstAgentText = screen.getByText('First part of the answer.');
expect(lastAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(firstAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
// Exercise the hoisted button: opens the "Agent Process Source" panel.
await act(async () => {
fireEvent.click(screen.getByTestId('view-process-source'));
});
// Exercise onViewSubagent: clicking the subagent row's "view full
// processing" affordance opens the subagent drawer for that task.
await act(async () => {
fireEvent.click(screen.getByTestId('subagent-view-processing'));
});
});
});
describe('Conversations — thread title editing', () => {
beforeEach(() => {
vi.clearAllMocks();