diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3c98f48a421..3b5183ffa5a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -208,6 +208,7 @@ import { useBackgroundTaskViewState, useBackgroundTaskViewActions, } from './contexts/BackgroundTaskViewContext.js'; +import { getLiveAgentPanelLayoutKey } from './components/background-view/liveAgentPanelVisibility.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -1584,7 +1585,11 @@ export const AppContainer = (props: AppContainerProps) => { const [hasTabConsumer, setHasTabConsumer] = useState(false); const agentViewState = useAgentViewState(); - const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState(); + const { + dialogOpen: bgTasksDialogOpen, + entries: bgTaskEntries, + livePanelFocused: bgLivePanelFocused, + } = useBackgroundTaskViewState(); const { closeDialog: closeBgTasksDialog } = useBackgroundTaskViewActions(); // Prompt suggestion state @@ -2536,6 +2541,15 @@ export const AppContainer = (props: AppContainerProps) => { : 'hidden'; const [controlsHeight, setControlsHeight] = useState(0); + // Re-measure the footer whenever the LiveAgentPanel's height can change + // (agents launching / finishing / focus), so `controlsHeight` — and thus + // `availableTerminalHeight` — never goes stale below the composer. See + // getLiveAgentPanelLayoutKey for the full rationale (#5798). + const liveAgentPanelLayoutKey = getLiveAgentPanelLayoutKey( + bgTaskEntries, + bgLivePanelFocused, + ); + useLayoutEffect(() => { if (!mainControlsRef.current) { setControlsHeight((previousHeight) => @@ -2557,6 +2571,7 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, dialogsVisible, stickyTodosLayoutKey, + liveAgentPanelLayoutKey, ]); // agentViewState is declared earlier (before handleFinalSubmit) so it diff --git a/packages/cli/src/ui/app-container-controls-dep.test.ts b/packages/cli/src/ui/app-container-controls-dep.test.ts new file mode 100644 index 00000000000..c80077db9c3 --- /dev/null +++ b/packages/cli/src/ui/app-container-controls-dep.test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Source-level regression guard for the one-line fix in #5798/#5799 that the + * behavioural tests cannot reach. + * + * The fix is: `liveAgentPanelLayoutKey` is listed in the dependency array of + * the `useLayoutEffect` that measures `controlsHeight` from `mainControlsRef`. + * Removing it silently re-introduces the non-VP overflow flicker (the footer + * stops being re-measured when the LiveAgentPanel grows). + * + * Why this is a source assertion rather than a render test: the behaviour only + * manifests on an in-place UPDATE of AppContainer, and ink-testing-library's + * `rerender` remounts AppContainer (re-running every mount effect regardless of + * its deps), while an external `setState` does not flush ink's reconciler. So a + * real AppContainer always re-measures on (re)mount in tests and the missing + * dependency is invisible to a render-based assertion — exactly why dropping it + * leaves the mechanism tests (which use a stand-in component) green. This guard + * pins the dependency directly, so a deps-array cleanup or an `exhaustive-deps` + * autofix cannot quietly delete the fix. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const source = readFileSync( + join(import.meta.dirname, 'AppContainer.tsx'), + 'utf8', +); + +/** Extract the dependency array of the controls-height measurement effect. */ +function controlsHeightEffectDeps(): string { + const measureAt = source.indexOf('measureElement(mainControlsRef.current)'); + expect(measureAt).toBeGreaterThan(-1); + const depsOpen = source.indexOf('}, [', measureAt); + expect(depsOpen).toBeGreaterThan(-1); + const depsClose = source.indexOf(']);', depsOpen); + expect(depsClose).toBeGreaterThan(depsOpen); + return source.slice(depsOpen, depsClose); +} + +describe('AppContainer controls-height measurement wiring', () => { + it('measures controls height from mainControlsRef', () => { + // Sanity: the effect we are guarding still exists and is shaped as expected. + expect(source).toContain('measureElement(mainControlsRef.current)'); + expect(source).toContain('setControlsHeight('); + }); + + it('lists liveAgentPanelLayoutKey in the measurement effect dependencies', () => { + const deps = controlsHeightEffectDeps(); + // Confirm we located the right deps array before the key assertion. + expect(deps).toContain('terminalHeight'); + expect(deps).toContain('stickyTodosLayoutKey'); + // The fix: dropping this entry re-introduces the non-VP overflow flicker. + expect(deps).toContain('liveAgentPanelLayoutKey'); + }); + + it('computes liveAgentPanelLayoutKey from the live agent roster', () => { + // The key must be derived from the roster + focus, not a constant. Match + // whitespace-tolerantly so prettier reformatting can't break the guard. + expect(source).toMatch( + /liveAgentPanelLayoutKey\s*=\s*getLiveAgentPanelLayoutKey\(\s*bgTaskEntries\s*,\s*bgLivePanelFocused\s*,?\s*\)/, + ); + }); +}); diff --git a/packages/cli/src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx b/packages/cli/src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx new file mode 100644 index 00000000000..32b21ebd1ab --- /dev/null +++ b/packages/cli/src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Runtime reproduction of the non-VP overflow flicker root cause. + * + * AppContainer reserves room for the footer with + * availableTerminalHeight = terminalHeight - controlsHeight - ... + * where `controlsHeight` is measured from the controls box (which contains the + * LiveAgentPanel) inside a `useLayoutEffect` gated by a dependency array. The + * panel grows as agents launch, but the panel's only self-driven re-render is a + * per-second elapsed-time tick that never changes the roster. So unless the + * roster is part of the measurement effect's deps, the effect does not re-run + * when an agent launches: `controlsHeight` stays stale, `availableTerminalHeight` + * stays too large, the pending region overflows the terminal, and every repaint + * forces the view back to the bottom with a flicker. + * + * This test faithfully mirrors that exact measurement contract on a minimal + * component — real ink render, real `measureElement`, real + * `getLiveAgentPanelLayoutKey` — and shows that: + * - WITHOUT the roster key in the deps, a roster that grows leaves the + * measured controls height (and thus availableHeight) stale; and + * - WITH the roster key in the deps, the controls are re-measured and the + * reserved room shrinks to match — which is exactly the one-line fix + * applied in AppContainer. + */ + +import { describe, it, expect } from 'vitest'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { render } from 'ink-testing-library'; +import { Box, Text, measureElement, type DOMElement } from 'ink'; +import { getLiveAgentPanelLayoutKey } from './liveAgentPanelVisibility.js'; +import type { AgentDialogEntry } from '../../hooks/useBackgroundTaskView.js'; + +const TERMINAL_HEIGHT = 24; + +const agent = (id: string): AgentDialogEntry => + ({ + kind: 'agent', + id, + description: 'desc', + status: 'running', + startTime: 0, + abortController: new AbortController(), + }) as unknown as AgentDialogEntry; + +/** + * Minimal stand-in for AppContainer's footer-measurement contract. The controls + * box renders one row per agent (so its real measured height grows with the + * roster), measures itself into `controlsHeight` via a useLayoutEffect, and + * reports the resulting availableHeight. `wireRosterDep` toggles whether the + * roster signal is part of the effect deps — i.e. buggy vs fixed. + */ +function ControlsMeasured({ + entries, + wireRosterDep, + report, +}: { + entries: readonly AgentDialogEntry[]; + wireRosterDep: boolean; + report: (availableHeight: number) => void; +}) { + const ref = useRef(null); + const [controlsHeight, setControlsHeight] = useState(0); + + const rosterKey = getLiveAgentPanelLayoutKey(entries, false); + const deps = wireRosterDep ? [rosterKey] : []; + + useLayoutEffect(() => { + if (!ref.current) return; + const { height } = measureElement(ref.current); + setControlsHeight((prev) => (prev === height ? prev : height)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + report(Math.max(0, TERMINAL_HEIGHT - controlsHeight)); + + return ( + + main + {entries.map((e) => ( + {e.id} · running + ))} + + ); +} + +async function measureGrowth( + wireRosterDep: boolean, +): Promise<{ before: number; after: number }> { + let availableHeight = -1; + const report = (v: number) => { + availableHeight = v; + }; + + const { rerender, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const before = availableHeight; + + // Three agents launch → the controls box is now three rows taller. + rerender( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const after = availableHeight; + + unmount(); + return { before, after }; +} + +describe('LiveAgentPanel growth → controls re-measurement', () => { + it('BUG: without the roster in the measurement deps, reserved room goes stale on growth', async () => { + const { before, after } = await measureGrowth(false); + // Footer was measured once with an empty roster and never again, so the + // reserved room does not shrink even though the panel grew by three rows. + expect(before).toBeGreaterThan(0); + expect(after).toBe(before); + }); + + it('FIX: wiring the roster key into the deps re-measures, shrinking reserved room', async () => { + const { before, after } = await measureGrowth(true); + // The taller controls footprint is now reflected: less room is left for the + // main content, so it can no longer overflow the terminal. + expect(after).toBeLessThan(before); + expect(before - after).toBe(3); // exactly the three new agent rows + }); +}); diff --git a/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.test.ts b/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.test.ts new file mode 100644 index 00000000000..9b917ad18e9 --- /dev/null +++ b/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + getLiveAgentPanelLayoutKey, + isLiveAgentPanelVisibleEntry, + TERMINAL_VISIBLE_MS, +} from './liveAgentPanelVisibility.js'; +import type { + AgentDialogEntry, + DialogEntry, +} from '../../hooks/useBackgroundTaskView.js'; + +function agentEntry( + overrides: Partial = {}, +): AgentDialogEntry { + return { + kind: 'agent', + id: 'a', + description: 'desc', + status: 'running', + startTime: 0, + abortController: new AbortController(), + ...overrides, + } as AgentDialogEntry; +} + +function shellEntry(overrides: Partial = {}): DialogEntry { + return { + kind: 'shell', + shellId: 'bg_x', + command: 'sleep 60', + cwd: '/tmp', + status: 'running', + startTime: 0, + outputPath: '/tmp/x.out', + abortController: new AbortController(), + ...overrides, + } as DialogEntry; +} + +describe('getLiveAgentPanelLayoutKey', () => { + it('changes when an agent is added (panel grows)', () => { + const before = getLiveAgentPanelLayoutKey([], false); + const after = getLiveAgentPanelLayoutKey([agentEntry({ id: 'a1' })], false); + expect(after).not.toBe(before); + }); + + it('changes when an agent is removed (panel shrinks)', () => { + const two = getLiveAgentPanelLayoutKey( + [agentEntry({ id: 'a1' }), agentEntry({ id: 'a2' })], + false, + ); + const one = getLiveAgentPanelLayoutKey([agentEntry({ id: 'a1' })], false); + expect(one).not.toBe(two); + }); + + it('changes when an agent status flips (running -> completed)', () => { + const running = getLiveAgentPanelLayoutKey( + [agentEntry({ id: 'a1', status: 'running' })], + false, + ); + const done = getLiveAgentPanelLayoutKey( + [agentEntry({ id: 'a1', status: 'completed', endTime: 1 })], + false, + ); + expect(done).not.toBe(running); + }); + + it('changes when panel focus toggles (adds the navigation hint row)', () => { + const entries = [agentEntry({ id: 'a1' })]; + expect(getLiveAgentPanelLayoutKey(entries, true)).not.toBe( + getLiveAgentPanelLayoutKey(entries, false), + ); + }); + + it('is STABLE across per-second elapsed-time ticks (no height change)', () => { + // The panel re-renders every second to refresh elapsed time, but that + // tick never touches the roster — the key must not churn, or AppContainer + // would needlessly re-measure the footer every second. + const entries = [ + agentEntry({ id: 'a1', status: 'running', startTime: 0 }), + agentEntry({ id: 'a2', status: 'running', startTime: 0 }), + ]; + const k1 = getLiveAgentPanelLayoutKey(entries, false); + const k2 = getLiveAgentPanelLayoutKey(entries, false); + expect(k2).toBe(k1); + }); + + it('ignores non-agent entries (panel renders only agents)', () => { + const onlyShell = getLiveAgentPanelLayoutKey([shellEntry()], false); + const empty = getLiveAgentPanelLayoutKey([], false); + expect(onlyShell).toBe(empty); + }); +}); + +// Guard the assumption the layout key relies on: a finished agent stays +// visible (so its row keeps occupying height) for the eviction window, and +// only then shrinks the panel — the "safe" direction the key intentionally +// does not track. +describe('isLiveAgentPanelVisibleEntry (eviction window)', () => { + it('returns false for non-agent entries', () => { + expect(isLiveAgentPanelVisibleEntry(shellEntry(), 1000)).toBe(false); + }); + + it('keeps running agents visible unconditionally (no endTime)', () => { + expect( + isLiveAgentPanelVisibleEntry(agentEntry({ status: 'running' }), 1000), + ).toBe(true); + }); + + it('keeps paused agents visible unconditionally (no endTime)', () => { + expect( + isLiveAgentPanelVisibleEntry(agentEntry({ status: 'paused' }), 1000), + ).toBe(true); + }); + + it('returns false for a terminal agent missing endTime (guards NaN)', () => { + // nowMs - undefined would be NaN, and NaN <= window is false — assert the + // explicit endTime guard short-circuits before that comparison. + const entry = agentEntry({ status: 'completed' }); + expect(isLiveAgentPanelVisibleEntry(entry, 1000)).toBe(false); + }); + + it('keeps a terminal agent visible within the window, evicts after', () => { + const entry = agentEntry({ status: 'completed', endTime: 1000 }); + expect(isLiveAgentPanelVisibleEntry(entry, 1000)).toBe(true); + expect( + isLiveAgentPanelVisibleEntry(entry, 1000 + TERMINAL_VISIBLE_MS), + ).toBe(true); + expect( + isLiveAgentPanelVisibleEntry(entry, 1000 + TERMINAL_VISIBLE_MS + 1), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.ts b/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.ts index 049ff19620d..22895542232 100644 --- a/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.ts +++ b/packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.ts @@ -22,3 +22,40 @@ export function isLiveAgentPanelVisibleEntry( if (entry.endTime === undefined) return false; return nowMs - entry.endTime <= TERMINAL_VISIBLE_MS; } + +/** + * A stable signature of everything that changes the LiveAgentPanel's + * **height** (and therefore the controls footprint that AppContainer reserves + * via `availableTerminalHeight`). + * + * The panel renders only agent-kind entries; their count and status + * (running / paused / terminal) drive the row count + the "N more above" + * overflow line, and the focus flag adds a navigation-hint row. Crucially the + * panel's per-second elapsed-time tick (`LiveAgentPanel`'s internal `setNow`) + * does NOT flow through the roster, so this key stays stable across those + * ticks — only genuine roster growth/shrink or focus changes alter it. + * + * AppContainer feeds this key into its `controlsHeight` measurement effect so + * the footer is re-measured exactly when the panel can grow. Without it, an + * agent launching grows the panel below the composer but `controlsHeight` + * stays stale, `availableTerminalHeight` is left too large, the pending region + * overflows the terminal, and (in non-VP mode) every repaint forces the + * terminal back to the bottom with a flicker. + * + * Time-based eviction (a finished agent's row disappearing after 8s) only + * SHRINKS the panel and is intentionally not captured here — a stale, slightly + * too-large reservation over-clips the pending region, which is the safe + * direction (no overflow). + */ +export function getLiveAgentPanelLayoutKey( + entries: readonly DialogEntry[], + livePanelFocused: boolean, +): string { + let key = livePanelFocused ? 'f' : '_'; + for (const entry of entries) { + if (entry.kind !== 'agent') continue; + // `id` is the canonical registry key; `agentId` is a @deprecated synonym. + key += `|${entry.id}:${entry.status}`; + } + return key; +}