From 34b0fbff5d064dc30d04b5317fbb442297b264e0 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:11:30 +0530 Subject: [PATCH] fix(conversations): reset agentic task insights override on turn settle (#5008) --- .../components/ToolTimelineBlock.tsx | 23 +++- .../__tests__/ToolTimelineBlock.test.tsx | 113 +++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index b78330de5..8c8d1484c 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,5 +1,5 @@ import createDebug from 'debug'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -499,6 +499,26 @@ export function ToolTimelineBlock({ // turn), so a plain `useState` already survives every turn it needs to. const [userOverrideOpen, setUserOverrideOpen] = useState(null); + // Whether *any* entry is currently running — computed here (ahead of the + // `entries.length === 0` early return below) purely so the reset effect + // that follows is an unconditional hook call every render; order doesn't + // matter for this existence check, unlike `latestRunningEntryId` further + // down, which needs the seq-sorted order to pick a specific "latest" row. + const isRunning = entries.some(entry => entry.status === 'running'); + + // Reset the user's manual open/close override on the running→settled edge + // (a turn just finished) so the auto-collapse applies to the just-settled + // turn. The override only sticks WITHIN a turn — preventing involuntary + // mid-feedback collapse (#4942) — not permanently across turns. + const prevIsRunningRef = useRef(false); + useEffect(() => { + if (prevIsRunningRef.current && !isRunning) { + log('agent-task-insights: turn settled (running→done), resetting user override'); + setUserOverrideOpen(null); + } + prevIsRunningRef.current = isRunning; + }, [isRunning]); + if (entries.length === 0) return null; // The rows + the parent's streaming response — shared by both the collapsible @@ -513,7 +533,6 @@ export function ToolTimelineBlock({ // but sorts earlier (e.g. seq [2, 0, 1]) gets treated as "latest" and the // wrong step stays expanded/linked in compact chat mode. const latestRunningEntryId = [...ordered].reverse().find(entry => entry.status === 'running')?.id; - const isRunning = latestRunningEntryId != null; const titleLabel = ( diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 6743380e6..c8ddb1d6d 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -396,7 +396,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { // earlier. These tests simulate that same "new turn's entries land on an // already-mounted block" shape via `rerender` rather than remounting. describe('agentic task insights — sticky user expand/collapse across turns', () => { - it('keeps an explicit user expand across a new turn that starts and settles', () => { + it('resets a user expand when a new turn settles', () => { const turn1Settled: ToolTimelineEntry[] = [ { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, ]; @@ -408,7 +408,9 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { fireEvent.click(screen.getByText('Agentic task insights')); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); - // A new turn/feedback starts streaming onto the SAME mounted block. + // A new turn/feedback starts streaming onto the SAME mounted block. The + // override still wins WHILE it runs — the user's choice isn't clobbered + // mid-turn (#4942). const turn2Running: ToolTimelineEntry[] = [ ...turn1Settled, { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, @@ -420,8 +422,9 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); - // ...and settles. Without the fix this is exactly where it would - // involuntarily re-collapse, wiping out the user's choice. + // ...and settles. The override only sticks WITHIN a turn — once this + // turn finishes, the auto-collapse applies to it, so the panel + // collapses instead of permanently overriding every future turn. const turn2Settled: ToolTimelineEntry[] = [ ...turn1Settled, { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, @@ -431,7 +434,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); - expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); }); it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { @@ -476,6 +479,106 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { ); expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); }); + + it('auto-collapses when a turn finishes even if the user had expanded it', () => { + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // User expands the settled turn1 panel. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // A new turn starts running — stays open (both the override and the + // auto rule agree here). + const turn2Running: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // It settles — the override is cleared on this running→settled edge, + // so the panel auto-collapses instead of staying pinned open forever. + const turn2Settled: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('does not collapse mid-stream when new entries arrive on a running turn', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // More entries stream in while the turn is still running — the + // running→settled edge never fires, so the panel must stay open. + const turn1StillRunning: ToolTimelineEntry[] = [ + ...turn1Running, + { id: 't1b', name: 'file_read', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + const turn1MoreRunning: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + { id: 't1b', name: 'file_read', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + + it('respects a manual expand during an active turn, resets on settle', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore(); + // Auto-open while running (no override yet). + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // User explicitly collapses it mid-run... + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // ...then explicitly re-expands it — a manual expand during the still- + // active turn — and it must stick while the turn keeps running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The turn settles — the manual override resets on this edge, and + // since the run is done the auto rule collapses the panel. + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, seq: 0, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); }); it('renders the tool result output inside the expanded row', () => {