fix(jetbrains): bound transcript reflow passes during streaming

The reflow chain restarted its pass budget on every height change, so a
session that keeps streaming after open reset the budget each EDT cycle and
held the panel in a perpetual forgetAll()/re-measure loop, defeating the
width-keyed height cache. Add a hard total-pass budget that never resets so
the layout can still settle across a few height changes while capping the
work a streaming session can trigger. Covers the doLayout re-arm path with a
test that latches pendingReflow via a real turn at zero width.
This commit is contained in:
kirillk
2026-07-31 16:43:34 -04:00
parent e0fa585e33
commit 820d5d5f01
2 changed files with 41 additions and 4 deletions
@@ -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
}
}
@@ -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)