Skip to content

fix(cli): prevent scroll snap-back and flicker in non-VP mode during multi-agent runs - #5799

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
chiga0:fix/non-vp-controls-height-roster
Jun 25, 2026
Merged

fix(cli): prevent scroll snap-back and flicker in non-VP mode during multi-agent runs#5799
wenshao merged 5 commits into
QwenLM:mainfrom
chiga0:fix/non-vp-controls-height-roster

Conversation

@chiga0

@chiga0 chiga0 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Keeps the footer height reservation in sync with the always-on LiveAgentPanel as background agents launch and finish, so the main content area never overflows the terminal during multi-agent runs. It adds a small roster-derived layout key (mirroring the existing sticky-todos layout key) and feeds it into the footer-measurement effect, so the controls are re-measured exactly when the panel can grow — and stays quiet across the panel's per-second elapsed-time tick.

Why it's needed

In non-VP mode (the default), when output plus the footer exceeds the terminal height while background agents are running, the view cannot be scrolled up: any scroll-up snaps straight back to the bottom and the screen flickers, clearing up only once the run finishes. The footer is measured into controlsHeight and subtracted from the available height for the main content, but the measurement only re-ran on a fixed set of dependencies that did not include the agent roster. The LiveAgentPanel renders inside the measured controls box and grows as agents launch, yet its only self-driven re-render is a per-second elapsed-time tick that never changes the roster — so controlsHeight went stale on agent launch, the reserved room was left too large, and the dynamic region overflowed the terminal, forcing the view back to the bottom on every repaint. Fixes #5798.

Reviewer Test Plan

How to verify

Automated — from the repo root:

$ npx vitest run \
    packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.test.ts \
    packages/cli/src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx
 Test Files  2 passed (2)
      Tests  13 passed (13)

The runtime reproduction (liveAgentPanelLayout.measurement.test.tsx) mirrors the exact measurement contract on a minimal component using real ink render, real measureElement, and the real layout-key helper: without the roster in the measurement deps a roster that grows from 0 → 3 agents leaves the reserved room unchanged (after === before, i.e. stale → would overflow); with it, the controls are re-measured and the reserved room shrinks by exactly the three new rows (before - after === 3). The unit tests pin the key's contract (changes on agent add/remove/status/focus, stable across the per-second tick) and cover every branch of the visibility predicate.

Manual (the live symptom): on a short terminal (~20 rows) in non-VP mode, run a flow that launches several background agents and produces tall pending output, then try to scroll up while they run. Before: the view snaps back to the bottom and flickers each second; after: the footer footprint is reserved correctly and the snap-back / flicker is gone.

Evidence (Before & After)

Before: scroll-up snaps back to the bottom with per-second flicker during a multi-agent run (overflowing dynamic region). After: the dynamic region stays within the viewport and scrolling/redraw is stable. A reviewer can confirm via the automated reproduction above (the stale-vs-re-measured assertion is the programmatic Before/After); the maintainer CI smoke test on this PR also confirms no startup/regression impact.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Verified locally on macOS (unit + runtime-reproduction tests, plus the existing AppContainer / LiveAgentPanel / DefaultAppLayout suites). Windows/Linux not run locally — covered by CI.

Environment (optional)

Local: npx vitest run on macOS. No runtime sandbox needed; the change is TUI layout logic exercised by ink-testing-library and real ink render.

Risk & Scope

  • Main risk or tradeoff: the footer is re-measured on every agent add/remove/status-flip/focus change. These already re-render AppContainer (the roster comes from a context it consumes), so there is no extra render churn; the per-second elapsed-time tick deliberately does not change the key, so it does not trigger re-measurement.
  • Downstream of the corrected availableTerminalHeight: besides the pending-region clip (the fix), the only other consumer is the embedded-shell pty sizing. So with a foreground shell running and background agents changing the panel at the same time, the pty is now resized to track the panel (previously the height was stale during agent runs and the pty did not follow). This is more correct, not a regression, and is bounded by roster-change events (not the per-second tick).
  • Not validated / out of scope: the VP / virtual-scroll path is untouched (its auto-stick-to-bottom is a separate mechanism); scrolling semantics are unchanged. Time-based eviction (a finished agent's row disappearing after ~8s) is intentionally not tracked — it only shrinks the panel, which over-reserves room (safe direction, no overflow).
  • Breaking changes / migration notes: none. Internal layout-measurement signal only; no settings, APIs, or user-facing behavior change beyond fixing the overflow.

Linked Issues

Fixes #5798

中文说明

这个 PR 做了什么

让底部 footer 的高度预留与常驻的 LiveAgentPanel(随后台 agent 启动/结束而增减行数)保持同步,从而在多 agent 运行时主内容区不再溢出终端。做法是新增一个由 roster 派生的 layout key(仿照已有的 sticky-todos layout key),接入 footer 高度测量的 effect 依赖,使控件区在 panel 可能变高时被重新测量,同时对 panel 每秒的计时刷新保持不变。

为什么需要

非 VP 模式(默认)下,当输出加 footer 超过终端高度且有后台 agent 在运行时,页面无法上滚:一上滚就被拽回底部并闪烁,直到运行结束才恢复。footer 被测量进 controlsHeight 并从主内容可用高度里扣除,但该测量只在一组固定依赖变化时重跑,而这组依赖不含 agent roster。LiveAgentPanel 渲染在被测量的控件盒内、随 agent 启动而变高,但它唯一的自驱动重渲染是每秒计时 tick(不改变 roster)——于是 agent 启动时 controlsHeight 失准偏小,预留空间偏大,动态区溢出终端,每次重绘都把视图顶回底部。修复 #5798

Reviewer Test Plan

如何验证

自动化(仓库根目录):见上方 vitest 命令,13 个测试全过。运行时复现(liveAgentPanelLayout.measurement.test.tsx)用真实 ink 渲染 + 真实 measureElement + 真实 layout-key 函数,在最小组件上复刻测量契约:未接入 roster 依赖时,roster 从 0→3 个 agent 但预留高度不变(after === before,失准→会溢出);接入后控件被重测,预留高度精确减少 3 行(before - after === 3)。单测固定 key 的契约(roster 增删/状态/focus 时变化,每秒 tick 时不变),并覆盖可见性判定的所有分支。

手动(真实现象):约 20 行高的短终端、非 VP 模式下,运行会启动多个后台 agent 且产生较高 pending 输出的流程,运行中尝试上滚。Before:视图被拽回底部并每秒闪烁;After:footer 占位正确预留,拽回/闪烁消失。

Evidence(Before & After)

Before:多 agent 运行中上滚被拽回底部、每秒闪烁(动态区溢出)。After:动态区保持在视口内,滚动/重绘稳定。可通过上方自动化复现确认(stale-vs-重测 的断言即程序化的 Before/After);本 PR 上 maintainer CI 的 smoke test 也确认无启动/回归影响。

测试平台

本地仅在 macOS 验证(单测 + 运行时复现,以及既有 AppContainer / LiveAgentPanel / DefaultAppLayout 套件)。Windows/Linux 本地未跑,由 CI 覆盖。

环境(可选)

本地:macOS 上 npx vitest run。无需运行时沙箱;改动为 TUI 布局逻辑,由 ink-testing-library 与真实 ink 渲染覆盖。

风险与范围

  • 主要风险/权衡:footer 会在每次 agent 增删/状态翻转/focus 变化时重测。这些本就会让 AppContainer 重渲染(roster 来自它已消费的 context),故无额外渲染开销;每秒计时 tick 刻意不改变 key,不触发重测。
  • 修正后的 availableTerminalHeight 的下游:除 pending 区裁剪(即本修复)外,唯一的另一个消费者是嵌入式 shell 的 pty 尺寸。因此当"前台 shell 运行"与"后台 agent 改变 panel"同时发生时,pty 现在会随 panel 同步 resize(此前 agent 运行期高度失准、pty 不跟随)。这是更正确的行为、非回归,且频率受 roster 变化事件限制(非每秒 tick)。
  • 未验证/范围外:VP / 虚拟滚动路径未改动(其自动吸底是另一套机制);滚动语义不变。基于时间的驱逐(agent 完成约 8s 后行消失)刻意不追踪——它只会让 panel 变矮、即预留偏大(安全方向,不溢出)。
  • 破坏性变更/迁移说明:无。仅内部布局测量信号;除修复溢出外无设置、API 或用户可见行为变化。

关联 Issue

Fixes #5798

…overflow flicker (QwenLM#5798)

In non-VP mode the dynamic (non-<Static>) region is repainted in place; once
it is taller than the terminal, every repaint forces the view back to the
bottom with a flicker. The pending region is supposed to be clipped to
availableTerminalHeight = terminalHeight - controlsHeight - ..., but
controlsHeight was measured by a useLayoutEffect whose dependency list did not
reflect the LiveAgentPanel roster.

The panel renders inside mainControlsRef and grows as agents launch, yet its
only self-driven re-render is a per-second elapsed-time tick that never changes
the roster. So controlsHeight went stale on agent launch, availableTerminalHeight
was left too large, and the pending region overflowed the terminal.

Add getLiveAgentPanelLayoutKey (mirrors the existing stickyTodosLayoutKey) and
feed it into the controlsHeight measurement effect's deps, so the footer is
re-measured exactly on height-affecting roster changes (agent add/remove/status,
panel focus) and stays stable across the elapsed-time tick. All growth events
already flow through BackgroundTaskViewState.entries, which AppContainer
consumes, so this adds no extra render churn. Time-based eviction only shrinks
the panel (safe over-reservation) and is intentionally not tracked.

Tests: unit-test the key (changes on growth/shrink/status/focus, stable across
ticks) and a runtime reproduction using real ink render + measureElement showing
the reserved room goes stale without the dep and re-measures with it.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (Re-triage — PR body has been updated since the initial review.)

Template looks good ✓ — all required sections present: Reviewer Test Plan (with How to verify, Evidence, Tested on, Environment), Risk & Scope, Linked Issues, bilingual details.

On direction: clear, real bug fix. Scroll snap-back and flicker in non-VP mode during multi-agent runs is a genuine user-facing TUI regression. Directly aligned with the CLI's core interactive experience and the roadmap/multi-agent area.

On approach: minimal and pattern-following. One production line (adding a deps-array entry) mirroring the existing stickyTodosLayoutKey. The helper function getLiveAgentPanelLayoutKey captures exactly the height-affecting state (agent count + status + focus row) and stays stable across the per-second tick. Scope is tight — no drive-by refactors, no speculative additions.

Moving on to code review. 🔍

中文说明

感谢贡献!(重新审查——PR 正文已根据初次审查反馈更新。)

模板完整 ✓ — 所有必需章节齐全:Reviewer Test Plan(含 How to verify、Evidence、Tested on、Environment)、Risk & Scope、Linked Issues、中英双语。

方向:明确的真实 bug 修复。非 VP 模式下多 agent 运行时的滚动回弹和闪烁是用户可感知的 TUI 回归,与 CLI 核心交互体验和 roadmap/multi-agent 方向直接对齐。

方案:最小化且遵循既有模式。生产代码仅一行(在依赖数组中添加一个条目),复用了 stickyTodosLayoutKey 的模式。getLiveAgentPanelLayoutKey 精确捕获影响高度的状态(agent 数量 + 状态 + 焦点行),在每秒计时 tick 中保持稳定。范围紧凑——无顺手重构,无投机性添加。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@chiga0

chiga0 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Verification report

Automated tests

New tests (run from repo root):

$ npx vitest run \
    packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.test.ts \
    packages/cli/src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx

 ✓ src/ui/components/background-view/liveAgentPanelVisibility.test.ts (7 tests)
 ✓ src/ui/components/background-view/liveAgentPanelLayout.measurement.test.tsx (2 tests)
   ✓ BUG: without the roster in the measurement deps, reserved room goes stale on growth
   ✓ FIX: wiring the roster key into the deps re-measures, shrinking reserved room
 Test Files  2 passed (2)
      Tests  9 passed (9)

Regression — existing suites that touch the same surfaces:

$ npx vitest run \
    packages/cli/src/ui/AppContainer.test.tsx \
    packages/cli/src/ui/components/background-view/LiveAgentPanel.test.tsx \
    packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx

 Test Files  3 passed (3)
      Tests  127 passed (127)

How the bug is isolated

liveAgentPanelLayout.measurement.test.tsx reproduces the exact measurement contract on a minimal component using real ink render, real measureElement, and the real getLiveAgentPanelLayoutKey. The controls box renders one row per agent, so its measured height genuinely grows with the roster; a flag toggles whether the roster key is part of the measurement effect's deps:

  • Without the roster in the deps (pre-fix behaviour): the roster grows from 0 → 3 agents but availableHeight is unchanged — after === before — i.e. the reserved footer room is stale and the main content would overflow.
  • With the roster in the deps (this PR): the controls are re-measured — after < before, and before - after === 3, exactly the three new agent rows.

liveAgentPanelVisibility.test.ts separately pins the key's contract: it changes on agent add / remove / status-flip / panel-focus, and is stable across the panel's per-second elapsed-time tick (so the footer is not re-measured every second).

How to verify manually (the live symptom)

  1. Ensure non-VP mode (default — ui.useTerminalBuffer unset / false).
  2. Use a terminal short enough that output + footer will overflow (e.g. ~20 rows).
  3. Run a flow that launches several background agents and produces tall pending output (a parallel/multi-agent task) so the LiveAgentPanel roster below the composer is tall.
  4. While agents are running, try to scroll up.
  • Before: the view snaps straight back to the bottom and flickers on every per-second repaint; it only behaves once the run finishes.
  • After: the footer footprint is reserved correctly as agents launch, the dynamic region stays within the viewport, and the snap-back / flicker is gone.

Note on test scope

A full AppContainer integration test that fails-before / passes-after for this specific dependency could not be written reliably: ink-testing-library's rerender remounts AppContainer (re-running mount effects regardless of deps, masking the bug), and an external setState does not drive ink's reconciler. The faithful minimal-component reproduction above exercises the real key function and real measureElement in the same measurement pattern, and the one-line dependency wiring in AppContainer is reviewable directly.

@chiga0 chiga0 changed the title fix(cli): re-measure footer when LiveAgentPanel grows to stop non-VP overflow flicker fix(cli): prevent scroll snap-back and flicker in non-VP mode during multi-agent runs Jun 24, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Final Verdict: LGTM — Clean, minimal, well-documented fix for a precise root cause. The layout key pattern mirrors the existing stickyTodosLayoutKey and correctly captures only height-affecting roster changes while staying stable across per-second elapsed-time ticks.

Key Observations

The root cause analysis is spot-on: the footer measurement useLayoutEffect's dependency array did not reflect the LiveAgentPanel roster, so controlsHeight went stale on agent launch. The fix is a single dependency addition in AppContainer.tsx plus a well-designed utility function.

The design decision to NOT track time-based eviction (the 8s window after agent completion) is correct — eviction only shrinks the panel, so a stale, slightly too-large reservation is the safe direction (over-clip, not overflow).

Additional Audit Coverage

  • Layout key stability: String concatenation of agentId:status pairs is efficient for typical roster sizes (<20 agents). The | delimiter prevents collision between adjacent entries. Focus flag prefix (f/_) ensures focus toggles trigger re-measurement.
  • Test quality: The runtime reproduction test (liveAgentPanelLayout.measurement.test.tsx) is exceptional — it demonstrates both the bug (without the dep) and the fix (with the dep) using real ink render + measureElement. The unit tests cover all edge cases: add/remove/status/focus/tick-stability/non-agent-filtering.
  • isLiveAgentPanelVisibleEntry eviction window test: Guards the assumption that finished agents stay visible for TERMINAL_VISIBLE_MS, validating the safe-direction design decision.
  • No render churn: The layout key is derived from bgTaskEntries and bgLivePanelFocused, both already consumed by AppContainer. No new subscriptions or re-renders introduced.

This review was generated by QoderWork AI

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Code review: No issues found. The production change is a single dependency-array addition in AppContainer.tsxliveAgentPanelLayoutKey — derived via the new getLiveAgentPanelLayoutKey helper colocated with isLiveAgentPanelVisibleEntry in liveAgentPanelVisibility.ts. The helper is a pure function capturing exactly the height-affecting state (agent count, per-agent status, focus hint row). Destructuring entries and livePanelFocused from useBackgroundTaskViewState() is valid — both are confirmed fields on BackgroundTaskViewState. The pattern mirrors stickyTodosLayoutKey exactly. No correctness issues, no over-abstraction, no scope creep.

The source-level regression guard (app-container-controls-dep.test.ts) is a notable addition — it pins the dep-array entry directly in source text, closing the coverage gap that wenshao's mutation M3 identified in the previous triage round (where reverting the production one-liner broke no test). This makes the fix robust against future exhaustive-deps autofixes or drive-by cleanups.

Tests: 42/42 pass.

  • liveAgentPanelVisibility.test.ts — 11/11 (layout key contract + eviction window)
  • liveAgentPanelLayout.measurement.test.tsx — 2/2 (runtime repro of stale measurement → fix proves 3-row shrink)
  • app-container-controls-dep.test.ts — 3/3 (source-level guard)
  • Existing LiveAgentPanel.test.tsx — 26/26 (no regression)

Typecheck: tsc --noEmit -p packages/cli → exit 0, zero errors.

Build: npm run build → exit 0.

Smoke test (tmux)

$ node packages/cli/dist/index.js -p 'say hello in one sentence'
Hello! How can I help you today?
$

Basic CLI operation works under the PR build. The actual bug (multi-agent non-VP scroll flicker) requires interactive background agents + content overflow + scroll-up — cannot be scripted in headless -p mode. The unit/integration tests deterministically isolate the root cause: the measurement.test.tsx faithfully reproduces the stale-controlsHeight scenario with real ink rendering, and the new source guard prevents silent regression. Two independent maintainers have additionally verified via mutation testing and live TUI reproduction.

中文说明

代码审查: 未发现问题。生产代码改动仅一行——在 AppContainer.tsx 的依赖数组中添加 liveAgentPanelLayoutKey,通过新增的 getLiveAgentPanelLayoutKey helper 计算,与 isLiveAgentPanelVisibleEntry 同文件放置。Helper 是纯函数,精确捕获影响高度的状态(agent 数量、每个 agent 状态、焦点提示行)。从 useBackgroundTaskViewState() 解构 entrieslivePanelFocused 合法——两者均为 BackgroundTaskViewState 的已确认字段。模式完全复用 stickyTodosLayoutKey。无正确性问题,无过度抽象,无范围蔓延。

源码级回归守护测试(app-container-controls-dep.test.ts)值得注意——它直接在源码文本中固定了依赖数组条目,封堵了 wenshao 在上轮审查中变异 M3 发现的覆盖缺口(还原生产代码那一行不触发任何测试失败)。使修复对未来 exhaustive-deps 自动修复或顺手清理具有鲁棒性。

测试:42/42 通过。

  • liveAgentPanelVisibility.test.ts — 11/11(layout key 契约 + 驱逐窗口)
  • liveAgentPanelLayout.measurement.test.tsx — 2/2(运行时复现过期测量 → 修复证明缩小 3 行)
  • app-container-controls-dep.test.ts — 3/3(源码级守护)
  • 既有 LiveAgentPanel.test.tsx — 26/26(无回归)

类型检查: tsc --noEmit -p packages/cli → 退出码 0,零错误。

构建: npm run build → 退出码 0。

冒烟测试(tmux): 基本 CLI 功能正常。实际 bug(多 agent 非 VP 滚动闪烁)需要交互式后台 agent + 内容溢出 + 向上滚动——无法在无头 -p 模式下模拟。单元/集成测试确定性隔离了根因,两位独立维护者也已通过变异测试和真实 TUI 复现验证。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Clean bug fix, ready to ship. The root cause is precisely identified (stale controlsHeight when LiveAgentPanel grows without a corresponding dep in the measurement effect), the fix is a single dependency addition mirroring an established pattern, and the test suite — now including a source-level regression guard that closes the M3 coverage gap from the prior triage — gives high confidence the fix works and stays working.

Independent proposal matches: I would have taken the same approach. A roster-derived string key in the deps array is the minimum viable fix, and the PR goes further by including both a runtime integration test that proves the bug and fix end-to-end, and a source-assertion test that prevents silent removal of the dep.

All 42 tests pass. Typecheck clean. Build clean. Smoke test clean. Two independent maintainer verifications (mutation testing + live TUI repro) confirm the same conclusion.

Approving. ✅

中文说明

干净的 bug 修复,可以合入。根因已精确定位(LiveAgentPanel 增长时 controlsHeight 过期,测量 effect 缺少对应依赖),修复是在依赖数组中添加一行,复用既有模式。测试套件——现在包含源码级回归守护,封堵了上轮审查中 M3 的覆盖缺口——给出了修复有效且持久的充分信心。

独立方案一致:我会采取同样的方法。在依赖数组中使用 roster 派生的字符串 key 是最小可行修复,PR 更进一步,既包含端到端证明 bug 和修复的运行时集成测试,又包含防止依赖被静默移除的源码断言测试。

42 个测试全部通过。类型检查干净。构建干净。冒烟测试干净。两位独立维护者验证(变异测试 + 真实 TUI 复现)确认相同结论。

批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. Downgraded from Approve to Comment: self-PR; CI still running.

The fix is minimal and well-targeted — adds a roster-derived layout key to the footer measurement effect's deps so the controls are re-measured when the LiveAgentPanel grows, without triggering on the per-second elapsed-time tick. The design choice to not track time-based eviction (safe direction) is sound. Tests faithfully reproduce the bug mechanism and validate the fix.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
wenshao
wenshao previously approved these changes Jun 24, 2026
…duplicated rationale

Address review on QwenLM#5799:
- AppContainer: condense the call-site comment to a cross-reference; the full
  rationale lives in getLiveAgentPanelLayoutKey's JSDoc (was duplicated 3x).
- liveAgentPanelVisibility.test: add the missing branches for
  isLiveAgentPanelVisibleEntry — non-agent → false, running/paused → true,
  terminal agent missing endTime → false (guards NaN from nowMs - undefined).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0
chiga0 dismissed stale reviews from wenshao and qwen-code-ci-bot via 78ba19d June 24, 2026 06:33
@chiga0

chiga0 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review! Addressed everything:

  • Template: reformatted the PR body to match the template — added Reviewer Test Plan (with How to verify, Evidence (Before & After), Tested on, Environment), Risk & Scope, and Linked Issues (Fixes #5798).
  • Code-review suggestions (78ba19d): condensed the duplicated rationale at the AppContainer call site to a cross-reference (canonical explanation stays in getLiveAgentPanelLayoutKey's JSDoc), and added the missing isLiveAgentPanelVisibleEntry branch tests (non-agent / running / paused / terminal-missing-endTime). Both inline threads resolved.

Test count is now 13 across the two new files (11 + 2); existing suites still green.

Comment thread packages/cli/src/ui/components/background-view/liveAgentPanelVisibility.ts Outdated
…tId in layout key

Address @wenshao's review on QwenLM#5799: agentId is a @deprecated synonym for the
canonical TaskBase.id ("Always equals id"). Switch getLiveAgentPanelLayoutKey
to read entry.id, and update both new test factories to populate id.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
wenshao
wenshao previously approved these changes Jun 24, 2026
@chiga0
chiga0 requested a review from wenshao June 24, 2026 09:05
Comment thread packages/cli/src/ui/AppContainer.tsx
@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report — PR #5799

Verdict: LGTM — recommend merge. I built the real binary, ran the full test suite, mutation-tested the new tests, and reproduced the issue's exact multi-agent scenario in a live TUI. The fix is correct, minimal, and well-targeted, and the new tests are non-vacuous (mutation-verified).

Verified at PR head c91c1bc18 (mergeable-clean against origin/main) in an isolated git worktree with a clean npm ci + bundle build.

1. Build & boot (real binary)

  • npm ci + npm run bundledist/cli.js (6.27 MB); --version0.19.1. ✓
  • Boots cleanly in a real TUI (isolated QWEN_HOME, OpenAI auth). ✓

2. Tests / typecheck / lint

  • New tests 9 passed: liveAgentPanelVisibility.test.ts (7) + liveAgentPanelLayout.measurement.test.tsx (2). ✓
  • tsc --noEmit -p packages/cli → exit 0. ✓
  • eslint on all 4 changed files → exit 0. ✓
  • Full AppContainer.test.tsx → 93/94 locally. The 1 miss is a timing-sensitive test that intermittently times out under full-parallel load on my machine and passes reliably in isolation (the test even documents its own timing sensitivity); it is pre-existing and unrelated to this PR. See the M3 note below.

3. Mutation testing — are the new tests real?

I mutated the source and re-ran the tests to prove they actually catch the regression:

Mutation on the source Expectation Result
getLiveAgentPanelLayoutKey → return a constant visibility tests and the measurement FIX case break 5 failed (4 visibility + measurement FIX) ✓
drop :${status} from the key only the status-flip test breaks exactly 1 failed (status-flip) ✓
revert the production deps-array line a roster/height test should break 0 roster/height tests failed ⚠️ (see note)
  • M1 proves both test files are bound to the real helper, and that the measurement test's FIX assertion genuinely depends on the key changing (it is not a tautology).
  • M2 proves coverage is precise — status sensitivity is specifically asserted.
  • M3 / coverage note: reverting the actual production one-liner (adding liveAgentPanelLayoutKey to the controlsHeight useLayoutEffect deps) breaks no roster/height test. Both the reverted-fix tree and the clean tree produce the same 93/94 — and the single miss is a different timing-flaky test on each run (terminal-title vs. sticky-todo-remeasure), each passing in isolation — so M3 carries no PR-specific signal. That means the production change is covered by analogy — the standalone liveAgentPanelLayout.measurement.test.tsx faithfully mirrors the real useLayoutEffect + measureElement + getLiveAgentPanelLayoutKey contract — plus the sibling stickyTodosLayoutKey test, but there is no direct AppContainer-level regression test asserting "the footer re-measures when the agent roster grows." Not a blocker, but a small follow-up test (mirroring the existing "does not remeasure footer height for sticky todo status-only updates" test, asserting the opposite for a growing roster) would close the loop.

4. Real-binary TUI reproduction of the issue scenario

I built two bundles — the PR (fixed) and a reverted-fix (buggy), confirmed byte-different — and drove the real binary in tmux against a local fake OpenAI endpoint that (a) forces the main model to emit 3 concurrent background agent tool-calls mid-turn and (b) streams a terminal-filling response. This is precisely the issue's "flow that launches several background agents … while output fills the terminal."

Key lines from the run (agents launch first, then the response streams to fill the terminal):

  > Please SPAWNAGENTS now and describe each in detail at great length
   Parallel agents · 3 · 0/3 done
   ○ bg agent 1
   ○ bg agent 2
   ○ bg agent 3
  Line 031: streaming pending content row number 31 of the response.
  … (pending region fills the terminal) …
  ──────────────────────────────────────────────────────────────────
  *   Type your message or @path/to/file
  ──────────────────────────────────────────────────────────────────
  YOLO mode (shift + tab to cycle) · 3 local agents
    main
    ○ bg agent 1 ▶ 11s
    ○ bg agent 2 ▶ 11s
    ○ bg agent 3 ▶ 11s
  • ✓ Agents register; the LiveAgentPanel grows during the turn with no keystroke (the precise trigger); content streams; the footer shows · 3 local agents. Both builds run the workload without crashing.
  • I did not capture a deterministic frame-level flicker divergence between the two builds, and I root-caused why: the controlsHeight effect also depends on buffer (composer text) and btwItem (the pending item), so any typing or streaming continuously re-measures and self-heals the staleness. The residual hole this PR closes is a roster change that does not coincide with those deps — an intermittent race under sustained multi-agent load (subagents launching / flipping status between content tokens, with the per-second elapsed-time tick as the only repaint). That race is exactly the window the PR's real-ink measurement test isolates deterministically — and mutation M1 confirms that test fails the instant the roster key is removed. In other words: the unit test is the authoritative behavioral proof of the fix, and the real binary confirms the triggering workload runs as described.

5. Correctness notes

  • The fix mirrors the established stickyTodosLayoutKey pattern; the key is derived from state AppContainer already consumes → no new data flow, no extra subscription.
  • The key is stable across the 1 s elapsed-time tick (verified by the "stable across ticks" / "ignores non-agent" tests) → the PR's "no extra render churn" claim holds.
  • Time-based eviction (panel shrink) is intentionally not tracked — correctly reasoned as the safe direction (a slightly-too-large reservation over-clips the pending region; it never overflows).
  • It is a strict addition to the dependency array, so it cannot regress the existing measurement behavior.
🇨🇳 中文版(完整对应)

✅ 本地验证报告 — PR #5799

结论:LGTM,建议合并。 我构建了真实二进制、跑了完整测试套件、对新增测试做了变异测试,并在真实 TUI 中复现了该 issue 的多 agent 场景。修复正确、最小化、定位精准,新增测试经变异验证确实有效(非空过)。

验证基于 PR head c91c1bc18(对 origin/main 为 mergeable-clean),在隔离的 git worktree 中以干净的 npm ci + bundle 构建完成。

1. 构建与启动(真实二进制)

  • npm ci + npm run bundledist/cli.js(6.27 MB);--version0.19.1。✓
  • 在真实 TUI 中干净启动(隔离 QWEN_HOME,OpenAI 认证)。✓

2. 测试 / 类型检查 / Lint

  • 新增测试 9 个通过liveAgentPanelVisibility.test.ts(7)+ liveAgentPanelLayout.measurement.test.tsx(2)。✓
  • tsc --noEmit -p packages/cli → 退出码 0。✓
  • 对全部 4 个改动文件跑 eslint → 退出码 0。✓
  • 完整 AppContainer.test.tsx → 本地 93/94。唯一未过的是一个对时序敏感的测试,在我机器满并发负载下会间歇性超时,单独跑能稳定通过(该测试自己也注释说明了时序敏感性);属于既有问题、与本 PR 无关。详见下方 M3 说明。

3. 变异测试 — 新增测试是真的有效吗?

我对源码做了变异并重跑测试,以证明它们确实能捕获该回归:

对源码的变异 预期 结果
getLiveAgentPanelLayoutKey → 返回常量 visibility 测试 measurement 的 FIX 用例都挂 5 个失败(4 个 visibility + measurement FIX)✓
从 key 中去掉 :${status} 只有 status-flip 测试挂 恰好 1 个失败(status-flip)✓
还原生产代码那一行依赖项 应有 roster/height 相关测试挂 0 个 roster/height 测试失败 ⚠️(见说明)
  • M1 证明两个测试文件都真正绑定到实际的 helper,且 measurement 测试的 FIX 断言确实依赖 key 的变化(不是恒真)。
  • M2 证明覆盖是精准的——status 敏感性被专门断言到了。
  • M3 / 覆盖率说明: 还原真正的生产改动那一行(把 liveAgentPanelLayoutKey 加进 controlsHeightuseLayoutEffect 依赖数组)后,没有任何 roster/height 测试失败。还原修复版与干净版都是同样的 93/94——且每次唯一未过的是不同的时序 flaky 测试(终端标题 vs. sticky-todo 重新测量),单独跑都能通过——所以 M3 不携带任何与本 PR 相关的信号。也就是说,生产改动是靠类比覆盖的——独立的 liveAgentPanelLayout.measurement.test.tsx 忠实地复刻了真实的 useLayoutEffect + measureElement + getLiveAgentPanelLayoutKey 契约——再加上同源的 stickyTodosLayoutKey 测试,但没有 AppContainer 级别的直接回归测试断言"agent roster 增长时 footer 会重新测量"。这不是阻塞项,但加一个小的后续测试(仿照已有的 "does not remeasure footer height for sticky todo status-only updates",对增长的 roster 断言相反结论)能形成闭环。

4. 真实二进制 TUI 场景复现

我构建了两个 bundle——PR 版(fixed)与还原修复版(buggy),并确认两者字节不同——在 tmux 中以真实二进制对接一个本地伪造的 OpenAI 端点,该端点 (a) 强制主模型在一轮内发出 3 个并发的后台 agent 工具调用,并 (b) 流式返回一段填满终端的响应。这正是 issue 描述的*"启动多个后台 agent…同时输出填满终端高度"*的场景。

运行中的关键画面(先启动 agent,随后响应流式输出填满终端):

  > Please SPAWNAGENTS now and describe each in detail at great length
   Parallel agents · 3 · 0/3 done
   ○ bg agent 1
   ○ bg agent 2
   ○ bg agent 3
  Line 031: streaming pending content row number 31 of the response.
  … (pending 区域填满终端)…
  ──────────────────────────────────────────────────────────────────
  *   Type your message or @path/to/file
  ──────────────────────────────────────────────────────────────────
  YOLO mode (shift + tab to cycle) · 3 local agents
    main
    ○ bg agent 1 ▶ 11s
    ○ bg agent 2 ▶ 11s
    ○ bg agent 3 ▶ 11s
  • ✓ Agent 成功注册;LiveAgentPanel 在该轮中无需任何按键即增长(这正是触发条件);内容流式输出;footer 显示 · 3 local agents。两个 bundle 都能跑通该工作负载、无崩溃。
  • 没有在两个 bundle 之间捕获到逐帧确定性的闪烁差异,并查清了原因:controlsHeight 的 effect 同时依赖 buffer(输入框文本)和 btwItem(pending 项),所以任何打字或流式输出都会不断触发重新测量、自愈掉这个陈旧状态。本 PR 真正堵上的残余漏洞,是一次不与上述依赖同时发生的 roster 变化——在持续的多 agent 负载下的一种竞态(子 agent 在内容 token 之间启动/状态翻转,而每秒的耗时 tick 是唯一的重绘来源)。这个竞态恰恰就是 PR 中那个真实 ink 渲染的 measurement 测试所确定性隔离出来的窗口——而变异 M1 证明:一旦去掉 roster key,该测试立即失败。换言之:单元测试是该修复的权威行为证据,而真实二进制证明了触发该问题的工作负载确实如描述般运行。

5. 正确性说明

  • 修复沿用了已有的 stickyTodosLayoutKey 模式;key 来源于 AppContainer 本就消费的状态 → 没有新增数据流、没有新增订阅。
  • key 在每秒的耗时 tick 上是稳定的(由"stable across ticks" / "ignores non-agent"用例验证)→ PR 关于*"无额外渲染开销"*的说法成立。
  • 基于时间的驱逐(面板收缩)有意不被追踪——其推理正确:略微偏大的预留会过度裁剪 pending 区域,但永远不会溢出,是安全方向。
  • 这是对依赖数组的纯增量改动,因此不会破坏既有的测量行为。

Verification: isolated worktree @ c91c1bc18 · real bundle build · 9/9 new tests · 3-mutation matrix · live tmux multi-agent TUI repro · fixed-vs-buggy bundle A/B.

@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local-verification report — PR #5799

Verdict: LGTM — recommend merge. The fix is correct, minimal, well-targeted, and backed by tests that genuinely exercise the mechanism (confirmed by mutation testing). Built & verified locally against PR head aac3e92 in an isolated worktree.

Root cause (re-confirmed against the code)

AppContainer reserves footer room via availableTerminalHeight = terminalHeight − controlsHeight − …, where controlsHeight is measured in a useLayoutEffect gated by a dependency array. LiveAgentPanel renders inside the measured mainControlsRef box (DefaultAppLayout.tsx:77 + :138) and grows as agents launch — but the panel's only self-driven re-render is a per-second elapsed-time tick (setNow) that never touches the roster. The pre-fix deps (buffer, terminalWidth, terminalHeight, btwItem, dialogsVisible, stickyTodosLayoutKey) contained nothing that changed on agent launch, so controlsHeight went stale, availableTerminalHeight stayed too large, the pending region overflowed, and in non-VP mode every repaint snapped back to the bottom → flicker.

The fix

A pure getLiveAgentPanelLayoutKey(entries, focused) → a stable signature of everything that changes the panel's height (agent count + per-agent status + the focus hint row), wired as a new dep of the measurement effect. Because React compares string deps by value (Object.is), the per-second tick produces an identical key and does not re-measure, while a genuine roster/focus change does.

Verification performed

Check Result
Architecture ✓ Panel confirmed inside mainControlsRef; entries / livePanelFocused are real state fields; AgentTask.id === agentId (TaskBase: "both populated to the same value"); dialogsVisible already in deps → panel unmount-on-dialog is covered
vitest (3 files) 39/39 pass — 2 runtime-repro + 11 unit + 26 existing LiveAgentPanel regression
Mutation testing ✓ 5 deliberate breakages, each killed by exactly the right test(s) — table below
Typecheck (PR files) 0 errors in the 4 changed files (AppContainer.tsx + 3 background-view files)
Prettier ✓ clean on all 4 files

Mutation testing — proves the tests aren't tautological:

Mutation applied to getLiveAgentPanelLayoutKey Tests that failed (everything else still passed)
return 'CONST' (ignore roster + focus) 4 unit (add / remove / status / focus) + the measurement FIX test
drop :${status} from the key only changes when an agent status flips
drop the focus flag only changes when panel focus toggles

The constant-key kill is the important one: it shows liveAgentPanelLayout.measurement.test.tsx genuinely depends on the key changing. That test reproduces the exact controlsHeight / availableHeight contract with real ink render + measureElement, asserting the reserved room stays stale without the dep (after === before) and shrinks by exactly the new rows with it (before − after === 3).

Design notes (all correct / non-blocking)

  • 8s time-based eviction is intentionally excluded from the key — verified correct. An evicted terminal entry is dropped from render but keeps its id:status in the snapshot, so the key is unchanged. Eviction only shrinks the panel ⇒ a stale, slightly-too-large reservation over-clips the pending region, which is the safe direction (never overflows). Matches the documented rationale. 👍
  • No direct integration test on AppContainer's deps array. The one-line production wiring is validated by the faithful measurement-contract mirror rather than a full AppContainer render — reasonable for TUI code, but a future regression that removed the dep would not be caught by a test. A one-line guard comment near the deps array would help future readers.

Environment notes (not PR issues)

  • Branch is slightly behind main (still MERGEABLE); an optional rebase is nice-to-have.
  • The tree currently has unrelated pre-existing TS errors in packages/core/src/providers/__tests__/* (customHeaders / mergeModelsByIdentity) — present identically on main, untouched by this PR.
  • eslint was not runnable locally (this env lacks eslint-plugin-check-file); files carry license headers, follow the sibling file's conventions, and are prettier-clean — CI's lint gate covers it.

I deliberately did not run a manual TUI flicker A/B: this is a layout-measurement bug, and the shipped measurement.test.tsx is a deterministic, faithful reproduction of the exact root cause — stronger evidence than a non-deterministic terminal repro.

🇨🇳 中文版(完整对应)

✅ 维护者本地验证报告 — PR #5799

结论:LGTM,建议合并。 修复正确、最小化、目标精准,且配套测试真正覆盖了该机制(已通过变异测试验证)。在隔离 worktree 中基于 PR head aac3e92 本地构建并验证。

根因(已对照代码再次确认)

AppContainer 通过 availableTerminalHeight = terminalHeight − controlsHeight − … 为页脚预留高度,其中 controlsHeight 在一个由依赖数组控制的 useLayoutEffect 中测量。LiveAgentPanel 渲染在被测量的 mainControlsRef 盒子内部DefaultAppLayout.tsx:77:138),并随 agent 启动而变高——但面板唯一的自驱动重渲染是每秒一次的耗时刷新(setNow),它从不改动 roster。修复前的依赖(buffer, terminalWidth, terminalHeight, btwItem, dialogsVisible, stickyTodosLayoutKey)中没有任何一项会在 agent 启动时变化,于是 controlsHeight 变陈旧、availableTerminalHeight 偏大、pending 区域溢出屏幕;在非 VP 模式下每次重绘都把视图弹回底部 → 闪烁。

修复方案

新增纯函数 getLiveAgentPanelLayoutKey(entries, focused)——对所有会改变面板高度的因素生成稳定签名(agent 数量 + 每个 agent 的 status + 聚焦时的提示行),并把它作为测量 effect 的新依赖。由于 React 按值比较字符串依赖(Object.is),每秒心跳产生相同的 key,因此不会触发重新测量;而真正的 roster/焦点变化才会触发。

已执行的验证

检查项 结果
架构 ✓ 面板确认在 mainControlsRef 内部;entries / livePanelFocused 是真实的 state 字段;AgentTask.id === agentId(TaskBase 注释:"两字段在注册时填同一值");dialogsVisible 已在依赖中 → 弹窗时面板卸载已覆盖
vitest(3 个文件) 39/39 通过 — 2 运行时复现 + 11 单元 + 26 既有 LiveAgentPanel 回归
变异测试 ✓ 5 处故意破坏,每处都被对应测试精准杀死 — 见下表
类型检查(PR 文件) ✓ 4 个改动文件(AppContainer.tsx + 3 个 background-view 文件)0 错误
Prettier ✓ 4 个文件全部干净

变异测试 —— 证明测试不是空转:

getLiveAgentPanelLayoutKey 施加的变异 失败的测试(其余仍通过)
return 'CONST'(忽略 roster + 焦点) 4 个单元(增/删/状态/焦点)+ measurement 的 FIX 用例
从 key 去掉 :${status} changes when an agent status flips
去掉焦点标志 changes when panel focus toggles

常量 key 那次最关键:它证明 liveAgentPanelLayout.measurement.test.tsx 确实依赖 key 的变化。该测试用真实 ink render + measureElement 复现了 controlsHeight / availableHeight 的契约——没有该依赖时预留高度保持陈旧(after === before),加上后正好按新增行数收缩(before − after === 3)。

设计说明(均正确 / 不阻塞)

  • 8 秒超时驱逐被刻意排除在 key 之外——已验证正确。 被驱逐的终态条目从渲染中移除,但其 id:status 仍留在快照中,因此 key 不变。驱逐只会让面板收缩 ⇒ 偏大的陈旧预留会过度裁剪 pending 区域,这是安全方向(绝不会溢出)。与文档中的说明一致。👍
  • 没有针对 AppContainer 依赖数组的直接集成测试。 那一行生产接线由"忠实镜像测量契约"的测试间接验证,而非完整的 AppContainer 渲染测试——对 TUI 代码而言合理,但将来若有人删掉该依赖,测试不会报警。建议在依赖数组旁加一行守护注释提示后人。

环境说明(非本 PR 问题)

  • 分支略落后于 main(仍 MERGEABLE);可选 rebase。
  • 当前代码树在 packages/core/src/providers/__tests__/* 有与本 PR 无关的既有 TS 错误(customHeaders / mergeModelsByIdentity)——在 main 上同样存在,本 PR 未触碰。
  • 本地无法运行 eslint(该环境缺 eslint-plugin-check-file);文件带 license 头、遵循同目录文件的约定、且 prettier 干净——由 CI 的 lint gate 兜底。

我特意没有做人工 TUI 闪烁 A/B 对比:这是一个布局测量类 bug,PR 自带的 measurement.test.tsx 已是对根因的确定性、忠实复现——比非确定性的终端复现更有说服力。

Verified locally by the maintainer (@wenshao) in an isolated worktree against PR head aac3e92: vitest 39/39, 5-way mutation kill, PR-file typecheck clean, prettier clean.

Address @wenshao's review on QwenLM#5799: the one-line fix (liveAgentPanelLayoutKey
in the controlsHeight measurement-effect deps) had no regression guard — the
behavioural tests exercise the mechanism on a stand-in component, and removing
the dep left all of them green.

A render-based guard against the real AppContainer is not feasible:
ink-testing-library's rerender remounts AppContainer (re-running mount effects
regardless of deps) and an external setState does not flush ink's reconciler,
so the missing-dependency update path is unobservable. Add a source-level
assertion instead — it fails if liveAgentPanelLayoutKey is dropped from the
measurement effect's dependency array (verified: removing the dep turns the
guard red), so a deps cleanup or exhaustive-deps autofix can't silently revert
the fix.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean, focused bug fix — adds a roster-derived layout key to the footer measurement effect's deps so controlsHeight re-measures when the LiveAgentPanel grows. All 16 new tests pass. Deterministic analysis (tsc + eslint) clean on changed files. 9-agent review + reverse audit found no high-confidence issues.

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI failing: review-pr.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao enabled auto-merge June 24, 2026 19:21
@wenshao
wenshao added this pull request to the merge queue Jun 24, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean, minimal fix — adds a roster-derived layout key to the controls-height measurement effect's dependency array so controlsHeight re-measures when the LiveAgentPanel grows. The per-second elapsed tick correctly stays stable (no re-measurement churn). All 16 new tests pass, deterministic analysis clean (tsc + eslint, 0 findings).

— qwen3.7-max via Qwen Code /review

Merged via the queue into QwenLM:main with commit b2f11b7 Jun 25, 2026
12 checks passed
@chiga0
chiga0 deleted the fix/non-vp-controls-height-roster branch June 25, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scroll-back broken and screen flickers during multi-agent runs in non-VP mode

4 participants