fix(cli): keep terminal background agents reachable from the dialog

The view hook used to filter the registry snapshot to running entries
only, so once the last background agent finished the pill disappeared
and the dialog became unreachable — final stats, prompt, and errors
were stranded for the rest of the session even though the registry
still held them. Expose every entry from the hook and let surfaces
that only care about live work do their own filtering. The pill stays
mounted whenever any entry exists and switches to a quieter "done"
label after the last agent terminates, so users can still open the
dialog to inspect final state.
This commit is contained in:
愚远 2026-04-26 14:12:31 +08:00
parent 36cb2e2dab
commit 40a8d27163
6 changed files with 67 additions and 40 deletions

View file

@ -135,7 +135,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} = useBackgroundAgentViewState();
const { setPillFocused: setBgPillFocused } = useBackgroundAgentViewActions();
const hasAgents = agents.size > 0;
const hasRunningBgAgents = bgEntries.some((e) => e.status === 'running');
// Includes terminal entries — the pill stays open so users can reopen
// the dialog to inspect final state after the last agent finishes.
const hasBgAgents = bgEntries.length > 0;
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -947,7 +949,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setAgentTabBarFocused(true);
return true;
}
if (hasRunningBgAgents) {
if (hasBgAgents) {
setBgPillFocused(true);
return true;
}
@ -1117,7 +1119,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
bgDialogOpen,
bgPillFocused,
hasAgents,
hasRunningBgAgents,
hasBgAgents,
setAgentTabBarFocused,
setBgPillFocused,
followup,

View file

@ -419,9 +419,11 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
}, [dialogOpen, dialogMode, selectedAgentId, selectedStatus]);
// Auto-fallback to the list view when the selected agent reaches a
// terminal state while the user is watching the detail view. We only
// exit on the running → terminal *transition* — if the user deliberately
// opened an already-completed entry, they stay on it.
// terminal state while the user is watching it live. We only exit on
// the running → terminal *transition* — if the user deliberately
// opened an already-completed entry, they stay on it. The detail
// view itself renders terminal state fine, so this is a UX choice
// (return focus to the running roster) rather than a correctness fix.
const initialDetailStatusRef = useRef<{
agentId: string;
status: BackgroundAgentEntry['status'];
@ -431,11 +433,10 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
initialDetailStatusRef.current = null;
return;
}
// The viewed entry disappeared out from under us — most commonly because
// the user pressed `x` and the live-entries filter dropped it once the
// registry flipped its status to terminal. Without this fallback the
// dialog would sit in detail mode with a stranded "No entry to show"
// screen.
// Defensive fallback: if the viewed entry has somehow gone missing,
// drop back to the list so we don't sit on a "No entry to show" screen.
// Hitting this path now is unlikely — terminal entries stay in the
// registry — but the entry could disappear if the registry is reset.
if (!selectedAgentId) {
initialDetailStatusRef.current = null;
exitDetail();

View file

@ -33,4 +33,29 @@ describe('getPillLabel', () => {
]),
).toBe('3 local agents');
});
it('counts only running entries when running and terminal mix', () => {
expect(
getPillLabel([
entry({ agentId: 'a', status: 'running' }),
entry({ agentId: 'b', status: 'completed' }),
entry({ agentId: 'c', status: 'cancelled' }),
]),
).toBe('1 local agent');
});
it('uses singular done form for one terminal-only entry', () => {
expect(getPillLabel([entry({ agentId: 'a', status: 'completed' })])).toBe(
'1 local agent done',
);
});
it('uses plural done form when all entries are terminal', () => {
expect(
getPillLabel([
entry({ agentId: 'a', status: 'completed' }),
entry({ agentId: 'b', status: 'failed' }),
]),
).toBe('2 local agents done');
});
});

View file

@ -15,16 +15,24 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import type { BackgroundAgentEntry } from '@qwen-code/qwen-code-core';
/** Single source of truth for pluralising the pill label. */
export function getPillLabel(running: readonly BackgroundAgentEntry[]): string {
const n = running.length;
return n === 1 ? '1 local agent' : `${n} local agents`;
/**
* Pill label: counts running entries while any are running; once everything
* has terminated, switches to a "done" form so the pill still invites
* reopening the dialog to inspect final state.
*/
export function getPillLabel(entries: readonly BackgroundAgentEntry[]): string {
const running = entries.filter((e) => e.status === 'running').length;
if (running > 0) {
return running === 1 ? '1 local agent' : `${running} local agents`;
}
return entries.length === 1
? '1 local agent done'
: `${entries.length} local agents done`;
}
export const BackgroundTasksPill: React.FC = () => {
const { entries, pillFocused } = useBackgroundAgentViewState();
const { openDialog, setPillFocused } = useBackgroundAgentViewActions();
const running = entries.filter((e) => e.status === 'running');
const onKeypress = useCallback(
(key: Key) => {
@ -46,9 +54,9 @@ export const BackgroundTasksPill: React.FC = () => {
useKeypress(onKeypress, { isActive: pillFocused });
if (running.length === 0) return null;
if (entries.length === 0) return null;
const label = getPillLabel(running);
const label = getPillLabel(entries);
return (
<>

View file

@ -115,14 +115,15 @@ export function BackgroundAgentViewProvider({
const [dialogMode, setDialogMode] = useState<BackgroundDialogMode>('closed');
const [pillFocused, setPillFocused] = useState(false);
const dialogOpen = dialogMode !== 'closed';
const hasRunning = entries.some((e) => e.status === 'running');
const hasEntries = entries.length > 0;
// Drop stale pill focus as soon as the pill loses its reason to exist —
// without this, InputPrompt's input-blocking branch would stay on after
// the last running agent finishes.
// Drop stale pill focus once the pill itself unmounts — i.e., when the
// registry is empty. The pill stays rendered while terminal entries
// exist (so the user can reopen the dialog post-termination), so we
// intentionally do *not* drop focus on the running → terminal flip.
useEffect(() => {
if (pillFocused && !hasRunning) setPillFocused(false);
}, [pillFocused, hasRunning]);
if (pillFocused && !hasEntries) setPillFocused(false);
}, [pillFocused, hasEntries]);
// rawSelectedIndex can fall out of range when entries shrink; clamp on read.
const selectedIndex =

View file

@ -7,7 +7,9 @@
/**
* useBackgroundAgentView subscribes to the background task registry's
* status-change callback and maintains a reactive snapshot of every
* `BackgroundAgentEntry`.
* `BackgroundAgentEntry`, including terminal ones. Surfaces that only
* care about live work (the footer pill, the composer's Down-arrow
* route) filter for `running` themselves.
*
* Intentionally ignores activity updates (appendActivity). Tool-call
* traffic from a running background agent would otherwise churn the
@ -26,17 +28,6 @@ export interface UseBackgroundAgentViewResult {
entries: readonly BackgroundAgentEntry[];
}
// The registry keeps terminal entries for the whole session so the model
// can still read their transcripts and the notification path stays deduped.
// The dialog only surfaces live work, so filter terminal statuses out here
// — otherwise "0 active agents" in the header would disagree with
// "Local agents (N)" in the list once anything finishes.
function selectLiveEntries(
all: readonly BackgroundAgentEntry[],
): BackgroundAgentEntry[] {
return all.filter((e) => e.status === 'running');
}
export function useBackgroundAgentView(
config: Config | null,
): UseBackgroundAgentViewResult {
@ -46,12 +37,11 @@ export function useBackgroundAgentView(
if (!config) return;
const registry = config.getBackgroundTaskRegistry();
// getAll() returns entries in registration order, which is startTime
// order — no sort needed.
setEntries(selectLiveEntries(registry.getAll()));
// getAll() returns a fresh array in registration (= startTime) order.
setEntries(registry.getAll());
const onStatusChange = () => {
setEntries(selectLiveEntries(registry.getAll()));
setEntries(registry.getAll());
};
registry.setStatusChangeCallback(onStatusChange);