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/tool-fade-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix a memory leak that made Agent Manager session switching slower the longer the window stayed open. Tool card fade animations kept unmounted transcript rows alive.
6 changes: 3 additions & 3 deletions packages/kilo-ui/src/components/grow-box.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
import { animate, settle, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"

export interface GrowBoxProps {
children: JSX.Element
Expand Down Expand Up @@ -406,8 +406,8 @@ export function GrowBox(props: GrowBoxProps) {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
height.destroy()
fadeAnim?.stop()
edgeAnim?.stop()
settle(fadeAnim)
settle(edgeAnim)
edgeAnim = undefined
edgeOn = false
})
Expand Down
17 changes: 16 additions & 1 deletion packages/kilo-ui/src/components/motion.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { followValue } from "motion"
import type { MotionValue } from "motion"
import type { AnimationPlaybackControls, MotionValue } from "motion"

export { animate, springValue } from "motion"
export type { AnimationPlaybackControls } from "motion"

/**
* Settle an element animation whose element is going away.
*
* `stop()` cancels the Web Animation but leaves Motion's `MotionValue.animation`
* pointing at it until the animation *finishes*, which a cancelled animation
* never does. That reference cycles through the WAAPI Animation, its target
* element and Motion's per-element visual element store, and the GC does not
* collect the cycle. Every fade interrupted by a session switch then pins its
* transcript row in memory. `complete()` finishes the animation instead, so
* Motion resolves its promise and drops the reference.
*/
export function settle(anim: AnimationPlaybackControls | undefined) {
anim?.complete()
}

/**
* Like `springValue` but preserves getters on the config object.
* `springValue` spreads config at creation, snapshotting getter values.
Expand Down
17 changes: 13 additions & 4 deletions packages/kilo-ui/src/components/tool-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
clearMaskStyles,
COLLAPSIBLE_SPRING,
GROW_SPRING,
settle,
WIPE_MASK,
} from "./motion"

Expand Down Expand Up @@ -201,8 +202,8 @@ export function useCollapsible(options: {

onCleanup(() => {
++gen
heightAnim?.stop()
fadeAnim?.stop()
settle(heightAnim)
settle(fadeAnim)
})
}

Expand Down Expand Up @@ -262,7 +263,7 @@ export function useGrowIn(el: () => HTMLElement | undefined, enabled: boolean) {
onCleanup(() => {
++gen
obs?.disconnect()
height?.stop()
settle(height)
const node = el()
if (node) clear(node)
})
Expand Down Expand Up @@ -354,6 +355,7 @@ export function useRowWipe(opts: {
cancelAnimationFrame(frame)
clear()
}
settle(anim)
})
})
}
Expand Down Expand Up @@ -401,6 +403,13 @@ export function useToolFade(
frame = undefined
const node = ref()
if (!node) return
// A node outside the document never finishes a Web Animation, and the
// pending animation keeps the node and its owner tree alive. Show it as is.
if (!node.isConnected) {
clearFadeStyles(node)
if (mask) clearMaskStyles(node)
return
}

anim = wipe
? mask
Expand All @@ -423,6 +432,6 @@ export function useToolFade(

onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
anim?.stop()
settle(anim)
})
}
21 changes: 17 additions & 4 deletions packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" // kilocode_change: added createMemo
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
Expand Down Expand Up @@ -105,6 +105,16 @@ export function BasicTool(props: BasicToolProps) {
const open = () => props.open ?? state.open
const ready = () => state.ready
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
// Match condition is never inserted, so its mounted effects (fade
// animations) outlive the card and leak the transcript row.
const node = createMemo(() => props.trigger)
const title = () => {
const value = node()
return isTriggerTitle(value) ? value : undefined
}
// kilocode_change end
// kilocode_change start - testing for children must not evaluate them. Reading
// the `children` getter constructs the whole collapsed body tree (and runs
// Markdown/diff parsing inside it) on every mount, even while closed, which
Expand Down Expand Up @@ -193,7 +203,10 @@ export function BasicTool(props: BasicToolProps) {
)

onCleanup(() => {
heightAnim?.stop()
// kilocode_change start - complete, not stop: a stopped animation keeps
// Motion's reference cycle to the removed element alive (see kilo-ui motion.tsx settle)
heightAnim?.complete()
// kilocode_change end
})

const handleOpenChange = (value: boolean) => {
Expand Down Expand Up @@ -226,7 +239,7 @@ export function BasicTool(props: BasicToolProps) {
{/* kilocode_change end */}
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
<Match when={title()}>{/* kilocode_change */}
{(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
Expand Down Expand Up @@ -278,7 +291,7 @@ export function BasicTool(props: BasicToolProps) {
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
<Match when={true}>{node() as JSX.Element}</Match>{/* kilocode_change */}
</Switch>
</div>
</div>
Expand Down
Loading