From b3e22e72dbd43b73608440ac343f297b19348567 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 5 Oct 2026 15:14:54 +0200 Subject: [PATCH 1/3] fix(vscode): keep session switching responsive with expanded edit diffs Reserve the cached height of a restored open tool card and defer its body mount, so Pierre and Shiki diff work no longer runs inside the Agent Manager session switch. Fall back to the eager path when the cached measurement is missing or stale. --- .changeset/quick-otters-switch.md | 5 + .../kilo-ui/src/components/basic-tool.css | 4 + .../kilo-ui/src/components/basic-tool.tsx | 63 ++++++++++--- .../kilo-ui/src/components/message-part.tsx | 3 + .../tests/fixtures/basic-tool-render.tsx | 94 +++++++++++++++++++ .../tests/unit/basic-tool-render.test.ts | 6 +- .../tests/unit/kilo-ui-contract.test.ts | 20 +++- packages/ui/src/components/basic-tool.tsx | 30 +++++- 8 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 .changeset/quick-otters-switch.md diff --git a/.changeset/quick-otters-switch.md b/.changeset/quick-otters-switch.md new file mode 100644 index 000000000000..d52305d26392 --- /dev/null +++ b/.changeset/quick-otters-switch.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager session switching responsive when visible edit diffs are expanded. diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index d49d9dd3cd1c..fd1770790d0d 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -222,6 +222,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty animation: tool-card-down 220ms cubic-bezier(0.23, 1, 0.32, 1); } + > [data-slot="collapsible-content"][data-deferred-height] { + animation: none; + } + > [data-slot="collapsible-content"][data-closed] { animation: tool-card-up 160ms cubic-bezier(0.77, 0, 0.175, 1); } diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 6f7a26b8f9bf..15e2a6d17365 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -1,4 +1,5 @@ -import { createMemo, Show } from "solid-js" +import { createMemo, createSignal, Show } from "solid-js" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { BasicTool as Base, GenericTool } from "@opencode-ai/ui/basic-tool" import type { BasicToolProps as BaseProps, TriggerTitle } from "@opencode-ai/ui/basic-tool" import { toolOpenKey, readToolOpen, writeToolOpen } from "./tool-open-state" @@ -11,6 +12,7 @@ export interface BasicToolProps extends BaseProps { tool?: string callID?: string partID?: string + revision?: unknown approvalPlacement?: "body" | "hidden" } @@ -20,11 +22,18 @@ type OpenProps = Pick() +const heights = new Map< + string, + { size: NonNullable; revision: unknown; approval: boolean; status: BaseProps["status"] } +>() function remember(key: string | undefined) { if (!key) return if (!mounted.has(key) && mounted.size >= MOUNTED_MAX) { const first = mounted.values().next().value - if (first) mounted.delete(first) + if (first) { + mounted.delete(first) + heights.delete(first) + } } mounted.add(key) } @@ -57,30 +66,48 @@ export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPl export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) const initial = () => initialOpen(props) + const approval = useToolApproval() + const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined) // A deferred card that mounts open paints one frame without its body (the // trigger only, about 24px) and grows to full size a frame later. On the - // first mount that is a cheap streaming trade-off. On a remount (virtualizer - // handoff, scrolling back into range) it is a collapse-and-expand flash of - // the full diff height that moves the pinned transcript, shifts the - // virtualizer's range and can remount the row again in a loop. Track cards - // that already mounted open, separately from the user preference map so the - // display setting and search `forceOpen` are not turned into a preference, - // and mount the body in the same frame when such a card comes back open. + // first mount that is a cheap streaming trade-off. On a remount it is a + // collapse-and-expand flash that shifts the virtualizer's range. Track cards + // that already mounted open, separately from the user preference map, so a + // remount can reserve its measured height without persisting display or + // search state as a user preference. const id = key() // Captured before the card is remembered so the first mount stays deferred. const remount = id !== undefined && mounted.has(id) + const cached = remount && id ? heights.get(id) : undefined + const size = + cached && cached.revision === props.revision && cached.status === props.status && cached.approval === inBody() + ? cached.size + : undefined if (initial() && !props.forceOpen) remember(id) - const defer = () => props.defer && !(remount && initial()) - const approval = useToolApproval() - const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined) + // Reserve the last measured details height while remounting through the + // deferred queue. Fall back to the eager path until a height is available. + const defer = () => props.defer && !(remount && initial() && size == null) const change = (open: boolean) => { writeToolOpen(key(), open) + if (open && !props.forceOpen) remember(key()) props.onOpenChange?.(open) } // Renders after the body/tool list, not before — it's context about what // happened, not part of the header. + const [body, setBody] = createSignal() + createResizeObserver(body, (rect) => { + const el = body() + const content = el?.parentElement + if (!id || !props.defer || !mounted.has(id) || !initial() || !content || rect.height <= 0) return + heights.set(id, { + size: { height: rect.height, width: content.getBoundingClientRect().width, font: getComputedStyle(content).font }, + revision: props.revision, + status: props.status, + approval: inBody(), + }) + }) const buildDetails = () => ( -
+
{props.children} {(value) => }
@@ -97,12 +124,20 @@ export function BasicTool(props: BasicToolProps) { + } > ( + +
Measured content
+
+ )) + const measured = seed.root.querySelector('[data-slot="collapsible-content"]')! + const size = { height: 240, width: measured.getBoundingClientRect().width, font: getComputedStyle(measured).font } + seed.dispose() + { + let built = 0 + const body = () => { + built += 1 + return
Restored diff
+ } + const { root, dispose } = mount(() => ( + + {body()} + + )) + const content = root.querySelector('[data-slot="collapsible-content"]') + assert.ok(content) + assert.equal(built, 0) + assert.equal(content.style.minHeight, "240px") + assert.ok(content.hasAttribute("data-deferred-height")) + await settle() + assert.equal(built, 1) + assert.ok(root.querySelector('[data-testid="deferred-body"]')) + assert.equal(content.style.minHeight, "") + assert.equal(content.hasAttribute("data-deferred-height"), true) + root.querySelector('[data-slot="collapsible-trigger"]')!.click() + await settle() + assert.equal(content.hasAttribute("data-deferred-height"), false) + dispose() + } + + // A rapid switch away must cancel the scheduled body construction. + { + let built = 0 + const body = () => { + built += 1 + return
Cancelled diff
+ } + const { root, dispose } = mount(() => ( + + {body()} + + )) + assert.ok(root.querySelector("[data-deferred-height]")) + dispose() + await settle() + assert.equal(built, 0) + } + + // An old measurement from another width must take the eager fallback. + { + let built = 0 + const body = () => { + built += 1 + return
Resized diff
+ } + const { root, dispose } = mount(() => ( + + {body()} + + )) + assert.equal(built, 1) + assert.ok(root.querySelector('[data-testid="resized-body"]')) + assert.equal(root.querySelector("[data-deferred-height]"), null) + dispose() + } } finally { await win.happyDOM.cancelAsync() await win.happyDOM.close() diff --git a/packages/kilo-vscode/tests/unit/basic-tool-render.test.ts b/packages/kilo-vscode/tests/unit/basic-tool-render.test.ts index 62d44d7ee517..b94c28d5db40 100644 --- a/packages/kilo-vscode/tests/unit/basic-tool-render.test.ts +++ b/packages/kilo-vscode/tests/unit/basic-tool-render.test.ts @@ -1,4 +1,8 @@ import { it } from "bun:test" import { fixture } from "../fixtures/run" -it("keeps a collapsed tool body lazy and its trigger node stable", () => fixture("basic-tool-render"), 30_000) +it( + "keeps tool bodies lazy and reserves restored open heights until deferred mount", + () => fixture("basic-tool-render"), + 30_000, +) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 25e01854735c..aefac861d5be 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -573,14 +573,24 @@ describe("Collapsed deferred tool details contract (source)", () => { }) describe("Deferred tool card remount contract (source)", () => { + const basic = fs.readFileSync(BASIC_TOOL_FILE, "utf-8") const wrapper = fs.readFileSync(path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/basic-tool.tsx"), "utf-8") + const css = fs.readFileSync(KILO_BASIC_TOOL_CSS_FILE, "utf-8") const scroll = fs.readFileSync(path.join(MONOREPO_ROOT, "packages/kilo-ui/src/hooks/create-auto-scroll.tsx"), "utf-8") - it("mounts a remembered-open deferred card with its body in the same frame", () => { - // Otherwise every virtualizer remount of an expanded diff paints a - // collapsed frame, then grows by the full diff height and the pinned - // transcript jumps (and can loop through the virtualizer's range). - expect(wrapper).toContain("const defer = () => props.defer && !(remount && initial())") + it("reserves remembered-open card height while remounting its body later", () => { + // Restoring the measured height prevents the collapsed frame and + // virtualizer feedback loop without rebuilding Pierre inside the switch. + expect(wrapper).toContain("const cached = remount && id ? heights.get(id) : undefined") + expect(wrapper).toContain("const defer = () => props.defer && !(remount && initial() && size == null)") + expect(wrapper).toContain("deferredSize={size}") + expect(wrapper).toContain("cached.revision === props.revision") + expect(wrapper).toContain("!mounted.has(id)") + expect(wrapper).toContain("if (open && !props.forceOpen) remember(key())") + expect(basic).toContain("deferredSize?: { height: number; width: number; font: string }") + expect(basic).toContain('data-deferred-height={state.restored ? "" : undefined}') + expect(basic).toContain('style={{ "min-height": reserve() }}') + expect(css).toMatch(/\[data-slot="collapsible-content"\]\[data-deferred-height\]\s*\{[^}]*animation:\s*none;/) // The memory is separate from the user preference map: a display setting // or search forceOpen must not become a durable per-card open state. expect(wrapper).toContain("if (initial() && !props.forceOpen) remember(id)") diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 14cf893df083..07c71a1d8233 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -36,6 +36,7 @@ export interface BasicToolProps { defer?: boolean retainDetails?: boolean // kilocode_change hasDetails?: boolean // kilocode_change + deferredSize?: { height: number; width: number; font: string } // kilocode_change locked?: boolean animated?: boolean allowPendingToggle?: boolean // kilocode_change @@ -101,9 +102,19 @@ export function BasicTool(props: BasicToolProps) { const [state, setState] = createStore({ open: props.defaultOpen ?? false, ready: !props.defer && (props.defaultOpen ?? false), + restored: !!(props.defer && props.deferredSize && (props.open ?? props.defaultOpen)), // kilocode_change }) const open = () => props.open ?? state.open const ready = () => state.ready + // kilocode_change start - keep a restored open card at its measured height + // while its expensive body mounts through the deferred frame queue. + const reserve = () => { + if (!props.defer || ready() || !open()) return + if (props.deferredSize == null || props.deferredSize.height <= 0) return + return `${props.deferredSize.height}px` + } + let content: HTMLDivElement | undefined + // kilocode_change end const pending = () => props.status === "pending" || props.status === "running" // kilocode_change start - read the trigger getter once. A JSX trigger is // rebuilt on every read of `props.trigger`, and the copy built only for the @@ -143,6 +154,17 @@ export function BasicTool(props: BasicToolProps) { onCleanup(cancel) onMount(() => { + // kilocode_change start - stale measurements must not shift the restored row + if (props.defer && open() && content && props.deferredSize) { + if ( + Math.abs(content.getBoundingClientRect().width - props.deferredSize.width) > 1 || + getComputedStyle(content).font !== props.deferredSize.font + ) { + setState({ ready: true, restored: false }) + return + } + } + // kilocode_change end if (props.defer && open()) scheduleReady(true) }) @@ -163,6 +185,7 @@ export function BasicTool(props: BasicToolProps) { (value) => { if (!props.defer) return if (!value) { + setState("restored", false) // kilocode_change cancel() if (!props.retainDetails) setState("ready", false) // kilocode_change return @@ -349,7 +372,12 @@ export function BasicTool(props: BasicToolProps) {
{/* kilocode_change start */} - + {props.children} From d756c2b4d4ffc4e225925f6b0a81592aeddb4ee7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 5 Oct 2026 15:33:03 +0200 Subject: [PATCH 2/3] fix(vscode): fingerprint deferred diff revision in the height cache Store a short checksum of the patch instead of the full string, so the cache cannot pin large diff payloads for the webview lifetime. Addresses review feedback. --- packages/kilo-ui/src/components/basic-tool.tsx | 14 +++++++++++--- .../tests/unit/kilo-ui-contract.test.ts | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 15e2a6d17365..135abfb928df 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, Show } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { checksum } from "@opencode-ai/core/util/encode" import { BasicTool as Base, GenericTool } from "@opencode-ai/ui/basic-tool" import type { BasicToolProps as BaseProps, TriggerTitle } from "@opencode-ai/ui/basic-tool" import { toolOpenKey, readToolOpen, writeToolOpen } from "./tool-open-state" @@ -24,7 +25,7 @@ const MOUNTED_MAX = 2000 const mounted = new Set() const heights = new Map< string, - { size: NonNullable; revision: unknown; approval: boolean; status: BaseProps["status"] } + { size: NonNullable; revision: string | undefined; approval: boolean; status: BaseProps["status"] } >() function remember(key: string | undefined) { if (!key) return @@ -78,9 +79,16 @@ export function BasicTool(props: BasicToolProps) { const id = key() // Captured before the card is remembered so the first mount stays deferred. const remount = id !== undefined && mounted.has(id) + // Fingerprint the patch/content instead of retaining the full string so a + // long-lived cache cannot pin large diff payloads. + const revision = createMemo(() => { + const value = props.revision + if (typeof value === "string") return checksum(value) + return value == null ? undefined : String(value) + }) const cached = remount && id ? heights.get(id) : undefined const size = - cached && cached.revision === props.revision && cached.status === props.status && cached.approval === inBody() + cached && cached.revision === revision() && cached.status === props.status && cached.approval === inBody() ? cached.size : undefined if (initial() && !props.forceOpen) remember(id) @@ -101,7 +109,7 @@ export function BasicTool(props: BasicToolProps) { if (!id || !props.defer || !mounted.has(id) || !initial() || !content || rect.height <= 0) return heights.set(id, { size: { height: rect.height, width: content.getBoundingClientRect().width, font: getComputedStyle(content).font }, - revision: props.revision, + revision: revision(), status: props.status, approval: inBody(), }) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index aefac861d5be..a41cc5862d3e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -584,7 +584,9 @@ describe("Deferred tool card remount contract (source)", () => { expect(wrapper).toContain("const cached = remount && id ? heights.get(id) : undefined") expect(wrapper).toContain("const defer = () => props.defer && !(remount && initial() && size == null)") expect(wrapper).toContain("deferredSize={size}") - expect(wrapper).toContain("cached.revision === props.revision") + expect(wrapper).toContain("cached.revision === revision()") + expect(wrapper).toContain("const value = props.revision") + expect(wrapper).toContain("if (typeof value === \"string\") return checksum(value)") expect(wrapper).toContain("!mounted.has(id)") expect(wrapper).toContain("if (open && !props.forceOpen) remember(key())") expect(basic).toContain("deferredSize?: { height: number; width: number; font: string }") From 480647b9a8f634b7f568fc221152a6a27be82833 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 5 Oct 2026 15:45:36 +0200 Subject: [PATCH 3/3] style: format deferred remount contract test --- packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index a41cc5862d3e..90e7b43bed7a 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -586,7 +586,7 @@ describe("Deferred tool card remount contract (source)", () => { expect(wrapper).toContain("deferredSize={size}") expect(wrapper).toContain("cached.revision === revision()") expect(wrapper).toContain("const value = props.revision") - expect(wrapper).toContain("if (typeof value === \"string\") return checksum(value)") + expect(wrapper).toContain('if (typeof value === "string") return checksum(value)') expect(wrapper).toContain("!mounted.has(id)") expect(wrapper).toContain("if (open && !props.forceOpen) remember(key())") expect(basic).toContain("deferredSize?: { height: number; width: number; font: string }")