首页
/ Kilo JetBrains 插件会话滚动修复:Question 与权限视图首次出现时不再抢占用户滚动位置

Kilo JetBrains 插件会话滚动修复:Question 与权限视图首次出现时不再抢占用户滚动位置

2026-09-10 18:00:21作者:咎竹峻Karen

导读

本文基于仓库内实现计划 .kilo/plans/1779989384508-mighty-cabin.md,深入剖析 Kilo 的 JetBrains 插件(packages/kilo-jetbrains)中一个典型的 UI 交互缺陷:当问答类(question-like)活动视图——例如权限审批提示(permission prompt)与问题卡片(question view)——首次出现在会话 transcript 中时,如果用户已经离开 transcript 底部,会话不应强制自动滚动。文章将结合 SessionScrollSessionUiQuestionView 等前端源码与 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.ktbuildUi() 组装,而 transcript 的滚动行为全部收敛在 SessionScroll.kt 这一个类中。

SessionScroll 维护了若干内部状态,其中最关键的是 tail 布尔标记,表示"当前是否贴住 transcript 底部":

  • tail == true 时,新的流式内容(streaming part)会持续把视图钉在底部;
  • 当用户向上滚动(鼠标滚轮、拖动滚动条、PageUp/PageDown 等键盘操作)离开底部后,tail 被置为 false,此时屏幕上会出现一个"跳到底部"(jump)按钮,供用户随时回到最新内容。

围绕 tailSessionScroll 暴露了三个核心 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.ktbuildUi() 中,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.ktbuildUi() 中,把 QuestionViewfollow 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 middletest question review navigation follows immediately from middle 等。

计划文档指出两个覆盖缺口:

  1. 权限视图(permission)的首次出现滚动完全没有对应测试;
  2. "内部 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.mdpackages/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 QuestionViewfollow 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 前端会话滚动设计中反复出现的主题:

  1. 状态双轨制tail 内部标记与滚动条实时位置之间存在"程序性滚动"与"用户滚动"的区分,任何插入内容都必须先回答"用户此刻到底在哪";
  2. 采样时机QuestionView.show() 的"先采样、后渲染、再滚动"时序意味着采样 API 必须反映渲染前用户可见的真实状态,而不是可能滞后的内部跟随标记;
  3. 测试即契约SessionScrollTest 把"贴底跟随"、"中间位置保持"、"jump 按钮显隐"、"显式导航跳转"四类行为固化为几十条用例,任何对滚动策略的调整都必须在这四类行为上同时得到验证;
  4. 边界最小化:计划文档通过显式 Non-Goals 声明不动的行为,让修复者可放心地只动一处调用点。

对于希望在 JetBrains 系插件中实现"内容插入不打断阅读"的开发者,本计划的思路(用实时底部状态替代内部跟随状态 + 面向四类行为补齐回归测试)是一份可以直接借鉴的实践模板。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.15 K
2.78 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
904
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
931
1.86 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
862
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.95 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.38 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
535
605
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
549
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23