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 54ce89e0bc..91aadbb123 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 @@ -529,12 +529,12 @@ class SessionMessageListPanel( stable = -1 val id = ++seq ApplicationManager.getApplication().invokeLater { - reflowPass(id, REFLOW_PASSES) + reflowPass(id, REFLOW_PASSES, REFLOW_BUDGET) } } @RequiresEdt - private fun reflowPass(id: Int, remaining: Int) { + private fun reflowPass(id: Int, remaining: Int, budget: Int) { if (dead || id != seq) return if (turnViews.isEmpty()) return if (width <= 0) { @@ -545,7 +545,12 @@ class SessionMessageListPanel( } val changed = reflow() if (changed) onReflow?.invoke(true) - if (remaining <= 0) { + // [remaining] restarts whenever the height is still moving, so the chain keeps re-measuring + // until the layout settles for REFLOW_PASSES consecutive passes. [budget] never resets: a + // session that keeps streaming changes its height every EDT cycle, which would otherwise + // reset [remaining] forever and hold the panel in a perpetual forgetAll()/re-measure loop. + // The hard budget caps total passes so streaming can't defeat the width-keyed height cache. + if (remaining <= 0 || budget <= 0) { stable = -1 return } @@ -553,7 +558,7 @@ class SessionMessageListPanel( val left = if (height == stable) remaining - 1 else REFLOW_PASSES stable = height ApplicationManager.getApplication().invokeLater { - reflowPass(id, left) + reflowPass(id, left, budget - 1) } } @@ -633,5 +638,10 @@ class SessionMessageListPanel( private companion object { const val REFLOW_PASSES = 6 + + // Hard ceiling on total reflow passes per schedule, independent of height stability. Lets the + // layout settle across several height changes (HTML panes reflow asynchronously) while capping + // the work a streaming session can trigger, since its height never stabilizes. + const val REFLOW_BUDGET = REFLOW_PASSES * 4 } } 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 89a6501a57..c239918ece 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 @@ -175,6 +175,33 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(80, child.height) } + fun `test deferred reflow re-arms on first real width layout`() { + val child = Growing(20) + panel.add(child, 0) + // A real turn makes turnViews non-empty, so a zero-width reflow latches pendingReflow instead + // of no-opping the way the turnless zero-width test above does. + model.upsertMessage(msg("u1", "user")) + panel.setSize(0, 400) + layout(panel) + assertFalse(panel.reflow()) + + // The first layout at a real width consumes the parked reflow and schedules a pass. + panel.setSize(600, 400) + panel.doLayout() + + // Simulate an HTML pane that only reports its settled height after the first layout: the child + // stays valid at the same width, so a plain layout keeps the cached height and only the + // re-armed forgetAll() reflow re-measures it. + child.markValid() + child.size = 80 + + // Draining the EDT runs the scheduled reflow. Without the doLayout re-arm nothing is queued + // and the child would stay at its stale cached height. + UIUtil.dispatchAllInvocationEvents() + + assertEquals(80, child.height) + } + fun `test apply style drops cached panel measurements`() { val child = Growing(20) panel.add(child, 0)