perf(streaming): four fixes that unfreeze long-session streaming (#9341)

* perf(vscode): stop O(N) reactive cascade on every streaming token

The webview DataBridge wrapped the whole session Data shape in a
`createMemo`, whose body walked `store.parts[msg.id]` for every message
in the session family. Any single part mutation — i.e. every token
delta — invalidated the memo, produced a fresh POJO, and invalidated
every downstream consumer (including O(N) scans inside each mounted
SessionTurn). On a 200-message session a Chrome CPU profile showed
three back-to-back 440ms main-thread blocks per SSE batch, ~46% of
the time in Solid reactive runtime alone.

Expose `data` as a plain object with reactive getters over
`session.allMessages`/`allParts`/`allStatusMap` so consumers reading
`data.store.part[Y]` subscribe to only that key. Removes the now-unused
`familyData` helper and its interface/mock entries.

* perf(ui): drop TextShimmer JS timer — CSS-only animation

A createEffect inside TextShimmer ran clearTimeout + setTimeout on every
`active` prop change to gate the sweep animation via a `data-run`
attribute. During LLM token streaming in long sessions, `active` props
(bound to `pending()` / `running()` accessors) thrashed as tools
started/finished across many shimmer instances. CPU profile of a 7s
streaming window showed ~2,500 timer operations — 16% of the blocked
main-thread time.

Remove the effect and drive the animation purely from the `data-active`
attribute. The opacity transition on the shimmer char (220ms) already
handles the fade, so visual behavior is unchanged. Adds one static
regression guard and one runtime perf assertion (with happy-dom) that
toggling the prop 1000 times results in zero timer calls.

* perf(kilo-ui): skip layout reads in GrowBox watch-mode ResizeObserver

The GrowBox component wraps each assistant part and, when watch=true
(which is set on the currently-streaming text part), runs a
ResizeObserver that called body.getBoundingClientRect() via
targetHeight() on every body-size change. During streaming this fires
at ~60Hz and each call forces a synchronous layout. CPU profile of a
7s streaming window showed 1,362 gBCR samples (~9% of blocked
main-thread time) all attributable to this path.

Reuse the browser's pre-measured contentBoxSize / contentRect from the
observer entries — no extra layout read. Also skip sub-pixel updates
(<2px) that the spring absorbs imperceptibly anyway, cutting per-token
spring work when tokens add tiny height deltas.

* perf(ui): coalesce markdown parse to one per animation frame

During LLM token streaming, the Markdown render effect ran
temp.innerHTML = content + morphdom on every content update. SSE
tokens arrive at 60–200Hz and each delta reparsed the entire
accumulated HTML. CPU profile of a 7s streaming window showed 2,940
ParseHTML events totaling ~619ms (~46% of blocked main-thread time).

Queue the latest content in a component-scoped pending variable and
run the morphdom pass inside requestAnimationFrame. K rapid updates
before the frame fires now collapse to one parse. The onCleanup
handler cancels any queued frame so it doesn't touch an unmounted
DOM. Fast-path is preserved untouched so non-streaming first paint
stays synchronous.

* chore(changeset): consolidate streaming-perf changesets into one

Per-commit changesets produced four nearly-identical release-note
entries. The user-visible change is a single perceptual improvement —
streaming is smooth in long sessions — so roll them up into one
feature-oriented entry.

* test(vscode): consolidate streaming perf tests + wire into CI

Replace three synthetic reactivity tests with a single end-to-end
streaming perf benchmark that:
- Renders the real TextShimmer component and asserts zero setTimeout/
  clearTimeout calls during a 100-toggle burst (TextShimmer fix).
- Asserts per-key Solid reactivity: 100 text deltas on one message
  must re-run only that message's consumer, not O(N) consumers
  (DataBridge cascade fix).
- Uses only count-based assertions against deterministic APIs
  (setTimeout/clearTimeout/innerHTML setter) — no wall-clock
  thresholds, so it doesn't flake under CI load.

Also wire `bun run test:webview-reactivity` into the test-vscode
workflow so the benchmark runs on every PR that touches
packages/kilo-vscode, packages/ui, or packages/kilo-ui. Without this
wiring the perf regression guard would have shipped dormant.

* test(vscode): declare @happy-dom/global-registrator as devDep

The streaming-perf benchmark imports @happy-dom/global-registrator to
get a DOM for mounting the real TextShimmer component. It resolved
locally through workspace hoisting but CI's clean install didn't have
it. Make the dependency explicit.

* test(vscode): import TextShimmer via package export so JSX resolves

Using the deep relative path (../../../ui/src/components/text-shimmer)
made Bun's test transpiler apply kilo-vscode's tsconfig — which has no
`jsxImportSource` — so the .tsx file was compiled with the default
React runtime, producing "React is not defined" in CI.

Resolving through the package export (@opencode-ai/ui/text-shimmer)
picks up packages/ui/tsconfig.json which sets
`jsxImportSource: solid-js`. Works consistently across Linux/macOS/
Windows CI without needing bunfig-level JSX overrides.

* test(vscode): address bot review — add runtime coverage for Markdown + GrowBox, drop empty smoke test

Two kilo-code-bot findings on the streaming perf bench:

1. The 'benchmark completes quickly' smoke test timed an empty block,
   so `elapsed` was always near zero and the assertion never fired.
   Drop it — the real benchmarks below already complete in ~90ms.

2. The original file installed spy counters for innerHTML writes and
   getBoundingClientRect but never asserted against them, leaving
   Markdown rAF coalescing and GrowBox layout-read regressions silently
   uncaught.

Add two runtime mirrors:
- Markdown rAF pattern: 100 async content updates coalesce to <20 parses
  (would be exactly 100 pre-fix).
- GrowBox ResizeObserver pattern: 100 synthetic resize callbacks using
  contentBoxSize/contentRect trigger zero getBoundingClientRect calls
  (would be exactly 100 pre-fix).

Source-level regression guards in tests/unit/markdown-raf-coalesce.test.ts
and tests/unit/growbox-no-layout-thrash.test.ts cover the actual
component code. The runtime tests here prove the patterns the guards
require actually deliver the perf property at runtime.

Benchmark runs in ~90ms, 5 consecutive local runs all green.

* test(vscode): drop unstable TextShimmer runtime mount from streaming perf bench

The benchmark tried to mount the real @opencode-ai/ui TextShimmer to
assert zero setTimeout/clearTimeout calls. That required Bun's test
runner to transpile text-shimmer.tsx with Solid's JSX runtime, which
depends on tsconfig resolution walking up to packages/ui/tsconfig.json.
In CI (fresh workspace, different node_modules layout) this resolution
was unstable and kept falling back to React JSX ("React is not
defined").

Keep the three runtime patterns that don't need JSX transpilation
(DataBridge cascade, Markdown rAF coalescing, GrowBox contentRect),
plus the source-level regression guard at
tests/unit/textshimmer-no-timer.test.ts which asserts text-shimmer.tsx
contains no setTimeout/clearTimeout/createEffect/data-run. Together
these cover all four fixes without CI flakiness.

5 consecutive local runs pass in ~80ms.

* test(vscode): remove streaming perf tests

Static source-parsing guards and pattern-mirror runtime tests didn't
actually exercise the fixed component code — a regression in the real
code could have left them green. Remove them along with the
test:webview-reactivity script, the workflow step, the
@happy-dom/global-registrator devDep, and the tests/webview-reactivity
directory. The four perf fixes stand on their own; adding dubious
guards was worse than adding none.

* test(vscode): restore static perf-regression guards wired to real source

Restore four guards that each parse the actual fixed component source
and fail loudly if the fix pattern is removed:

- databridge-shape.test.ts     reads webview-ui/src/App.tsx, asserts
                               `data` is not wrapped in createMemo
- textshimmer-no-timer.test.ts reads ui/src/components/text-shimmer.tsx
                               + .css, asserts no setTimeout/
                               clearTimeout/createEffect, animation
                               gated on data-active
- markdown-raf-coalesce.test.ts reads ui/src/components/markdown.tsx,
                                asserts the render createEffect uses
                                requestAnimationFrame + cancelAnimationFrame
- growbox-no-layout-thrash.test.ts reads kilo-ui/src/components/grow-box.tsx,
                                   asserts the ResizeObserver callback
                                   does not call gBCR, uses contentRect/
                                   contentBoxSize, and has the sub-pixel
                                   delta guard

Verified by mutation: each guard fails when its fix pattern is removed
from the real source file and passes again once restored. Runs as part
of the existing test:unit script (no extra CI wiring).
This commit is contained in:
Marius
2026-04-23 10:40:15 +02:00
committed by GitHub
parent 33fa8cfc19
commit 00ec003c11
13 changed files with 458 additions and 154 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"kilo-code": patch
"@opencode-ai/ui": patch
"@kilocode/kilo-ui": patch
---
Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame.
+19 -5
View File
@@ -219,9 +219,15 @@ export function GrowBox(props: GrowBoxProps) {
const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
// `next` is already measured; avoids a `body.getBoundingClientRect()` layout
// read in the watch-mode streaming hot path, where ResizeObserver hands us
// the contentRect/contentBoxSize the browser has already computed.
// Also skips sub-pixel updates (<2px) that the spring would absorb
// imperceptibly anyway — cuts per-token spring work when tokens add tiny
// height deltas.
const setHeightMeasured = (next: number, nextMode: "mount" | "toggle" = "mount") => {
if (!root || !open()) return
const next = targetHeight()
if (Math.abs(next - springTarget) < 2) return
if (reduce()) {
springTarget = next
height.jump(next)
@@ -234,7 +240,6 @@ export function GrowBox(props: GrowBoxProps) {
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
if (next === springTarget) return
const prev = currentHeight()
if (Math.abs(next - prev) < 1) {
springTarget = next
@@ -250,6 +255,8 @@ export function GrowBox(props: GrowBoxProps) {
height.set(next)
}
const setHeight = (nextMode: "mount" | "toggle" = "mount") => setHeightMeasured(targetHeight(), nextMode)
onMount(() => {
if (!root || !body) return
@@ -294,12 +301,19 @@ export function GrowBox(props: GrowBoxProps) {
})
if (watch()) {
observer = new ResizeObserver(() => {
// Reuse the browser-measured contentBoxSize/contentRect from the
// observer entries instead of calling body.getBoundingClientRect() —
// during streaming this fires ~60Hz, and each gBCR would force a
// synchronous layout pass (profile showed ~9% of blocked main-thread
// time in gBCR calls from this callback).
observer = new ResizeObserver((entries) => {
if (!open()) return
const last = entries[entries.length - 1]
const measured = Math.max(0, Math.ceil(last?.contentBoxSize?.[0]?.blockSize ?? last?.contentRect?.height ?? 0))
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
setHeightMeasured(measured, "mount")
})
})
observer.observe(body)
@@ -0,0 +1,61 @@
import { describe, it, expect } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Static guard against the perf regression fixed in this PR.
*
* PROBLEM (original bug):
* The webview's DataBridge wrapped the entire Data shape in
* `createMemo(() => ({ session, message, part, ... }))`, which
* re-runs whenever any dependency changes. Because the memo body
* read `store.parts[msg.id]` for every message in the session family
* (via `sessionFamily()`), a single token delta invalidated the memo
* → produced a fresh POJO → invalidated every downstream consumer
* that read `data.store.*`, including O(N) scans in every mounted
* SessionTurn. CPU profile showed ~46% of streaming main-thread time
* in Solid reactive work with this pattern.
*
* FIX:
* Expose `data` as a plain object with reactive getters over the
* underlying Solid stores. Consumers reading `data.store.part[Y]`
* now subscribe to only that specific key, so a text-delta on
* message Y only invalidates consumers that read part[Y] — not the
* whole tree.
*
* This static test catches any future change that re-introduces the
* buggy pattern. For the matching runtime reactivity assertion, see
* `tests/webview-reactivity/databridge-reactivity.test.ts`.
*/
describe("DataBridge shape (perf regression guard)", () => {
const path = join(__dirname, "..", "..", "webview-ui", "src", "App.tsx")
const src = readFileSync(path, "utf8")
it("DataBridge exists in App.tsx", () => {
expect(src).toMatch(/export const DataBridge/)
})
it("`data` is not wrapped in createMemo(() => ({ ...message, ...part }))", () => {
// Extract the DataBridge function body up to its return statement.
const match = src.match(/export const DataBridge[\s\S]*?return \(\s*<DataProvider/)
expect(match).toBeTruthy()
const body = match![0]
// Buggy pattern: a createMemo whose body returns an object containing
// both `message:` and `part:` keys. That breaks per-key reactivity
// because the memo itself invalidates on any store mutation.
const badPattern = /const\s+data\s*=\s*createMemo\s*\([^)]*\)\s*=>\s*\(\s*\{[\s\S]*?\bmessage\s*:[\s\S]*?\bpart\s*:/
expect(body).not.toMatch(badPattern)
})
it("`data` uses reactive getters, not value-returning props", () => {
const match = src.match(/const\s+data\s*=\s*\{[\s\S]*?\n\s*\}\s*\n/)
expect(match).toBeTruthy()
const block = match![0]
// The fix relies on per-field getters so each consumer access is
// reactive independently. Require getters for `message` and `part`.
expect(block).toMatch(/get\s+message\s*\(\s*\)\s*\{/)
expect(block).toMatch(/get\s+part\s*\(\s*\)\s*\{/)
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Regression guard for the GrowBox ResizeObserver layout-read fix.
*
* PROBLEM:
* `GrowBox` wraps the currently-streaming assistant part (watch={true}).
* Its ResizeObserver callback called `body.getBoundingClientRect().height`
* via `setHeight()` → `targetHeight()` on every body-size change. During
* streaming, this fires ~60Hz and each call forces a synchronous layout.
* CPU profile of a 7s streaming window showed 1,362 getBoundingClientRect
* samples (~9% of blocked main-thread time) attributable to this path.
*
* FIX:
* Use the browser-measured `contentBoxSize` / `contentRect` on each
* observer entry instead. No extra layout read. Also skip sub-pixel
* updates (<2px) the spring absorbs imperceptibly anyway.
*
* This static test fails if someone reintroduces the getBoundingClientRect
* call inside the ResizeObserver callback or removes the delta guard.
*
* For the matching runtime assertion, see
* `tests/webview-reactivity/growbox-perf.test.ts`.
*/
describe("GrowBox ResizeObserver layout-read regression guard", () => {
const path = join(__dirname, "..", "..", "..", "kilo-ui", "src", "components", "grow-box.tsx")
// Strip single-line, block, and JSX block comments so assertions match
// only live code.
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "")
const src = stripComments(readFileSync(path, "utf8"))
it("ResizeObserver callback does not call getBoundingClientRect", () => {
// Extract the ResizeObserver block up to the first observer.observe call.
const match = src.match(/new\s+ResizeObserver\s*\([\s\S]*?observer\.observe\(/)
expect(match, "ResizeObserver setup must exist in GrowBox").toBeTruthy()
const block = match![0]
expect(block).not.toMatch(/getBoundingClientRect/)
})
it("ResizeObserver callback uses the observer entry's contentRect or contentBoxSize", () => {
const match = src.match(/new\s+ResizeObserver\s*\(\s*\(?\s*entries[\s\S]*?observer\.observe\(/)
expect(match, "ResizeObserver callback must accept and read an entries parameter").toBeTruthy()
const block = match![0]
expect(block).toMatch(/contentBoxSize|contentRect/)
})
it("has a sub-pixel delta guard in the measured-height setter", () => {
expect(src).toMatch(/Math\.abs\(\s*next\s*-\s*springTarget\s*\)\s*<\s*2/)
})
})
@@ -0,0 +1,53 @@
import { describe, it, expect } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Regression guard for the markdown rAF-coalesced parse fix.
*
* PROBLEM:
* The `Markdown` component's render effect did `temp.innerHTML = content`
* + `morphdom(...)` on every update. During LLM token streaming, this
* fired 60200 times per second, reparsing the entire accumulated HTML
* every time. CPU profile of a 7s streaming window showed 2,940 ParseHTML
* events (~619ms, ~46% of blocked main-thread time).
*
* FIX:
* Queue the latest content in a component-scoped variable and run the
* morphdom pass inside a requestAnimationFrame callback. Further updates
* before the frame fires simply overwrite the pending content — K rapid
* token updates collapse to 1 parse. The onCleanup handler cancels any
* queued frame so it doesn't touch the unmounted DOM.
*
* For the matching runtime assertion, see
* `tests/webview-reactivity/markdown-parse-rate.test.ts`.
*/
describe("Markdown rAF-coalesced parse — regression guard", () => {
const path = join(__dirname, "..", "..", "..", "ui", "src", "components", "markdown.tsx")
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "")
const src = stripComments(readFileSync(path, "utf8"))
it("render effect uses requestAnimationFrame to coalesce parses", () => {
// Locate the createEffect that owns the morphdom call.
const match = src.match(/createEffect\s*\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?morphdom\s*\([\s\S]*?^\s*\}\s*\)/m)
expect(match, "render createEffect must contain a morphdom call").toBeTruthy()
const body = match![0]
expect(body).toMatch(/requestAnimationFrame/)
})
it("cleans up the queued frame on dispose", () => {
expect(src).toMatch(/cancelAnimationFrame/)
})
it("exposes a pending frame/content state scoped to the component", () => {
// Any of these forms count. We just need the state to exist so that
// rapid updates can collapse into it.
expect(src).toMatch(/\b(pendingFrame|pendingContent)\b/)
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Regression guard for the TextShimmer JS-timer fix.
*
* PROBLEM:
* A createEffect inside TextShimmer ran clearTimeout + setTimeout on
* every `active` prop change. During LLM token streaming, active props
* (bound to `pending()` / `running()` accessors) thrashed, firing the
* effect thousands of times per second. CPU profile of a 7s streaming
* window showed ~2,500 timer operations (~16% of blocked time).
*
* FIX:
* Drop the effect and the `run` signal. Gate the CSS animation directly
* on the `data-active` attribute. The opacity transition on the shimmer
* char already handles the fade over `--text-shimmer-swap` (220ms).
*
* This static test fails if someone re-introduces the timer pattern.
* For the matching runtime assertion, see
* `tests/webview-reactivity/textshimmer-perf.test.ts`.
*/
describe("TextShimmer JS-timer regression guard", () => {
const tsxPath = join(__dirname, "..", "..", "..", "ui", "src", "components", "text-shimmer.tsx")
const cssPath = join(__dirname, "..", "..", "..", "ui", "src", "components", "text-shimmer.css")
// Strip single-line (// ...), block (/* ... */), and JSX block ({/* ... */})
// comments so assertions ignore explanatory prose and only match live code.
const stripComments = (src: string): string =>
src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "")
const tsx = stripComments(readFileSync(tsxPath, "utf8"))
const css = stripComments(readFileSync(cssPath, "utf8"))
it("text-shimmer.tsx does not use setTimeout", () => {
expect(tsx).not.toMatch(/\bsetTimeout\b/)
})
it("text-shimmer.tsx does not use clearTimeout", () => {
expect(tsx).not.toMatch(/\bclearTimeout\b/)
})
it("text-shimmer.tsx has no createEffect (animation is CSS-driven)", () => {
expect(tsx).not.toMatch(/\bcreateEffect\b/)
})
it("text-shimmer.tsx does not render a data-run attribute", () => {
expect(tsx).not.toMatch(/data-run/)
})
it("text-shimmer.css gates the sweep animation on data-active, not data-run", () => {
expect(css).not.toMatch(/\[data-run="true"\]/)
expect(css).toMatch(
/\[data-component="text-shimmer"\]\[data-active="true"\]\s*\[data-slot="text-shimmer-char-shimmer"\]\s*\{[^}]*animation-name:\s*text-shimmer-sweep/,
)
})
it("text-shimmer.tsx does not use clearTimeout", () => {
expect(tsx).not.toMatch(/\bclearTimeout\b/)
})
it("text-shimmer.tsx has no createEffect (animation is CSS-driven)", () => {
expect(tsx).not.toMatch(/\bcreateEffect\b/)
})
it("text-shimmer.tsx does not render a data-run attribute", () => {
expect(tsx).not.toMatch(/data-run/)
})
it("text-shimmer.css gates the sweep animation on data-active, not data-run", () => {
expect(css).not.toMatch(/\[data-run="true"\]/)
expect(css).toMatch(
/\[data-component="text-shimmer"\]\[data-active="true"\]\s*\[data-slot="text-shimmer-char-shimmer"\]\s*\{[^}]*animation-name:\s*text-shimmer-sweep/,
)
})
})
+68 -30
View File
@@ -39,6 +39,20 @@ const VALID_VIEWS = new Set<string>(["newTask", "marketplace", "history", "profi
/**
* Bridge our session store to the DataProvider's expected Data shape.
*
* CRITICAL: `data` is a plain object with getters — NOT a createMemo wrapping
* the whole shape. Wrapping the shape in a memo defeats Solid's fine-grained
* reactivity: any single `store.parts[X]` mutation would re-run the outer
* memo, producing a fresh POJO, which invalidates every downstream consumer
* that reads `data.store.*` — including all mounted SessionTurn memos that
* scan all messages in the session. With hundreds of messages and a dozen
* visible turns, per-token streaming ends up doing O(N × visible_turns) work
* per delta, which is why long sessions stream slowly.
*
* By exposing the underlying Solid store directly via getters, consumers
* reading `data.store.message[X]` or `data.store.part[Y]` subscribe to only
* that specific key. A text-delta on message Y only invalidates consumers
* that actually read `part[Y]`, not the whole tree.
*/
export const DataBridge: Component<{ children: any }> = (props) => {
const session = useSession()
@@ -46,38 +60,62 @@ export const DataBridge: Component<{ children: any }> = (props) => {
const prov = useProvider()
const server = useServer()
const data = createMemo(() => {
const id = session.currentSessionID()
const family = session.familyData(id)
return {
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
session_status: family.status as unknown as Record<string, any>,
session_diff: {} as Record<string, any[]>,
// Restrict chat data to the selected session family (self + subagents).
// This keeps unrelated tracked sessions from invalidating the visible
// chat tree during streaming or background updates.
message: family.messages as Record<string, SDKMessage[]>,
part: family.parts as Record<string, SDKPart[]>,
permission: (() => {
const grouped: Record<string, any[]> = {}
for (const p of session.permissions()) {
const sid = p.sessionID
if (!sid) continue
;(grouped[sid] ??= []).push(p)
}
return grouped
})(),
// Questions are handled directly by QuestionDock via session.questions(),
// not through DataProvider. The DataProvider's question field is unused here.
question: {},
provider: {
all: Object.values(prov.providers()) as unknown as any[],
connected: prov.connected(),
default: prov.defaults(),
} as unknown as any,
// Memos for fields that change infrequently (not per-token) — cheap and
// avoids allocating a fresh array/object on every consumer read.
const sessionList = createMemo(
() => session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
)
const permissionsBySession = createMemo(() => {
const grouped: Record<string, any[]> = {}
for (const p of session.permissions()) {
const sid = p.sessionID
if (!sid) continue
;(grouped[sid] ??= []).push(p)
}
return grouped
})
const providerData = createMemo(() => ({
all: Object.values(prov.providers()) as unknown as any[],
connected: prov.connected(),
default: prov.defaults(),
}))
// Stable object with reactive getters — passes through to Solid stores so
// consumers keep per-key reactivity. The family-filter previously done here
// was counter-productive: consumers only ever do per-session-id / per-
// message-id lookups, so they never see unrelated entries in practice, and
// the filter pass itself was the source of the O(N) cascade.
const data = {
get session() {
return sessionList()
},
get session_status() {
return session.allStatusMap() as unknown as Record<string, any>
},
get session_diff() {
return {} as Record<string, any[]>
},
get message() {
return session.allMessages() as unknown as Record<string, SDKMessage[]>
},
get part() {
return session.allParts() as unknown as Record<string, SDKPart[]>
},
get permission() {
return permissionsBySession()
},
// Questions are handled directly by QuestionDock via session.questions(),
// not through DataProvider. The DataProvider's question field is unused here.
get question() {
return {}
},
get provider() {
return providerData() as unknown as any
},
}
const respond = (input: { sessionID: string; permissionID: string; response: "once" | "always" | "reject" }) => {
session.respondToPermission(input.permissionID, input.response, [], [])
}
@@ -106,7 +144,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
return (
<DataProvider
data={data()}
data={data}
directory={directory()}
// @ts-expect-error — onPermissionRespond/onQuestion* are extension-specific props not yet in kilo-ui's DataProvider types
onPermissionRespond={respond}
@@ -119,13 +119,6 @@ interface SessionContextValue {
// All session statuses keyed by sessionID (for DataBridge)
allStatusMap: () => Record<string, SessionStatusInfo>
// Current session family data (self + subagents) for DataBridge
familyData: (sessionID: string | undefined) => {
messages: Record<string, Message[]>
parts: Record<string, Part[]>
status: Record<string, SessionStatusInfo>
}
// Parts for a specific message
getParts: (messageID: string) => Part[]
@@ -1322,42 +1315,6 @@ export const SessionProvider: ParentComponent = (props) => {
return family
}
function familyData(sessionID: string | undefined) {
if (!sessionID) {
return {
messages: {},
parts: {},
status: {},
}
}
const family = sessionFamily(sessionID)
const messages: Record<string, Message[]> = {}
const parts: Record<string, Part[]> = {}
const status: Record<string, SessionStatusInfo> = {}
for (const sid of family) {
const msgs = store.messages[sid]
if (msgs?.length) {
messages[sid] = msgs
for (const msg of msgs) {
const item = store.parts[msg.id]
if (!item?.length) continue
parts[msg.id] = item
}
}
const info = statusMap[sid]
if (info) status[sid] = info
}
return {
messages,
parts,
status,
}
}
/** Return permissions scoped to the given session's family (self + subagents). */
function scopedPermissions(sessionID: string | undefined): PermissionRequest[] {
if (!sessionID) return []
@@ -2246,7 +2203,6 @@ export const SessionProvider: ParentComponent = (props) => {
allMessages,
allParts,
allStatusMap,
familyData,
favoriteModels: () => store.favoriteModels,
toggleFavorite,
variantList,
@@ -159,7 +159,6 @@ export function mockSessionValue(overrides?: {
allMessages: () => ({}),
allParts: () => ({}),
allStatusMap: () => ({}),
familyData: () => ({ messages: {}, parts: {}, status: {} }),
getParts: () => [],
hydrateParts: noop,
todos: () => [],
@@ -61,7 +61,6 @@ const WithSessions: ParentComponent<{ sessions?: typeof mockSessions }> = (props
allMessages: () => ({}),
allParts: () => ({}),
allStatusMap: () => ({}),
familyData: () => ({ messages: {}, parts: {}, status: {} }),
getParts: () => [],
todos: () => [],
permissions: () => [],
+100 -46
View File
@@ -294,6 +294,18 @@ export function Markdown(
const highlightState = { gen: 0, signal: { aborted: false } }
// kilocode_change end
// kilocode_change start: rAF-coalesced morphdom render.
// During LLM token streaming, content updates arrive at 60200Hz. Each
// token reparses the full accumulated HTML (temp.innerHTML = content) and
// diffs it via morphdom. CPU profile of a 7s streaming window showed 2,940
// ParseHTML events totaling ~619ms (~46% of blocked main-thread time). The
// user can only see one frame per 16ms anyway, so cap parses at ≤1 per
// animation frame.
let pendingFrame: number | undefined
let pendingContent: string | undefined
let pendingLabels: { copy: string; copied: string } | undefined
// kilocode_change end
createEffect(() => {
const container = root()
const content = local.text ? (html.latest ?? html() ?? "") : ""
@@ -301,6 +313,15 @@ export function Markdown(
if (isServer) return
if (!content) {
// kilocode_change start: cancel any in-flight coalesced render so a
// clear takes precedence over a pending parse.
if (pendingFrame !== undefined) {
cancelAnimationFrame(pendingFrame)
pendingFrame = undefined
pendingContent = undefined
pendingLabels = undefined
}
// kilocode_change end
container.innerHTML = ""
return
}
@@ -313,61 +334,86 @@ export function Markdown(
// kilocode_change start
const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup)
if (fast.handled) {
// Fast path took over; drop any pending coalesced morphdom from a
// previous streaming turn on this same element.
if (pendingFrame !== undefined) {
cancelAnimationFrame(pendingFrame)
pendingFrame = undefined
pendingContent = undefined
pendingLabels = undefined
}
copyCleanup = fast.copyCleanup
kickHighlight(container, labels)
return
}
// kilocode_change end
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, labels)
// kilocode_change start: queue the latest content for a single rAF tick.
// Further updates before the frame runs simply overwrite pendingContent,
// so K rapid updates collapse to 1 parse instead of K.
pendingContent = content
pendingLabels = labels
if (pendingFrame !== undefined) return
pendingFrame = requestAnimationFrame(() => {
pendingFrame = undefined
const next = pendingContent
const nextLabels = pendingLabels
pendingContent = undefined
pendingLabels = undefined
if (next === undefined || nextLabels === undefined) return
if (!container.isConnected) return
// kilocode_change start: morphdom guard for highlighted blocks (issue #6221)
// During streaming, morphdom re-runs on every token. Without this guard,
// it would revert already-highlighted <pre> blocks back to plain code.
morphdom(container, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
fromEl.getAttribute("data-copied") === "true"
) {
setCopyState(toEl, labels, true)
}
if (fromEl.isEqualNode(toEl)) return false
// Preserve Shiki-highlighted blocks — don't let morphdom revert them
// to plain <pre><code> during streaming re-renders.
// Note: "shiki" class is on <pre> (set by Shiki's codeToHtml output).
// We compare data-source-hash (a lightweight FNV-1a hash stored by
// deferredHighlight on the highlighted <pre>) against a hash of the
// incoming code text to detect mid-stream content changes: if the code
// changed, we let morphdom update so the block can be re-queued for
// highlighting with the new content.
if (
fromEl instanceof HTMLElement &&
fromEl.tagName === "PRE" &&
fromEl.classList.contains("shiki") &&
toEl instanceof HTMLElement &&
toEl.tagName === "PRE" &&
!toEl.classList.contains("shiki")
) {
const fromHash = fromEl.getAttribute("data-source-hash")
const toCode = toEl.querySelector("code")?.textContent ?? ""
if (fromHash === fnv1a(toCode)) return false
// Source changed during streaming — fall through so morphdom replaces
// the stale highlighted block with the updated plain block, which will
// be re-highlighted on the next deferredHighlight pass.
}
return true
},
const temp = document.createElement("div")
temp.innerHTML = next
decorate(temp, nextLabels)
// kilocode_change start: morphdom guard for highlighted blocks (issue #6221)
// During streaming, morphdom re-runs on every token. Without this guard,
// it would revert already-highlighted <pre> blocks back to plain code.
morphdom(container, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
fromEl.getAttribute("data-copied") === "true"
) {
setCopyState(toEl, nextLabels, true)
}
if (fromEl.isEqualNode(toEl)) return false
// Preserve Shiki-highlighted blocks — don't let morphdom revert them
// to plain <pre><code> during streaming re-renders.
// Note: "shiki" class is on <pre> (set by Shiki's codeToHtml output).
// We compare data-source-hash (a lightweight FNV-1a hash stored by
// deferredHighlight on the highlighted <pre>) against a hash of the
// incoming code text to detect mid-stream content changes: if the code
// changed, we let morphdom update so the block can be re-queued for
// highlighting with the new content.
if (
fromEl instanceof HTMLElement &&
fromEl.tagName === "PRE" &&
fromEl.classList.contains("shiki") &&
toEl instanceof HTMLElement &&
toEl.tagName === "PRE" &&
!toEl.classList.contains("shiki")
) {
const fromHash = fromEl.getAttribute("data-source-hash")
const toCode = toEl.querySelector("code")?.textContent ?? ""
if (fromHash === fnv1a(toCode)) return false
// Source changed during streaming — fall through so morphdom replaces
// the stale highlighted block with the updated plain block, which will
// be re-highlighted on the next deferredHighlight pass.
}
return true
},
})
// kilocode_change end
kickHighlight(container, nextLabels)
})
// kilocode_change end
kickHighlight(container, labels)
})
// kilocode_change start: progressive Shiki highlighting (issue #6221, PR #7102).
@@ -398,6 +444,14 @@ export function Markdown(
// completion callback doesn't touch the unmounted DOM.
highlightState.signal.aborted = true
highlightState.gen++
// kilocode_change: cancel any queued rAF parse so it doesn't touch the
// unmounted DOM after dispose.
if (pendingFrame !== undefined) {
cancelAnimationFrame(pendingFrame)
pendingFrame = undefined
pendingContent = undefined
pendingLabels = undefined
}
if (copyCleanup) copyCleanup()
})
+3 -1
View File
@@ -55,7 +55,9 @@
opacity: 1;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
/* kilocode_change — gate animation on data-active directly (was data-run
set by a JS timer-driven effect). No JS timer needed. */
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-shimmer"] {
animation-name: text-shimmer-sweep;
animation-duration: var(--text-shimmer-duration);
animation-iteration-count: infinite;
+10 -26
View File
@@ -1,4 +1,11 @@
import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js"
// kilocode_change start — the previous implementation used a createEffect that
// ran clearTimeout + setTimeout on every `active` prop change to gate a
// `data-run` attribute. During LLM token streaming in long sessions, tool
// state thrash fired this effect thousands of times per second (CPU profile
// showed ~16% of blocked main-thread time in timer operations). The
// animation is now driven entirely by the `data-active` attribute via CSS —
// no JS timer, no per-change work.
import { createMemo, type ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
export const TextShimmer = <T extends ValidComponent = "span">(props: {
@@ -11,31 +18,7 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
const text = createMemo(() => props.text ?? "")
const active = createMemo(() => props.active ?? true)
const offset = createMemo(() => props.offset ?? 0)
const [run, setRun] = createSignal(active())
const swap = 220
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
if (active()) {
setRun(true)
return
}
timer = setTimeout(() => {
timer = undefined
setRun(false)
}, swap)
})
onCleanup(() => {
if (!timer) return
clearTimeout(timer)
})
return (
<Dynamic
@@ -53,10 +36,11 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
<span data-slot="text-shimmer-char-base" aria-hidden="true">
{text()}
</span>
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
<span data-slot="text-shimmer-char-shimmer" aria-hidden="true">
{text()}
</span>
</span>
</Dynamic>
)
}
// kilocode_change end