fix(jetbrains): gate reflow settle window on Busy, not isBusy()

isBusy() is true for awaiting-permission/question, retry, and offline —
states recoverPending() can seed right after history load, where no deltas
arrive and a moving height genuinely means the panes are still settling.
Gating the settle-window shortcut on those states cut the reflow chain to
REFLOW_PASSES and reintroduced the crop-until-resize bug on exactly that
path. Gate only on SessionState.Busy (the streaming state) so the runaway
fix stays while blocked/retry/offline sessions keep their full settle window.
Add a Busy-state test that fails if the term is dropped or inverted.
This commit is contained in:
kirillk
2026-07-31 18:19:22 -04:00
parent 6820530e97
commit f1947697ea
2 changed files with 28 additions and 6 deletions
@@ -553,12 +553,14 @@ class SessionMessageListPanel(
return
}
val height = preferredSize.height
// A moving height means the layout is still settling only while the session is idle. During
// streaming it just tracks incoming content, so restarting the settle window on every delta
// was the runaway that pinned the panel in a perpetual forgetAll()/re-measure loop. Count the
// pass down while busy so streaming settles in REFLOW_PASSES and hands off to the per-turn
// forgetTurn path; [budget] still caps the idle case if a pane never converges.
val left = if (height == stable || model.state.isBusy()) remaining - 1 else REFLOW_PASSES
// A moving height only means the layout is still settling when nothing is streaming in. While
// [SessionState.Busy] deltas land every EDT cycle, so restarting the settle window on each one
// was the runaway that pinned the panel in a perpetual forgetAll()/re-measure loop — count the
// pass down instead so streaming settles in REFLOW_PASSES and hands off to the per-turn
// forgetTurn path. Every other state (idle, awaiting-permission/question, retry, offline —
// which recoverPending() can seed right after load) has no deltas arriving, so a moving height
// is genuine convergence and must keep restarting; [budget] caps that if a pane never settles.
val left = if (height == stable || model.state is SessionState.Busy) remaining - 1 else REFLOW_PASSES
stable = height
ApplicationManager.getApplication().invokeLater {
reflowPass(id, left, budget - 1)
@@ -221,6 +221,26 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue("reflow passes must be bounded by the budget, was $reflows", reflows in 1..30)
}
fun `test streaming session settles reflow within the pass window`() {
var reflows = 0
panel.onReflow = { reflows++ }
// Same ever-growing child, but a streaming (Busy) session: a moving height is incoming
// content, not the layout still settling, so the chain must count its passes down and stop
// after REFLOW_PASSES instead of restarting the settle window and draining the full budget
// the idle case relies on. recoverPending()'s non-Busy states (awaiting-permission, retry,
// offline) intentionally keep the idle settle behavior and are not gated here.
model.loadHistory(listOf(MessageWithPartsDto(msg("u1", "user"), emptyList())))
model.setState(SessionState.Busy("thinking"))
panel.add(EverGrowing(), 0)
panel.setSize(600, 400)
UIUtil.dispatchAllInvocationEvents()
// A handful of passes (~REFLOW_PASSES), well short of the idle budget (~25). Dropping or
// inverting the Busy term restarts the window every pass and blows past this bound.
assertTrue("streaming reflow must settle in the pass window, was $reflows", reflows in 1..10)
}
fun `test apply style drops cached panel measurements`() {
val child = Growing(20)
panel.add(child, 0)