mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user