perf(vscode): share timeline tooltip and lazy-mount historical bash output

This commit is contained in:
marius-kilocode
2026-06-02 15:49:59 +02:00
parent 5b34dfce3d
commit 28fa37d5b1
6 changed files with 123 additions and 25 deletions
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Speed up Agent Manager switching for edit-heavy sessions by rendering collapsed historical tool details only when expanded.
Speed up Agent Manager switching for long sessions by lazily mounting collapsed historical tool details and sharing timeline hover infrastructure across activity bars.
@@ -2189,6 +2189,11 @@ ToolRegistry.register({
const subtitle = () => props.input.description ?? props.metadata.description
const key = () => toolOpenKey(props)
const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true)
const [mounted, setMounted] = createSignal(open())
createEffect(() => {
if (open() || pending()) setMounted(true)
})
// also apply processCarriageReturns for Windows CLI tools
const cmd = createMemo(() => {
@@ -2208,6 +2213,7 @@ ToolRegistry.register({
{...props}
icon="console"
animated
hasDetails
defaultOpen={props.defaultOpen ?? true}
onOpenChange={setOpen}
allowPendingToggle
@@ -2222,7 +2228,9 @@ ToolRegistry.register({
</div>
}
>
<BashHighlightedOutput cmd={cmd()} output={out()} outputPath={props.metadata.outputPath} active={open()} />
<Show when={mounted()}>
<BashHighlightedOutput cmd={cmd()} output={out()} outputPath={props.metadata.outputPath} active={open()} />
</Show>
</BasicTool>
)
},
@@ -301,4 +301,13 @@ describe("Collapsed deferred tool details contract (source)", () => {
expect(block).toContain("hasDetails")
}
})
it("lazy-mounts completed bash output and retains it after first expansion", () => {
const block =
message.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? ""
expect(block).toContain("const [mounted, setMounted] = createSignal(open())")
expect(block).toMatch(/if \(open\(\) \|\| pending\(\)\) setMounted\(true\)/)
expect(block).toContain("hasDetails")
expect(block).toMatch(/<Show when=\{mounted\(\)\}>[\s\S]*?<BashHighlightedOutput/)
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Regression guard for timeline tooltip mount cost.
*
* A long session can render hundreds of timeline bars. Wrapping every bar in
* the shared Tooltip component creates a Kobalte tooltip instance and
* MutationObserver per bar during session activation. The timeline keeps all
* bars but delegates hover handling to one portal tooltip instead.
*/
describe("TaskTimeline delegated tooltip contract", () => {
const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "TaskTimeline.tsx")
const src = readFileSync(path, "utf8")
it("does not mount one shared Tooltip component per timeline bar", () => {
expect(src).not.toMatch(/@kilocode\/kilo-ui\/tooltip/)
expect(src).not.toMatch(/<Tooltip\b/)
})
it("keeps bar labels and renders one delegated portal tooltip", () => {
expect(src).toMatch(/data-tip=\{bar\(\)\.tip\}/)
expect(src).toMatch(/role="img"/)
expect(src).toMatch(/aria-label=\{bar\(\)\.tip\}/)
expect(src).toMatch(/if \(!bar \|\| !ref\?\.contains\(bar\)\) return hideTip\(\)/)
expect(src).toMatch(/<Portal>/)
expect(src).toMatch(/class="task-timeline-tooltip"/)
})
})
@@ -12,8 +12,8 @@
* updates bindings in place (unlike React). Even 1000+ bars are fine.
*/
import { Component, Index, createMemo, createEffect, on, onCleanup } from "solid-js"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Component, Index, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { useSession } from "../../context/session"
import { color, label } from "../../utils/timeline/colors"
import { sizes, MAX_HEIGHT } from "../../utils/timeline/sizes"
@@ -56,6 +56,8 @@ export const TaskTimeline: Component = () => {
let dragging = false
let startX = 0
let startScroll = 0
let tipBar: HTMLElement | undefined
const [tip, setTip] = createSignal<{ text: string; x: number; y: number }>()
const messages = () => session.visibleMessages()
const allParts = () => {
@@ -85,8 +87,31 @@ export const TaskTimeline: Component = () => {
),
)
const hideTip = () => {
tipBar = undefined
setTip(undefined)
}
createEffect(on(bars, hideTip, { defer: true }))
const showTip = (e: PointerEvent) => {
if (dragging || !(e.target instanceof Element)) return
const bar = e.target.closest<HTMLElement>(".task-timeline-bar")
if (!bar || !ref?.contains(bar)) return hideTip()
if (bar === tipBar) return
const rect = bar.getBoundingClientRect()
tipBar = bar
const margin = Math.min(160, window.innerWidth / 2)
setTip({
text: bar.dataset.tip ?? "",
x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + rect.width / 2)),
y: rect.top,
})
}
// ── Drag scroll ──────────────────────────────────────────────────
const onPointerDown = (e: PointerEvent) => {
hideTip()
if (!ref) return
dragging = true
startX = e.clientX
@@ -97,7 +122,7 @@ export const TaskTimeline: Component = () => {
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging || !ref) return
if (!dragging || !ref) return showTip(e)
ref.scrollLeft = startScroll - (e.clientX - startX)
}
@@ -111,6 +136,7 @@ export const TaskTimeline: Component = () => {
// ── Wheel → horizontal scroll ────────────────────────────────────
const onWheel = (e: WheelEvent) => {
hideTip()
if (!ref) return
e.preventDefault()
ref.scrollLeft += e.deltaY || e.deltaX
@@ -124,23 +150,27 @@ export const TaskTimeline: Component = () => {
})
return (
<div class="task-timeline-outer">
<div
ref={ref}
class="task-timeline"
style={{ height: `${MAX_HEIGHT}px` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<Index each={bars()}>
{(bar) => {
const active = () => busy() && bar().idx === bars().length - 1
return (
<Tooltip value={bar().tip} placement="top">
<>
<div class="task-timeline-outer">
<div
ref={ref}
class="task-timeline"
style={{ height: `${MAX_HEIGHT}px` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={hideTip}
>
<Index each={bars()}>
{(bar) => {
const active = () => busy() && bar().idx === bars().length - 1
return (
<div
class="task-timeline-bar"
data-tip={bar().tip}
role="img"
aria-label={bar().tip}
style={{
width: `${bar().width}px`,
height: `${MAX_HEIGHT}px`,
@@ -157,11 +187,25 @@ export const TaskTimeline: Component = () => {
}}
/>
</div>
</Tooltip>
)
}}
</Index>
)
}}
</Index>
</div>
</div>
</div>
<Show when={tip()}>
{(current) => (
<Portal>
<div
data-component="tooltip"
class="task-timeline-tooltip"
role="tooltip"
style={{ left: `${current().x}px`, top: `${current().y}px` }}
>
{current().text}
</div>
</Portal>
)}
</Show>
</>
)
}
@@ -164,6 +164,13 @@
animation: timeline-pulse 2s ease-in-out infinite 0.5s;
}
.task-timeline-tooltip {
position: fixed;
max-width: min(320px, calc(100vw - 16px));
transform: translate(-50%, calc(-100% - 4px));
white-space: normal;
}
@keyframes timeline-fade-in {
from {
opacity: 0;