diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index 474b58852..d66d926f8 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,3 +1,6 @@ +import createDebug from 'debug'; +import { useState } from 'react'; + import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; import type { @@ -422,6 +425,8 @@ function RepeatCount({ count }: { count: number }) { ); } +const log = createDebug('app:conversations:tool-timeline'); + /** * Neutral surface tones for an expanded row's body (worker-thread card, * detail bubble, code block). Per the Figma "Agentic task insights" @@ -478,6 +483,23 @@ export function ToolTimelineBlock({ const { t } = useT(); const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; + // Sticky override for the outer "Agentic task insights" group: `null` means + // the user hasn't explicitly toggled it on THIS mount yet, so the group + // falls back to the auto rule below (open while running, collapsed once + // settled). Once the user clicks the summary, this pins their choice — + // including across later turns that stream onto the SAME mounted block + // (e.g. the workflow copilot's dedicated thread, whose `ToolTimelineBlock` + // stays mounted for the life of the conversation while `entries` keeps + // growing turn over turn) — so a new turn's activity landing no longer + // involuntarily re-collapses (or re-expands) a choice the user already + // made ("Agentic task insights keeps collapsing on every new feedback"). + // Deliberately component-local state, not lifted to Redux: + // the block never remounts mid-conversation in the one place this bug was + // reported (`WorkflowCopilotPanel` renders it at a stable JSX position + // with entries accumulating in `toolTimelineByThread`, not reset per + // turn), so a plain `useState` already survives every turn it needs to. + const [userOverrideOpen, setUserOverrideOpen] = useState(null); + if (entries.length === 0) return null; const isRunning = latestRunningEntryId != null; @@ -629,20 +651,38 @@ export function ToolTimelineBlock({ // The group header is a static section label — the live "working" state is // conveyed by the pulsing agent-name rows, so it never repeats a "Working…" - // string. The group is always collapsible: while the run is in flight it is - // open so the live activity is visible; once it settles it collapses to a - // single-line opener by default so a finished run (which can be dozens of - // steps) never dominates the conversation — the rows stay one click away, and - // the whole-run side panel is still reachable via the header link. The full - // "Agent Process Source" panel forces every row open via `expandAllRows`. - const open = isRunning || expandAllRows; + // string. Absent a user override, the group is auto-driven: open while the + // run is in flight so the live activity is visible; collapsed once it + // settles so a finished run (which can be dozens of steps) never dominates + // the conversation. The full "Agent Process Source" panel forces every row + // open via `expandAllRows`. Once the user has explicitly toggled the group + // (see `userOverrideOpen` above), THAT choice wins over the auto rule — + // otherwise a new turn streaming onto an already-mounted block (settling, + // or starting a fresh run) would silently flip `open` out from under the + // user's manual choice on every turn. + const autoOpen = isRunning || expandAllRows; + const open = userOverrideOpen ?? autoOpen; + + // Fully own the disclosure via React state rather than letting the browser + // toggle the native `open` attribute itself — `preventDefault` stops the + // native toggle so there is exactly one source of truth (`open` above), + // never a race between the DOM's own uncontrolled state and the next + // render's `open` prop. + const handleSummaryClick = (event: React.MouseEvent) => { + event.preventDefault(); + const next = !open; + log('agent-task-insights: user toggled open=%s (auto would be %s)', next, autoOpen); + setUserOverrideOpen(next); + }; return (
- + {titleLabel} ▶ diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 6924d3364..d0c692f34 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -358,6 +358,99 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); }); + // Regression coverage for "Agentic task insights keeps collapsing on every + // new feedback": the workflow copilot keeps ONE `ToolTimelineBlock` mounted + // for the life of a thread, appending each new turn's entries onto the same + // `entries` prop (see `WorkflowCopilotPanel`/`useWorkflowBuilderChat`) — it + // never remounts the block per turn. Before the fix, the outer group's + // `open` was driven purely by `isRunning || expandAllRows`, so every time a + // turn settled (running → not running) the group snapped shut regardless of + // anything the user had done, discarding a manual expand made moments + // 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', () => { + const turn1Settled: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'success' }, + ]; + const { rerender } = renderInStore(); + // Default: settled and collapsed (unchanged behaviour). + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The user manually expands it. + 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. + const turn2Running: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, status: 'running' }, + ]; + rerender( + + + + ); + 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. + const turn2Settled: ToolTimelineEntry[] = [ + ...turn1Settled, + { id: 't2', name: 'file_read', round: 2, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + + it('leaves the default open-while-running/collapsed-when-settled behaviour unchanged absent any user interaction', () => { + const running: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + const settled: ToolTimelineEntry[] = [ + { id: 'r', name: 'web_search', round: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('also persists an explicit user collapse across a new turn (does not force it back open)', () => { + const turn1Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'running' }, + ]; + const { rerender } = renderInStore(); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The user collapses it while a turn is still running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // A new turn starts running — the auto rule alone would force it back + // open, but the user's explicit collapse must win. + const turn2Running: ToolTimelineEntry[] = [ + { id: 't1', name: 'web_search', round: 1, status: 'success' }, + { id: 't2', name: 'file_read', round: 2, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + }); + it('renders the tool result output inside the expanded row', () => { const entries: ToolTimelineEntry[] = [ {