Kilo JetBrains 插件会话滚动修复:Question 与权限视图首次出现时不再抢占用户滚动位置
导读
本文基于仓库内实现计划 .kilo/plans/1779989384508-mighty-cabin.md,深入剖析 Kilo 的 JetBrains 插件(packages/kilo-jetbrains)中一个典型的 UI 交互缺陷:当问答类(question-like)活动视图——例如权限审批提示(permission prompt)与问题卡片(question view)——首次出现在会话 transcript 中时,如果用户已经离开 transcript 底部,会话不应强制自动滚动。文章将结合 SessionScroll、SessionUi、QuestionView 等前端源码与 SessionScrollTest 测试用例,还原问题根因(following() 采样了陈旧的内部 follow 状态),给出两种候选修复方案与完整的回归测试计划,并说明如何在 packages/kilo-jetbrains 下用 Gradle 验证修复。
读完本文,你将掌握 Kilo JetBrains 前端会话滚动状态机的设计(tail 标记、jump 按钮、多轮 follow pass)、following() 与 atBottom() 两个 API 的语义差异,以及如何为"内容插入但不打断用户阅读位置"这类场景编写可复现的滚动回归测试。
背景:JetBrains 会话 UI 的滚动跟随机制
Kilo 的 JetBrains 插件前端以 Kotlin + Swing(IntelliJ Platform SDK)实现。会话主界面由 SessionUi.kt 的 buildUi() 组装,而 transcript 的滚动行为全部收敛在 SessionScroll.kt 这一个类中。
SessionScroll 维护了若干内部状态,其中最关键的是 tail 布尔标记,表示"当前是否贴住 transcript 底部":
- 当
tail == true时,新的流式内容(streaming part)会持续把视图钉在底部; - 当用户向上滚动(鼠标滚轮、拖动滚动条、PageUp/PageDown 等键盘操作)离开底部后,
tail被置为false,此时屏幕上会出现一个"跳到底部"(jump)按钮,供用户随时回到最新内容。
围绕 tail,SessionScroll 暴露了三个核心 API:
// SessionScroll.kt
@RequiresEdt
fun atBottom(): Boolean {
return tail
}
@RequiresEdt
fun followBottom(follow: Boolean) {
if (!follow) {
seq++
updateJump()
return
}
user = false
pause = false
tail = true
stable = -1
auto = true
show(messages)
auto = false
val id = ++seq
if (SwingUtilities.isEventDispatchThread()) {
followPass(id, FOLLOW_PASSES)
return
}
ApplicationManager.getApplication().invokeLater {
followPass(id, FOLLOW_PASSES)
}
}
@RequiresEdt
fun following(): Boolean {
return component.viewport.view === messages && tail
}
注意 following() 在 atBottom() 的基础上额外要求 component.viewport.view === messages,即"当前视口显示的就是消息列表"(而不是加载页、空白页等其他视图)。FOLLOW_PASSES = 6 表示贴底跟随会分最多 6 轮重排,以应对内容在布局过程中不断增长的情况。
问题描述:Question 类视图首次出现时不该"抢滚动"
本计划要修复的行为可以一句话概括:
当权限提示、问题卡片这类"交互式活动视图"第一次出现在 JetBrains 会话 UI 中时,如果用户此时已经离开 transcript 底部、且跳到底部按钮可见,就不应把 transcript 自动滚回底部;反之,如果用户本来就在底部,新出现的 Question/权限卡片仍应保持视图贴底;而用户显式点击 Question 卡片的上一页/下一页/Review 导航时,仍应允许跳转到活动卡片。
这一需求涉及三类视图,它们都会以卡片形式嵌入 transcript 消息流:
QuestionView——工具向用户提出选择题、自定义文本输入(QuestionView.kt);PermissionView——工具请求文件读写、命令执行等权限(PermissionView.kt);LoginRequiredView——付费模型鉴权失败时提示登录。
它们都属于"主动弹出的活动视图",插入位置在 transcript 中靠近底部的位置。如果插入时不加区分地执行 scroll(true),就会把正在阅读历史内容的用户强行拽回底部。
根因分析:following() 采样的是陈旧内部状态
计划文档明确指出,问题出在 QuestionView.show() 首次显示时对"是否贴底"的采样方式上。
在 SessionUi.kt 的 buildUi() 中,QuestionView 被这样接线:
val questionView = if (readonly) null else QuestionView(
project = project,
reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) },
reject = { id -> controller.rejectQuestion(id) },
follow = { scroll.following() },
scroll = { scroll.followBottom(it) },
selection = selection,
focus = focus,
)
而 QuestionView.show(q) 的时序是"先采样、再渲染、后滚动":
// QuestionView.kt
@RequiresEdt
fun show(q: Question) {
if (q.items.isEmpty()) {
hideView()
return
}
request = q.id
question = q
idx = 0
val tail = follow() // 渲染卡片之前采样当前是否贴底
...
isVisible = true
applyStyle(SessionEditorStyle.current())
syncPage()
scroll(tail) // 渲染完成之后按采样结果决定是否贴底
}
问题的关键在于 follow() 此刻调用的是 scroll.following(),而 following() 依赖的是 SessionScroll 内部的 tail 标记。tail 是程序内部的跟随状态,它与滚动条(JScrollBar)当前的真实位置并不总是同步——例如在批量事件冲刷、多轮 follow pass 尚未收敛、或"视口被程序化移动但内部标记尚未更新"等场景下,tail 可能仍为 true,但用户肉眼看到的滚动条其实已经离开底部(jump 按钮已经出现)。
也就是说,following() 使用了"陈旧的内在跟随状态",而不是"实时滚动条位置"。计划文档给出的判据是:用户可见的条件应当由 SessionScroll.atBottom()(即内部 tail 的当前值)来代表,而它也正是 SessionController.beforeUpdate 在普通 transcript 更新时使用的判据:
// SessionUi.kt(beforeUpdate 接线处)
beforeUpdate = { if (opening) false else scroll.following() },
// SessionController.kt(beforeUpdate 的消费处)
val follow = beforeUpdate()
普通消息更新与 Question 首次出现两条路径对"是否贴底"的判断标准不一致,正是本缺陷的土壤:前者用的判据在长期迭代中已被证明符合用户预期,后者却可能读到陈旧状态,导致首次出现时错误地执行自动贴底滚动。
修复方案:让首次出现的跟随采样改用实时底部状态
计划文档给出了两条候选路径,并明确倾向改动更小、语义更清晰的那一条:
方案 A(推荐):改 SessionUi 调用点
在 SessionUi.kt 的 buildUi() 中,把 QuestionView 的 follow lambda 从 scroll.following() 改为 scroll.atBottom():
follow = { scroll.atBottom() },
这样 QuestionView.show() 首次出现时采样的就是"用户视角下当前是否在底部"这一实时状态:在底部则渲染后贴底(scroll(true)),不在底部则保持滚动位置不变并让 jump 按钮继续可见。
方案 B:改 SessionScroll.following() 语义
如果团队更希望保留单一语义 API,也可以把 following() 改为同时校验实时底部状态:
fun following(): Boolean {
return component.viewport.view === messages && atBottom()
}
这样 SessionUi 的调用点无需改动,following() 在语义上等价于"视口正在显示消息列表且实时位于底部"。
计划文档的取舍建议是:优先采用方案 A 这种更小、更清晰的调用点改动,除非测试证明 following() 的其他行为(例如 followTail()、beforeUpdate 等路径)也需要一并调整,再考虑方案 B。
修复必须保持的边界
QuestionView.goForward()、goBack()、goReview()会显式调用scroll(true),这是用户主动导航到活动卡片的意图,必须保留(见 QuestionView.kt 中三个导航方法的实现);- prompt 编辑器高度增长、普通流式内容增长的跟随行为不变,除非有失败测试证明同样的陈旧状态问题也出现在那些路径上;
- 后端与 RPC 协议零改动。
回归测试计划:补齐权限视图与陈旧状态场景
现有 SessionScrollTest.kt 已经覆盖了大量滚动场景,其中与本文直接相关的是:
test question appearing at bottom keeps scroll at bottom——问题卡片出现在底部时保持贴底;test question appearing while user is in middle preserves scroll position——用户停留在中间时保持滚动位置;test login required appearing while user is in middle preserves scroll position——登录提示出现在中间时保持滚动位置;test large question after reasoning stays at bottom——推理文本后的大问题卡片贴底;- 以及一组"显式导航必须跳转"的用例:
test question carousel navigation follows even when transcript is in middle、test question review navigation follows immediately from middle等。
计划文档指出两个覆盖缺口:
- 权限视图(permission)的首次出现滚动完全没有对应测试;
- "内部
tail陈旧但滚动条实时不在底部"的边界没有针对性用例。
为此,计划要求在 SessionScrollTest 中新增两条权限回归测试:
// 新增用例 1:权限出现在底部时保持贴底
fun `test permission appearing at bottom keeps scroll at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
emit(ChatEventDto.PermissionAsked("ses_test", PermissionRequestDto(...)))
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
// 新增用例 2:用户停留在中间时,权限出现保持滚动位置
fun `test permission appearing while user is in middle preserves scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
val value = bar.value
emit(ChatEventDto.PermissionAsked("ses_test", PermissionRequestDto(...)))
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
测试要点:
- 事件类型使用
ChatEventDto.PermissionAsked("ses_test", PermissionRequestDto(...)),与仓库中已存在的恢复权限测试(rpc.pendingPermissionList.add(PermissionRequestDto("perm_pending", "ses_test", "edit", listOf("*.kt"))))保持同一 DTO 风格; - 断言分为两层:滚动条
bar.value是否保持不变(位置不被动),以及jumpButton().isVisible在用户离开底部时是否始终为true(用户可随时返回底部); - 若陈旧
tail的缺陷只有在 jump 按钮可见时才可复现,计划要求追加一条针对性用例:在QuestionAsked之前先构造出可见的 jump 状态,再断言首次显示不会发生跳转; - 禁止为测试引入生产专用测试钩子,一律复用 SessionUiTestBase.kt 中现有的辅助方法——
setValue(bar, value)通过先触发wheelNoop()再被动设置滚动条值来模拟用户滚动、setValuePassive只做纯被动赋值、drainScroll()循环 4 轮layout() + pumpEdt()来收敛异步的 follow pass、emit/forceFlush负责事件冲刷。这也是SessionScrollTest一贯的测试风格。
对既有的 question 中间位置用例(test question appearing while user is in middle preserves scroll position)则原样保留,作为修复不回归的底线。
验证步骤:如何在 kilo-jetbrains 下运行滚动测试
计划文档给出了明确的验证命令(工作目录为 packages/kilo-jetbrains):
# 1) 优先:按测试类过滤运行滚动测试
./gradlew test --tests "ai.kilocode.client.session.SessionScrollTest"
# 2) Gradle 过滤不可用或定向测试通过后,跑全量类型检查
./gradlew typecheck
执行前需确认环境前提:
- 需要 Java 21 环境(JetBrains 插件构建对 JDK 版本有要求);若机器上 Java 21 不可用,应先按仓库文档(
packages/kilo-jetbrains/AGENTS.md、packages/kilo-jetbrains/README.md)的指引安装或切换 JDK,再继续验证,不要跳过该前置检查; SessionScrollTest继承自SessionUiTestBase,属于 IntelliJ Platform 的轻量 UI 测试框架,运行时会自动完成会话 UI 组装、事件冲刷与滚动布局的编排;- 定向测试通过后务必再跑
typecheck,防止本次滚动改动波及SessionUi等其他编译单元。
预期改动文件与非目标
计划的落点被严格收敛在两个文件:
| 文件 | 改动性质 |
|---|---|
packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt |
将 QuestionView 的 follow lambda 由 scroll.following() 改为 scroll.atBottom()(方案 A) |
packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt |
新增权限首次出现的底部/中间两条回归用例,必要时追加陈旧状态用例 |
同时计划明确列出了以下非目标,防止修复范围失控:
- 不涉及任何后端或 RPC 协议改动;
- 不重构
SessionScroll的滚动算法本身; - 不改变显式 Question 导航(
goForward/goBack/goReview)的自动滚动行为; - 不改变 prompt 增长与普通流式内容的跟随行为。
小结:从这次修复看 JetBrains 会话滚动的设计要点
这次修复虽然只改动一个 lambda,但它折射出 Kilo JetBrains 前端会话滚动设计中反复出现的主题:
- 状态双轨制:
tail内部标记与滚动条实时位置之间存在"程序性滚动"与"用户滚动"的区分,任何插入内容都必须先回答"用户此刻到底在哪"; - 采样时机:
QuestionView.show()的"先采样、后渲染、再滚动"时序意味着采样 API 必须反映渲染前用户可见的真实状态,而不是可能滞后的内部跟随标记; - 测试即契约:
SessionScrollTest把"贴底跟随"、"中间位置保持"、"jump 按钮显隐"、"显式导航跳转"四类行为固化为几十条用例,任何对滚动策略的调整都必须在这四类行为上同时得到验证; - 边界最小化:计划文档通过显式 Non-Goals 声明不动的行为,让修复者可放心地只动一处调用点。
对于希望在 JetBrains 系插件中实现"内容插入不打断阅读"的开发者,本计划的思路(用实时底部状态替代内部跟随状态 + 面向四类行为补齐回归测试)是一份可以直接借鉴的实践模板。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python240
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java291
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java210
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript190
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python300