fix(flows): persist agentic task insights expand state across copilot turns (#4942)

This commit is contained in:
Cyrus Gray
2026-07-16 12:47:38 +05:30
committed by GitHub
parent 6d0b7c2b5f
commit d59eeede66
2 changed files with 141 additions and 8 deletions
@@ -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<boolean | null>(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<HTMLElement>) => {
event.preventDefault();
const next = !open;
log('agent-task-insights: user toggled open=%s (auto would be %s)', next, autoOpen);
setUserOverrideOpen(next);
};
return (
<details
open={open}
className="group/insights mb-2 px-1 py-0"
data-testid="agent-task-insights">
<summary className="mb-1.5 flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
<summary
onClick={handleSummaryClick}
className="mb-1.5 flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
{titleLabel}
<span className="text-[11px] text-content-faint transition-transform group-open/insights:rotate-90">
@@ -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(<ToolTimelineBlock entries={turn1Settled} />);
// 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(
<Provider store={store}>
<ToolTimelineBlock entries={turn2Running} />
</Provider>
);
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(
<Provider store={store}>
<ToolTimelineBlock entries={turn2Settled} />
</Provider>
);
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(<ToolTimelineBlock entries={running} />);
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
const settled: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'success' },
];
rerender(
<Provider store={store}>
<ToolTimelineBlock entries={settled} />
</Provider>
);
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(<ToolTimelineBlock entries={turn1Running} />);
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(
<Provider store={store}>
<ToolTimelineBlock entries={turn2Running} />
</Provider>
);
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
});
});
it('renders the tool result output inside the expanded row', () => {
const entries: ToolTimelineEntry[] = [
{