mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(flows): persist agentic task insights expand state across copilot turns (#4942)
This commit is contained in:
@@ -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[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user