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

Improve live subagent transcript performance in editor tabs and Agent Manager, especially with many parallel agents and shared-board messages.
3 changes: 2 additions & 1 deletion packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1287,7 +1287,7 @@ function McpTool(props: ToolProps) {
})

const formattedOutput = createMemo(() => {
if (!props.output) return undefined
if (messages() || !props.output) return undefined
try {
const parsed = JSON.parse(props.output)
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"
Expand All @@ -1303,6 +1303,7 @@ function McpTool(props: ToolProps) {
>
<BasicTool
icon={board() ? "task" : "mcp"}
defer={board()}
status={props.status}
tool={props.tool}
partID={props.partID}
Expand Down
155 changes: 155 additions & 0 deletions packages/kilo-vscode/tests/fixtures/board-tool-render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import assert from "node:assert/strict"
import { Window } from "happy-dom"
import type { AssistantMessage, ToolPart } from "@kilocode/sdk/v2"

const window = new Window({ url: "http://localhost" })
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
HTMLAnchorElement: window.HTMLAnchorElement,
HTMLButtonElement: window.HTMLButtonElement,
HTMLDivElement: window.HTMLDivElement,
HTMLPreElement: window.HTMLPreElement,
SVGElement: window.SVGElement,
MutationObserver: window.MutationObserver,
ResizeObserver: window.ResizeObserver,
CustomEvent: window.CustomEvent,
Event: window.Event,
MouseEvent: window.MouseEvent,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
getComputedStyle: window.getComputedStyle.bind(window),
})

const { createSignal } = await import("solid-js")
const { createStore } = await import("solid-js/store")
const { render } = await import("solid-js/web")
const { Part } = await import("@kilocode/kilo-ui/message-part")
const { MarkedProvider, createMarkedParser } = await import("@kilocode/kilo-ui/context/marked")

const labels = ["initial", "hidden", "latest", "reopened", "search", "search-updated"]
const outputs = labels.map((label) =>
JSON.stringify({
messages: [
{ from: "worker", to: "main", fromLabel: `Worker ${label}`, toLabel: "Coordinator", body: `**${label}** body` },
],
hasMore: false,
}),
)
const message: AssistantMessage = {
id: "assistant",
sessionID: "child",
role: "assistant",
parentID: "prompt",
modelID: "test",
providerID: "test",
mode: "code",
agent: "code",
path: { cwd: "/test", root: "/test" },
time: { created: 1, completed: 2 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const [part, setPart] = createStore({
id: "board-read",
sessionID: message.sessionID,
messageID: message.id,
type: "tool",
callID: "board-read-call",
tool: "board_read",
state: {
status: "completed",
input: { limit: 1 },
output: outputs.at(0)!,
metadata: {},
title: "Board messages",
time: { start: 1, end: 2 },
},
} satisfies ToolPart)
const [search, setSearch] = createSignal(false)
const parsed: string[] = []
const decoded: string[] = []
const parser = createMarkedParser({})
const decode = JSON.parse
JSON.parse = (text, reviver) => {
if (outputs.includes(text)) decoded.push(text)
return decode(text, reviver)
}
const root = document.createElement("div")
document.body.append(root)
const dispose = render(
() => (
<MarkedProvider
nativeParser={async (text) => {
parsed.push(text)
return parser.parse(text)
}}
>
<Part part={part} message={message} forceOpen={search()} />
</MarkedProvider>
),
root,
)
const settle = async () => {
await Promise.resolve()
await window.happyDOM.waitUntilComplete()
}
const trigger = () => {
const button = root.querySelector<HTMLButtonElement>('[data-slot="collapsible-trigger"]')
assert(button)
return button
}
const update = async (index: number) => {
setPart("state", "output", outputs.at(index)!)
await settle()
}
const visible = (label: string) => {
assert.equal(trigger().getAttribute("aria-expanded"), "true")
assert.equal(root.querySelector('[data-slot="board-message-body"] strong')?.textContent, label)
assert.equal(root.querySelector(".board-route-sender")?.textContent, `Worker ${label}`)
assert.equal(root.querySelector(".board-route-recipient")?.textContent, "Coordinator")
assert(parsed.includes(`**${label}** body`))
}

try {
await settle()
for (const index of [0, 1, 2]) {
if (index) await update(index)
assert.equal(trigger().getAttribute("aria-expanded"), "false")
assert.equal(root.querySelector('[data-component="board-messages"]'), null)
assert.equal(root.querySelector('[data-component="markdown"]'), null)
assert.deepEqual(parsed, [])
assert.deepEqual(decoded, outputs.slice(0, index + 1))
}

trigger().click()
await settle()
visible("latest")
assert.deepEqual(parsed, ["**latest** body"])

trigger().click()
await settle()
await update(3)
trigger().click()
await settle()
visible("reopened")

trigger().click()
await settle()
await update(4)
setSearch(true)
await settle()
visible("search")
await update(5)
visible("search-updated")
assert.deepEqual(decoded, outputs)
} finally {
dispose()
JSON.parse = decode
await window.happyDOM.cancelAsync()
await window.happyDOM.close()
}
100 changes: 99 additions & 1 deletion packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ Object.assign(globalThis, {
})

const { render } = await import("solid-js/web")
const { For, Show, createEffect, createSignal } = await import("solid-js")
const { For, Show, createEffect, createRoot, createSignal } = await import("solid-js")
const { unwrap } = await import("solid-js/store")
const { WorktreeItem } = await import("../../webview-ui/agent-manager/WorktreeItem")
const { SubagentPanel } = await import("../../webview-ui/agent-manager/SubagentPanel")
const { createSubagentController } = await import("../../webview-ui/agent-manager/subagent-tabs")
const { DragDropProvider, SortableProvider } = await import("@thisbeyond/solid-dnd")
const { renderTab } = await import("../../webview-ui/agent-manager/tab-rendering")
const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode")
Expand Down Expand Up @@ -1079,6 +1080,103 @@ try {
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
await check("root", "idle")

setInspector(false)
setInspected(["inspector-child", "inspector-sibling"])
setActive("inspector-child")
const start = sent.length
const loads = () => sent.slice(start).filter((message) => message.type === "loadMessages")
setInspector(true)
await settle()
assert.deepEqual(loads(), [
{ type: "loadMessages", sessionID: "inspector-child", mode: "replace", focus: false, limit: 80 },
])
for (const id of inspected()) {
await emit({ type: "messagesLoaded", sessionID: id, messages: [], mode: "replace" })
assert.equal(loads().length, 1, `${id} snapshot reloaded the selected inspector`)
for (const status of ["busy", "idle"] as const) {
await emit({ type: "sessionStatus", sessionID: id, status })
assert.equal(loads().length, 1, `${id} ${status} reloaded the selected inspector`)
assert.equal(active(), "inspector-child")
assert.equal(value.currentSessionID(), "root")
}
}
for (const id of ["inspector-sibling", "inspector-child"]) {
const tab = host.querySelector<HTMLElement>(`[data-tab-id="${id}"] [role="tab"]`)
assert(tab)
tab.click()
await settle()
assert.equal(active(), id)
assert.deepEqual(loads().at(-1), {
type: "loadMessages",
sessionID: id,
mode: "reconcile",
focus: false,
limit: 80,
})
assert.equal(value.currentSessionID(), "root")
}
assert.equal(loads().length, 3)
setInspector(false)
await settle()

const family = createRoot((dispose) => {
const state = { reads: 0 }
createEffect(() => {
value.scopedPermissions("root")
state.reads++
})
return { state, dispose }
})
try {
await settle()
assert.equal(family.state.reads, 1)
for (const id of inspected()) {
await emit({ type: "sessionStatus", sessionID: id, status: "busy" })
await emit({ type: "sessionStatus", sessionID: id, status: "busy" })
}
assert.equal(family.state.reads, 1, "Busy updates rebuilt unchanged session ancestry")
} finally {
family.dispose()
}

const opened = createRoot((dispose) => {
const [visible, setVisible] = createSignal(false)
const selected: (string | undefined)[] = []
const controller = createSubagentController({
project: () => undefined,
current: () => "root",
selection: () => null,
parts: () =>
inspected().map((id) => ({
id,
type: "tool",
tool: "task",
state: { status: "running", input: {} },
metadata: { sessionId: id },
})),
visible,
show: () => setVisible(true),
hide: () => setVisible(false),
sync: () => {},
unsync: () => {},
})
createEffect(() => selected.push(controller.tabs.active()))
return { ...controller, selected, dispose }
})
try {
await settle()
assert.deepEqual(opened.selected, [undefined])
opened.toolbar.toggle()
await settle()
assert.deepEqual(
opened.tabs.tabs().map((tab) => tab.id),
inspected(),
)
assert.deepEqual(opened.selected, [undefined, "inspector-sibling"])
} finally {
opened.dispose()
}

await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
await emit({
type: "suggestionRequest",
Expand Down
51 changes: 51 additions & 0 deletions packages/kilo-vscode/tests/unit/board-tool-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "bun:test"
import { unlinkSync } from "node:fs"
import path from "node:path"
import { build } from "esbuild"
import { solidPlugin } from "esbuild-plugin-solid"

const root = path.resolve(import.meta.dir, "../..")
const webview = path.join(root, "webview-ui")

describe("Board tool transcript rendering", () => {
it("defers collapsed bodies and shows current routes and Markdown when opened", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
"solid-js/web": path.join(solid, "web/dist/web.js"),
"solid-js/store": path.join(solid, "store/dist/store.js"),
}
const dedupe = {
name: "solid-dedupe",
setup(ctx: Parameters<NonNullable<Parameters<typeof build>[0]["plugins"]>[number]["setup"]>[0]) {
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
ctx.onResolve({ filter: /\?worker&url$/ }, (args) => ({ path: args.path, namespace: "worker-url" }))
ctx.onLoad({ filter: /.*/, namespace: "worker-url" }, () => ({
contents: "export default undefined",
loader: "js",
}))
},
}
const result = await build({
entryPoints: [path.join(root, "tests/fixtures/board-tool-render.tsx")],
bundle: true,
conditions: ["browser"],
external: ["happy-dom"],
format: "esm",
logLevel: "silent",
loader: { ".css": "empty", ".svg": "dataurl" },
platform: "node",
plugins: [dedupe, solidPlugin()],
target: "es2022",
write: false,
})
const file = path.join(root, `.board-tool-render-${crypto.randomUUID()}.mjs`)
await Bun.write(file, result.outputFiles.at(0)!.contents)
try {
const child = Bun.spawnSync(["bun", file], { cwd: webview, stdout: "pipe", stderr: "pipe" })
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
} finally {
unlinkSync(file)
}
}, 15_000)
})
13 changes: 7 additions & 6 deletions packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { createEffect, createMemo, type Accessor, type Component } from "solid-js"
import { createEffect, createMemo, on, type Accessor, type Component } from "solid-js"
import { DataBridge } from "../src/App"
import { ChatView } from "../src/components/chat"
import { ActivityIcon } from "../src/components/shared/ActivityIcon"
Expand All @@ -34,11 +34,12 @@ interface Props {
const SubagentChat: Component<{ active: Accessor<string | undefined> }> = (props) => {
const session = useSession()

createEffect(() => {
const id = props.active()
if (!id) return
session.selectSession(id, { focus: false })
})
createEffect(
on(props.active, (id) => {
if (!id) return
session.selectSession(id, { focus: false })
}),
)

return (
<DataBridge>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ export function createSubagentToolbar(opts: {
}
const id = opts.current()
if (!id) return
for (const tab of available()) opts.open(tab.id, tab.title, id)
batch(() => {
for (const tab of available()) opts.open(tab.id, tab.title, id)
})
}
createEffect(
on(
Expand Down
Loading
Loading