Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) =>
Expand All @@ -2557,6 +2571,7 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem,
dialogsVisible,
stickyTodosLayoutKey,
liveAgentPanelLayoutKey,
Comment thread
chiga0 marked this conversation as resolved.
]);

// agentViewState is declared earlier (before handleFinalSubmit) so it
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/ui/app-container-controls-dep.test.ts
Original file line number Diff line number Diff line change
@@ -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*\)/,
);
});
});
Original file line number Diff line number Diff line change
@@ -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<DOMElement>(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 (
<Box flexDirection="column" ref={ref}>
<Text>main</Text>
{entries.map((e) => (
<Text key={e.id}>{e.id} · running</Text>
))}
</Box>
);
}

async function measureGrowth(
wireRosterDep: boolean,
): Promise<{ before: number; after: number }> {
let availableHeight = -1;
const report = (v: number) => {
availableHeight = v;
};

const { rerender, unmount } = render(
<ControlsMeasured
entries={[]}
wireRosterDep={wireRosterDep}
report={report}
/>,
);
await new Promise((r) => setTimeout(r, 20));
const before = availableHeight;

// Three agents launch → the controls box is now three rows taller.
rerender(
<ControlsMeasured
entries={[agent('a1'), agent('a2'), agent('a3')]}
wireRosterDep={wireRosterDep}
report={report}
/>,
);
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
});
});
Original file line number Diff line number Diff line change
@@ -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> = {},
): AgentDialogEntry {
return {
kind: 'agent',
id: 'a',
description: 'desc',
status: 'running',
startTime: 0,
abortController: new AbortController(),
...overrides,
} as AgentDialogEntry;
}

function shellEntry(overrides: Partial<DialogEntry> = {}): 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', () => {
Comment thread
chiga0 marked this conversation as resolved.
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);
});
});
Loading
Loading