Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-otters-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep Agent Manager session switching responsive when visible edit diffs are expanded.
4 changes: 4 additions & 0 deletions packages/kilo-ui/src/components/basic-tool.css
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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);
}
Expand Down
71 changes: 57 additions & 14 deletions packages/kilo-ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createMemo, Show } from "solid-js"
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"
Expand All @@ -11,6 +13,7 @@ export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
revision?: unknown
approvalPlacement?: "body" | "hidden"
}

Expand All @@ -20,11 +23,18 @@ type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen"
// remount; never read as an open preference.
const MOUNTED_MAX = 2000
const mounted = new Set<string>()
const heights = new Map<
string,
{ size: NonNullable<BaseProps["deferredSize"]>; revision: string | undefined; 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)
}
Expand Down Expand Up @@ -57,30 +67,55 @@ 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)
// 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 === 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<HTMLDivElement>()
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: revision(),
status: props.status,
approval: inBody(),
})
})
const buildDetails = () => (
<div data-slot="basic-tool-details">
<div ref={setBody} data-slot="basic-tool-details">
{props.children}
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
</div>
Expand All @@ -97,12 +132,20 @@ export function BasicTool(props: BasicToolProps) {
<Show
when={"children" in props || inBody()}
fallback={
<Base {...props} defer={defer()} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
<Base
{...props}
defer={defer()}
deferredSize={size}
defaultOpen={initial()}
retainDetails={props.defer}
onOpenChange={change}
/>
}
>
<Base
{...props}
defer={defer()}
deferredSize={size}
defaultOpen={initial()}
retainDetails={props.defer}
onOpenChange={change}
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2981,6 +2981,7 @@ ToolRegistry.register({
<div data-component="edit-tool">
<BasicTool
{...props}
revision={props.metadata?.filediff?.patch ?? props.input.newString}
icon="code-lines"
defer
hasDetails
Expand Down Expand Up @@ -3068,6 +3069,7 @@ ToolRegistry.register({
<div data-component="write-tool">
<BasicTool
{...props}
revision={props.metadata?.filediff?.patch ?? props.input.content}
icon="code-lines"
defer
hasDetails
Expand Down Expand Up @@ -3248,6 +3250,7 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
revision={props.input.patchText}
icon="code-lines"
defer
hasDetails
Expand Down
94 changes: 94 additions & 0 deletions packages/kilo-vscode/tests/fixtures/basic-tool-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,100 @@ try {
assert.ok(root.querySelector('[data-testid="custom-trigger"]'))
dispose()
}

// A restored open card reserves its height without building the body in
// the selection task. The reservation disappears once the body mounts.
const seed = mount(() => (
<BasicTool icon="task" status="completed" trigger={{ title: "Measured card" }} defaultOpen>
<div>Measured content</div>
</BasicTool>
))
const measured = seed.root.querySelector<HTMLElement>('[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 <div data-testid="deferred-body">Restored diff</div>
}
const { root, dispose } = mount(() => (
<BasicTool
icon="task"
status="completed"
trigger={{ title: "Restored diff" }}
defaultOpen
defer
deferredSize={size}
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
>
{body()}
</BasicTool>
))
const content = root.querySelector<HTMLElement>('[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<HTMLButtonElement>('[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 <div>Cancelled diff</div>
}
const { root, dispose } = mount(() => (
<BasicTool
icon="task"
status="completed"
trigger={{ title: "Cancelled diff" }}
defaultOpen
defer
deferredSize={size}
>
{body()}
</BasicTool>
))
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 <div data-testid="resized-body">Resized diff</div>
}
const { root, dispose } = mount(() => (
<BasicTool
icon="task"
status="completed"
trigger={{ title: "Resized diff" }}
defaultOpen
defer
deferredSize={{ ...size, width: size.width + 100 }}
>
{body()}
</BasicTool>
))
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()
Expand Down
6 changes: 5 additions & 1 deletion packages/kilo-vscode/tests/unit/basic-tool-render.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
22 changes: 17 additions & 5 deletions packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,14 +573,26 @@ 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 === 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 }")
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)")
Expand Down
30 changes: 29 additions & 1 deletion packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})

Expand All @@ -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
Expand Down Expand Up @@ -349,7 +372,12 @@ export function BasicTool(props: BasicToolProps) {
</Show>
{/* kilocode_change start */}
<Show when={!props.animated && (hasChildren() || hasDetails()) && !props.hideDetails}>
<Collapsible.Content onAnimationEnd={end}>
<Collapsible.Content
ref={content}
onAnimationEnd={end}
data-deferred-height={state.restored ? "" : undefined}
style={{ "min-height": reserve() }}
>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
</Show>
Expand Down
Loading