fix(jetbrains): preserve session scroll follow intent

Use the sticky tail-follow intent instead of momentary viewport position when deciding whether batched session updates and newly sent prompts should continue auto-scrolling. This prevents turn-close layout changes, such as modified-files cards plus progress footer removal, from cancelling an in-flight follow-to-bottom pass.
This commit is contained in:
kirillk
2026-08-10 15:27:26 -04:00
parent 9f32194345
commit 025bbdfe93
2 changed files with 57 additions and 2 deletions
@@ -156,7 +156,7 @@ class SessionUi(
condense = Registry.`is`("kilo.session.condense", true),
displayMs = displayMs,
open = { item -> manager?.openSession(item) },
beforeUpdate = { if (opening) false else scroll.atBottom() },
beforeUpdate = { if (opening) false else scroll.following() },
afterUpdate = { if (!opening) scroll.followBottom(it) },
loaded = ::onSessionLoaded,
openProfileAction = ::openProfileSettings,
@@ -684,7 +684,7 @@ class SessionUi(
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
if (text.isBlank() && files.isEmpty()) return
prompt.clear()
val follow = scroll.atBottom()
val follow = scroll.following()
val action = completion.clientAction(text)
if (action != null) {
action.action()
@@ -498,6 +498,61 @@ class SessionScrollTest : SessionUiTestBase() {
assertFalse(jumpButton().isVisible)
}
fun `test turn close after modified files keeps pending tail follow`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
emit(ChatEventDto.TurnOpen("ses_test"))
drainScroll()
assertBottom(bar)
assertTrue(ui.scroll.following())
val id = "modified_close_tail"
val pid = "modified_close_part"
emit(ChatEventDto.MessageUpdated("ses_test", message(id).copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "tail line\n".repeat(160))), flush = false)
forceFlushWithoutDispatch()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
drainScroll()
assertBottom(bar)
assertTrue(ui.scroll.following())
assertFalse(jumpButton().isVisible)
findAll<EditorTextField>(ui).first().text = "next prompt"
find<PromptPanel>(ui).send()
settleShort(100)
val text = rpc.prompts.last().third.parts.single().text
val next = "modified_close_next"
emit(ChatEventDto.MessageUpdated("ses_test", message(next)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("modified_close_next_part", next, "text", text)), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test turn close after modified files preserves user scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
val value = bar.value
emit(ChatEventDto.TurnOpen("ses_test"), flush = false)
emit(ChatEventDto.MessageUpdated("ses_test", message("modified_close_middle").copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false)
emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false)
forceFlush()
drainScroll()
assertEquals(value, bar.value)
assertFalse(ui.scroll.following())
assertTrue(jumpButton().isVisible)
}
fun `test prompt editor growth preserves middle scroll position`() {
showMessages()
fillTranscript(24)