From f1947697eafac9534671bbbd1e00f994245305df Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 31 Jul 2026 18:19:22 -0400 Subject: [PATCH] fix(jetbrains): gate reflow settle window on Busy, not isBusy() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session/ui/SessionMessageListPanel.kt | 14 +++++++------ .../session/ui/SessionMessageListPanelTest.kt | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 9cba95c0d4..5f60ef388b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index d190544480..124357439c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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)