Merge pull request #13429 from Kilo-Org/investigate-worktree-scroll-reset

fix(agent-manager): preserve worktree list scroll on deletion
This commit is contained in:
Marius
2026-08-25 18:05:02 +02:00
committed by GitHub
4 changed files with 211 additions and 3 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve the Agent Manager sidebar scroll position when worktrees are deleted.
@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { createSidebarScrollPreserver } from "../../webview-ui/agent-manager/sidebar-scroll"
const window = new Window()
const frames = new Map<number, FrameRequestCallback>()
let id = 0
function schedule(fn: FrameRequestCallback) {
const next = ++id
frames.set(next, fn)
return next
}
function cancel(id: number) {
frames.delete(id)
}
function preserver(active: () => string | null | undefined = () => undefined) {
return createSidebarScrollPreserver(active, window.document, schedule, cancel)
}
afterEach(() => {
window.document.body.innerHTML = ""
frames.clear()
id = 0
})
function list(cls = "am-worktree-list") {
const el = window.document.createElement("div")
el.className = cls
Object.defineProperty(el, "scrollTop", { configurable: true, value: 0, writable: true })
window.document.body.append(el)
return el
}
function flush() {
for (let i = 0; i < 2; i++) {
const next = frames.entries().next().value
if (!next) return
frames.delete(next[0])
next[1](0)
}
}
describe("Agent Manager sidebar scroll preservation", () => {
it("restores the scroll offset after the state update has rendered", () => {
const el = list()
el.scrollTop = 240
const preserve = preserver()
preserve(() => {
el.scrollTop = 0
})
expect(el.scrollTop).toBe(0)
flush()
expect(el.scrollTop).toBe(240)
})
it("tracks project and worktree scroll owners independently", () => {
const projects = list("am-projects-list")
const worktrees = list()
projects.scrollTop = 120
worktrees.scrollTop = 80
const preserve = preserver()
preserve(() => {
projects.scrollTop = 0
worktrees.scrollTop = 0
})
flush()
expect(projects.scrollTop).toBe(120)
expect(worktrees.scrollTop).toBe(80)
})
it("does not override intentional selection scrolling", () => {
const el = list()
el.scrollTop = 240
const preserve = preserver()
preserve(() => {
el.scrollTop = 140
})
flush()
expect(el.scrollTop).toBe(140)
})
it("does not restore when the selected worktree changes during the update", () => {
const el = list()
let selected = "first"
el.scrollTop = 240
const preserve = preserver(() => selected)
preserve(() => {
el.scrollTop = 0
selected = "second"
})
flush()
expect(el.scrollTop).toBe(0)
})
it("does not restore when selection changes before the delayed frame", () => {
const el = list()
let selected = "first"
el.scrollTop = 240
const preserve = preserver(() => selected)
preserve(() => {
el.scrollTop = 0
})
selected = "second"
flush()
expect(el.scrollTop).toBe(0)
})
it("keeps intentional scrolling from the top of the list", () => {
const el = list()
const preserve = preserver()
preserve(() => {
el.scrollTop = 180
})
flush()
expect(el.scrollTop).toBe(180)
})
it("cancels stale restores when a newer state arrives", () => {
const el = list()
const preserve = preserver()
el.scrollTop = 120
preserve(() => {
el.scrollTop = 0
})
el.scrollTop = 210
preserve(() => {
el.scrollTop = 0
})
flush()
expect(el.scrollTop).toBe(210)
expect(frames.size).toBe(0)
})
it("does not restore a container that was removed by the update", () => {
const el = list()
el.scrollTop = 160
const preserve = preserver()
preserve(() => {
el.remove()
el.scrollTop = 0
})
flush()
expect(el.isConnected).toBe(false)
expect(el.scrollTop).toBe(0)
})
})
@@ -213,6 +213,7 @@ import { defaultBase as projectDefaultBase } from "./project/default-base"
import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
import { createSidebarScrollPreserver } from "./sidebar-scroll"
const REVIEW_TAB_ID = "review"
interface SetupState {
active: boolean
@@ -1089,7 +1090,6 @@ const AgentManagerContent: Component = () => {
apply: (state) => applyActiveState(state),
pruneLive: (ids) => projectLive.prune(ids),
})
const stateHandlers = createProjectStateHandlers({
setMulti: setMultiProject,
setProjects: setProjectList,
@@ -1105,7 +1105,7 @@ const AgentManagerContent: Component = () => {
rename: setRenamingSection,
font: (font) => font && setTerminalFont(font),
})
const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID())
/** Apply the active-transition effects of a state payload (data already landed in the store). */
const applyActiveState = (state: AgentManagerStateMessage) => {
const switched = applyProjectSwitch(state)
@@ -1479,7 +1479,7 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.focusContextRequested") focusCtl.report()
if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
if (msg.type === "agentManager.state") stateHandlers.state(msg)
if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg))
// When a multi-version progress update arrives, mark newly created worktrees as loading
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
@@ -0,0 +1,38 @@
type Entry = {
el: HTMLElement
top: number
}
export function createSidebarScrollPreserver(
active: () => string | null | undefined = () => undefined,
root: ParentNode = document,
schedule: typeof requestAnimationFrame = requestAnimationFrame,
cancel: typeof cancelAnimationFrame = cancelAnimationFrame,
) {
let frame: number | undefined
let inner: number | undefined
return (fn: () => void): void => {
if (frame !== undefined) cancel(frame)
if (inner !== undefined) cancel(inner)
const prior = active()
const scrolls: Entry[] = [...root.querySelectorAll<HTMLElement>(".am-worktree-list, .am-projects-list")].map(
(el) => ({
el,
top: el.scrollTop,
}),
)
fn()
frame = schedule(() => {
frame = undefined
inner = schedule(() => {
inner = undefined
if (active() !== prior) return
for (const item of scrolls) {
if (item.el.isConnected && item.top > 0 && item.el.scrollTop === 0) item.el.scrollTop = item.top
}
})
})
}
}