diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx
index 67902ae68..657e6bbb6 100644
--- a/app/src/components/flows/WorkflowCopilotPanel.tsx
+++ b/app/src/components/flows/WorkflowCopilotPanel.tsx
@@ -157,6 +157,7 @@ export default function WorkflowCopilotPanel({
const {
threadId,
sending,
+ turnActive,
proposal,
capped,
displayMessages,
@@ -529,6 +530,7 @@ export default function WorkflowCopilotPanel({
)}
diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx
index aa3e6ef24..a25ff26c8 100644
--- a/app/src/features/conversations/Conversations.tsx
+++ b/app/src/features/conversations/Conversations.tsx
@@ -1983,6 +1983,15 @@ const Conversations = ({
entries={selectedThreadToolTimeline}
onViewDetails={openScopedDetail}
onViewWholeRun={openWholeRunSource}
+ // Reuse `isSending` rather than a raw `in` membership check on
+ // `inferenceTurnLifecycleByThread`: that map also carries
+ // `'interrupted'` entries (a turn that crashed mid-flight in a
+ // PRIOR core process, written by `hydrateRuntimeFromSnapshot` on
+ // cold boot) which have no live driver and must NOT read as an
+ // active turn, or stale disclosure state leaks into a later
+ // retry. `isSending` already excludes it (only `'started'` /
+ // `'streaming'`, same as this component's own live-turn checks).
+ turnActive={isSending}
/>
) : (
// Transcript-only turn: reasoning/narration was streamed but no tool
diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx
index 8c8d1484c..33249b07f 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 { useEffect, useRef, useState } from 'react';
+import { useState } from 'react';
import WorktreeActions from '../../../components/worktree/WorktreeActions';
import { useT } from '../../../lib/i18n/I18nContext';
@@ -454,6 +454,7 @@ export function ToolTimelineBlock({
onViewWholeRun,
expandAllRows = false,
liveResponse,
+ turnActive,
}: {
entries: ToolTimelineEntry[];
/** Opens the full-transcript drawer for a subagent row. When omitted,
@@ -479,6 +480,16 @@ export function ToolTimelineBlock({
* Omitted/empty once the turn settles — the final answer is the message
* bubble. */
liveResponse?: string;
+ /** Whether a turn is in flight on this thread's lifecycle
+ * (`inferenceTurnLifecycleByThread`), the same signal the chat threads page
+ * uses. When provided, the sticky `userOverrideOpen` reset fires on THIS
+ * value's true→false edge — once per USER TURN — instead of on `isRunning`'s
+ * edge, which flips once PER SUB-AGENT within a single turn (each
+ * subagent spawn→settle) and made the panel flicker open/closed as
+ * sub-agents ran (regression from #5008). Falls back to `isRunning` when
+ * omitted, which is correct for a settled/past-turn render (there is no
+ * turn left to track). */
+ turnActive?: boolean;
}) {
const { t } = useT();
@@ -500,24 +511,44 @@ export function ToolTimelineBlock({
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.
+ // `entries.length === 0` early return below) purely so the render-time
+ // reset adjustment that follows runs 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) {
+ // The signal the reset below watches for a "turn just settled" edge. Prefer
+ // the real TURN lifecycle (`turnActive`, sourced from
+ // `inferenceTurnLifecycleByThread` — the same signal the chat threads page
+ // uses) when the caller supplies it: it flips true→false exactly once per
+ // USER TURN. `isRunning` is only a fallback for callers with no turn
+ // lifecycle to hand (e.g. a settled/past-turn render, where entries never
+ // change again anyway) — used directly it flips once PER SUB-AGENT within a
+ // single turn (each subagent spawn→settle), which reset the override (and
+ // so auto-collapsed the panel) repeatedly within one turn and made it
+ // flicker open/closed as sub-agents ran (#5008 regression).
+ const settleSignal = turnActive ?? isRunning;
+
+ // Reset the user's manual open/close override on the settleSignal's
+ // true→false 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.
+ //
+ // Done as a render-time adjustment (comparing against `prevSettleSignal`
+ // state and calling both setters synchronously in the render body), not a
+ // `useEffect`, per React's documented pattern for resetting state on a prop
+ // transition: it bails out and re-renders with the reset applied before
+ // paint, instead of committing a stale (still-collapsed/expanded) frame
+ // and only correcting it a tick later once the effect runs.
+ const [prevSettleSignal, setPrevSettleSignal] = useState(settleSignal);
+ if (prevSettleSignal !== settleSignal) {
+ if (prevSettleSignal && !settleSignal) {
log('agent-task-insights: turn settled (running→done), resetting user override');
setUserOverrideOpen(null);
}
- prevIsRunningRef.current = isRunning;
- }, [isRunning]);
+ setPrevSettleSignal(settleSignal);
+ }
if (entries.length === 0) return null;
diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
index c8ddb1d6d..a054d60ff 100644
--- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
+++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
@@ -581,6 +581,148 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
});
});
+ // #5008 shipped the reset-on-settle fix using the `isRunning` true→false
+ // edge, but that edge fires once PER SUB-AGENT within a single turn (each
+ // subagent_spawned/subagent_completed pair toggles `isRunning`), not once
+ // per turn — so on a multi-sub-agent turn the panel's override reset (and
+ // its auto-collapse) fired repeatedly, flickering the panel open/closed
+ // as each sub-agent came and went. `turnActive` — sourced from
+ // `inferenceTurnLifecycleByThread`, the same lifecycle the chat threads
+ // page uses for `isSending` — transitions exactly once per USER TURN, so
+ // passing it in makes the reset track the turn instead of any single
+ // sub-agent.
+ describe('with turnActive prop', () => {
+ it('does not reset the user override while turnActive stays true across multiple isRunning toggles, only resetting (and auto-collapsing) when turnActive itself goes false', () => {
+ const subagentARunning: ToolTimelineEntry[] = [
+ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' },
+ ];
+ const { rerender } = renderInStore(
+
+ );
+ // Sub-agent A running, turn active → auto-open (no override yet).
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+
+ // Sub-agent A settles: `isRunning` flips true→false, but the whole
+ // TURN is still active. Pre-fix this alone would have reset the
+ // (nonexistent, still null) override — here there's nothing to reset,
+ // but the auto rule alone already collapses it (autoOpen tracks
+ // `isRunning`, unchanged by this fix).
+ const subagentASettled: ToolTimelineEntry[] = [
+ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+
+ // The user manually expands it while the turn is still in flight.
+ fireEvent.click(screen.getByText('Agentic task insights'));
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+
+ // Sub-agent B spawns: `isRunning` flips false→true again. Still one
+ // turn (`turnActive` unchanged) — the user's override must hold.
+ const subagentBRunning: ToolTimelineEntry[] = [
+ ...subagentASettled,
+ { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+
+ // Sub-agent B settles: `isRunning` flips true→false a second time
+ // within the SAME turn. This is exactly the edge that used to reset
+ // the override and cause the flicker (#5008 regression) — with
+ // `turnActive` supplied it must NOT reset; the user's expand sticks.
+ const subagentBSettled: ToolTimelineEntry[] = [
+ ...subagentASettled,
+ { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+
+ // Only when the TURN itself ends (`turnActive` true→false) does the
+ // override reset — the panel then auto-collapses since the run is done.
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+ });
+
+ it('keeps a mid-turn manual collapse intact across a sub-agent settling, only reopening per the auto rule once turnActive ends', () => {
+ const subagentARunning: ToolTimelineEntry[] = [
+ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' },
+ ];
+ const { rerender } = renderInStore(
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+
+ // The user explicitly collapses it while sub-agent A is still running.
+ fireEvent.click(screen.getByText('Agentic task insights'));
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+
+ // Sub-agent A settles, sub-agent B spawns and settles too — all within
+ // the same turn (`turnActive` stays true throughout). None of these
+ // `isRunning` toggles may reopen the panel against the user's choice.
+ const afterSubagentB: ToolTimelineEntry[] = [
+ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' },
+ { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+
+ const bothSettled: ToolTimelineEntry[] = [
+ { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' },
+ { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+
+ // The turn ends — override resets; auto rule (settled, not running)
+ // keeps it collapsed, same outcome but for the right reason now.
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
+
+ // Both sides of that transition read "collapsed", which a STALE `false`
+ // override would also produce — prove the override actually reset (not
+ // just that it happened to still agree with the auto rule) by starting
+ // a brand-new turn: the auto rule alone (isRunning) should now govern,
+ // reopening the panel with no further user interaction.
+ const newTurnRunning: ToolTimelineEntry[] = [
+ { id: 'c', name: 'subagent:researcher', round: 2, seq: 0, status: 'running' },
+ ];
+ rerender(
+
+
+
+ );
+ expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
+ });
+ });
+
it('renders the tool result output inside the expanded row', () => {
const entries: ToolTimelineEntry[] = [
{
diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts
index 20cf47c07..78caf651f 100644
--- a/app/src/hooks/useWorkflowBuilderChat.test.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.test.ts
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BuilderTurnResult } from '../services/api/flowsApi';
-import type { WorkflowProposal } from '../store/chatRuntimeSlice';
+import type { InferenceTurnLifecycle, WorkflowProposal } from '../store/chatRuntimeSlice';
import type { ThreadMessage } from '../types/thread';
import { useWorkflowBuilderChat, type WorkflowBuilderSendResult } from './useWorkflowBuilderChat';
@@ -21,6 +21,7 @@ const selectorState = vi.hoisted(() => ({
messagesByThreadId: {} as Record,
toolTimelineByThread: {} as Record,
streamingAssistantByThread: {} as Record,
+ inferenceTurnLifecycleByThread: {} as Record,
}));
vi.mock('../store/hooks', () => ({
useAppDispatch: () => dispatch,
@@ -31,6 +32,7 @@ vi.mock('../store/hooks', () => ({
pendingWorkflowProposalsByThread: selectorState.proposals,
toolTimelineByThread: selectorState.toolTimelineByThread,
streamingAssistantByThread: selectorState.streamingAssistantByThread,
+ inferenceTurnLifecycleByThread: selectorState.inferenceTurnLifecycleByThread,
},
}),
}));
@@ -43,6 +45,8 @@ vi.mock('../store/threadSlice', () => ({
THREAD_NOT_FOUND_MESSAGE,
}));
vi.mock('../store/chatRuntimeSlice', () => ({
+ beginInferenceTurn: (p: unknown) => ({ type: 'beginInferenceTurn', p }),
+ endInferenceTurn: (p: unknown) => ({ type: 'endInferenceTurn', p }),
clearWorkflowProposalForThread: (p: unknown) => ({ type: 'clearProposal', p }),
setWorkflowProposalForThread: (p: unknown) => ({ type: 'setProposal', p }),
fetchAndHydrateTurnState: (threadId: string) => ({ type: 'fetchAndHydrateTurnState', threadId }),
@@ -68,6 +72,7 @@ describe('useWorkflowBuilderChat', () => {
selectorState.messagesByThreadId = {};
selectorState.toolTimelineByThread = {};
selectorState.streamingAssistantByThread = {};
+ selectorState.inferenceTurnLifecycleByThread = {};
dispatch.mockReset().mockImplementation((action: { type: string }) => {
if (action.type === 'createNewThread') {
return { unwrap: () => Promise.resolve({ id: 'builder-1' }) };
@@ -107,6 +112,86 @@ describe('useWorkflowBuilderChat', () => {
await waitFor(() => expect(result.current.threadId).toBe('builder-1'));
});
+ it('seeds and clears the shared turn-lifecycle entry around the blocking flows_build call', async () => {
+ // Regression test: `turnActive` (derived from `inferenceTurnLifecycleByThread`
+ // below) must reflect a REAL turn in flight, or `ToolTimelineBlock`'s
+ // `turnActive ?? isRunning` fallback is defeated — a `false` `turnActive`
+ // (as opposed to `undefined`) short-circuits nullish coalescing and never
+ // falls back to `isRunning`, permanently disabling the panel's settle-edge
+ // override reset for the copilot surface this hook drives.
+ let sawActiveDuringCall = false;
+ buildWorkflow.mockImplementation(async () => {
+ // `beginInferenceTurn` must have been dispatched — and not yet cleared —
+ // by the time the blocking RPC is in flight.
+ sawActiveDuringCall = dispatch.mock.calls.some(
+ ([a]) => (a as { type: string }).type === 'beginInferenceTurn'
+ );
+ return okResult();
+ });
+
+ const { result } = renderHook(() => useWorkflowBuilderChat());
+ await act(async () => {
+ await result.current.send({
+ displayText: 'hi',
+ request: { mode: 'create', instruction: 'x' },
+ });
+ });
+
+ expect(sawActiveDuringCall).toBe(true);
+ const calls = dispatch.mock.calls.map(([a]) => a as { type: string; p?: unknown });
+ const beginIndex = calls.findIndex(a => a.type === 'beginInferenceTurn');
+ const endIndex = calls.findIndex(a => a.type === 'endInferenceTurn');
+ expect(beginIndex).toBeGreaterThanOrEqual(0);
+ expect(endIndex).toBeGreaterThan(beginIndex);
+ expect(calls[beginIndex].p).toEqual({ threadId: 'builder-1' });
+ expect(calls[endIndex].p).toEqual({ threadId: 'builder-1' });
+ });
+
+ it('clears the turn-lifecycle entry even when the blocking flows_build call throws', async () => {
+ buildWorkflow.mockRejectedValue(new Error('network blip'));
+
+ const { result } = renderHook(() => useWorkflowBuilderChat());
+ await act(async () => {
+ await result.current.send({
+ displayText: 'hi',
+ request: { mode: 'create', instruction: 'x' },
+ });
+ });
+
+ // A failed turn must not leak the lifecycle entry — otherwise `turnActive`
+ // would stay stuck `true` forever, since no `chat_done` ever arrives for a
+ // turn that never reached the server.
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'endInferenceTurn', p: { threadId: 'builder-1' } })
+ );
+ });
+
+ it("treats an 'interrupted' lifecycle entry as NOT active (no live driver behind it)", () => {
+ // 'interrupted' is written by `hydrateRuntimeFromSnapshot` for a turn that
+ // crashed mid-flight in a PRIOR core process (cold-boot rehydrate) — there
+ // is no live driver streaming for it, so `turnActive` must stay `false`,
+ // or stale disclosure state (from before the crash) leaks into whatever
+ // the user does next on this thread.
+ const interrupted: InferenceTurnLifecycle = 'interrupted';
+ selectorState.inferenceTurnLifecycleByThread = { 'builder-1': interrupted };
+
+ const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
+
+ expect(result.current.turnActive).toBe(false);
+ });
+
+ it("treats 'started'/'streaming' lifecycle entries as active", () => {
+ const started: InferenceTurnLifecycle = 'started';
+ selectorState.inferenceTurnLifecycleByThread = { 'builder-1': started };
+ const { result: startedResult } = renderHook(() => useWorkflowBuilderChat('builder-1'));
+ expect(startedResult.current.turnActive).toBe(true);
+
+ const streaming: InferenceTurnLifecycle = 'streaming';
+ selectorState.inferenceTurnLifecycleByThread = { 'builder-1': streaming };
+ const { result: streamingResult } = renderHook(() => useWorkflowBuilderChat('builder-1'));
+ expect(streamingResult.current.turnActive).toBe(true);
+ });
+
it('surfaces the proposal the builder returned by dispatching it into the store', async () => {
const proposal: WorkflowProposal = {
name: 'Digest',
diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts
index fa2710243..d17bce0f1 100644
--- a/app/src/hooks/useWorkflowBuilderChat.ts
+++ b/app/src/hooks/useWorkflowBuilderChat.ts
@@ -27,9 +27,15 @@
import createDebug from 'debug';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { type BuilderTurnRequest, buildWorkflow } from '../services/api/flowsApi';
import {
+ type BuilderTurnRequest,
+ type BuilderTurnResult,
+ buildWorkflow,
+} from '../services/api/flowsApi';
+import {
+ beginInferenceTurn,
clearWorkflowProposalForThread,
+ endInferenceTurn,
fetchAndHydrateTurnHistory,
fetchAndHydrateTurnState,
setWorkflowProposalForThread,
@@ -94,6 +100,15 @@ export interface UseWorkflowBuilderChat {
threadId: string | null;
/** True while a builder turn is in flight on this thread. */
sending: boolean;
+ /**
+ * Whether a turn is in flight on this thread per the runtime's
+ * `inferenceTurnLifecycleByThread` — the same turn-lifecycle signal the main
+ * chat threads page uses to derive `isSending`. Passed through as
+ * `ToolTimelineBlock`'s `turnActive` prop so the panel's sticky
+ * open/collapse override resets once per TURN instead of once per
+ * sub-agent (see that component's doc for the #5008 flicker this fixes).
+ */
+ turnActive: boolean;
/** The latest proposal the agent returned on this thread, or `null`. */
proposal: WorkflowProposal | null;
/**
@@ -196,6 +211,21 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
const streamingAssistantByThread = useAppSelector(
state => state.chatRuntime.streamingAssistantByThread
);
+ const inferenceTurnLifecycleByThread = useAppSelector(
+ state => state.chatRuntime.inferenceTurnLifecycleByThread
+ );
+
+ // A turn is in flight on this thread iff its lifecycle entry is `'started'`
+ // or `'streaming'` — NOT `'interrupted'`, which `hydrateRuntimeFromSnapshot`
+ // (chatRuntimeSlice.ts) writes for a turn that crashed mid-flight in a PRIOR
+ // core process (cold-boot rehydrate): there is no live driver behind it, so
+ // treating it as "active" would leak stale disclosure state into a later,
+ // genuinely new turn on this same thread. Mirrors `Conversations.tsx`'s
+ // `isSending` derivation (same explicit two-state check, not a broad `in`
+ // membership test) so the copilot's `ToolTimelineBlock` resets its sticky
+ // override on the same real turn-settle edge the main chat uses.
+ const threadLifecycle = threadId != null ? inferenceTurnLifecycleByThread[threadId] : undefined;
+ const turnActive = threadLifecycle === 'started' || threadLifecycle === 'streaming';
// Prefer the runtime's streamed proposal (populated on this thread by
// `ChatRuntimeProvider` as the builder's `propose_workflow`/`revise_workflow`
@@ -343,8 +373,37 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
// slices as the turn runs, so this hook must NOT also append the agent
// reply (doing so would double it — B26). We still await the blocking
// result for its `proposal`/`error` signal.
+ //
+ // Seed the shared turn-lifecycle entry for this thread (mirrors
+ // `Conversations.tsx`'s `beginInferenceTurn` dispatch on send) so
+ // `turnActive` above (`threadId in inferenceTurnLifecycleByThread`)
+ // reflects a REAL turn in flight. Without this, nothing ever creates
+ // an entry for the builder's dedicated thread — `ChatRuntimeProvider`
+ // only *updates* an existing entry (`markInferenceTurnStreaming` is a
+ // no-op unless one is already present) — so `turnActive` would stay
+ // `false` for the entire life of every builder turn. Because it's a
+ // *boolean* `false` rather than `undefined`, that permanently defeats
+ // `ToolTimelineBlock`'s `turnActive ?? isRunning` fallback (nullish
+ // coalescing only falls back on null/undefined, never on `false`),
+ // so the panel's settle-edge override reset — the entire point of
+ // this fix — would never fire for the copilot surface it targets.
+ dispatch(beginInferenceTurn({ threadId: targetThreadId }));
log('send: running flows_build thread=%s mode=%s', targetThreadId, request.mode);
- const result = await buildWorkflow(request, targetThreadId);
+ let result: BuilderTurnResult;
+ try {
+ result = await buildWorkflow(request, targetThreadId);
+ } finally {
+ // The blocking RPC settling (success or error) IS the turn ending —
+ // clear eagerly here rather than relying solely on
+ // `ChatRuntimeProvider`'s generic `chat_done` listener (which also
+ // calls `endInferenceTurn` for this thread — redundant but
+ // harmless, since it's a plain delete). If the server-side turn
+ // never reaches `chat_done` (e.g. this call throws before the core
+ // ever starts one), that listener never fires and the lifecycle
+ // entry would otherwise leak, stranding `turnActive` — and the
+ // panel it drives — permanently `true`.
+ dispatch(endInferenceTurn({ threadId: targetThreadId }));
+ }
// Surface the proposal via the same store slice the streamed path used,
// so `WorkflowProposalCard` / the copilot preview render unchanged. This
@@ -412,6 +471,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
return {
threadId,
sending,
+ turnActive,
proposal,
capped,
messages,