diff --git a/.changeset/fast-subagent-transcripts.md b/.changeset/fast-subagent-transcripts.md new file mode 100644 index 000000000000..9db283c90b47 --- /dev/null +++ b/.changeset/fast-subagent-transcripts.md @@ -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. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 151295dadda4..738165661979 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -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```" @@ -1303,6 +1303,7 @@ function McpTool(props: ToolProps) { > + 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( + () => ( + { + parsed.push(text) + return parser.parse(text) + }} + > + + + ), + root, +) +const settle = async () => { + await Promise.resolve() + await window.happyDOM.waitUntilComplete() +} +const trigger = () => { + const button = root.querySelector('[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() +} diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index b5d12e4ee82c..e7dd38445af4 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -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") @@ -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(`[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", diff --git a/packages/kilo-vscode/tests/unit/board-tool-render.test.ts b/packages/kilo-vscode/tests/unit/board-tool-render.test.ts new file mode 100644 index 000000000000..9840092ecde5 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/board-tool-render.test.ts @@ -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 = { + "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[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) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 763752d8f346..a70b7c401376 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -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" @@ -34,11 +34,12 @@ interface Props { const SubagentChat: Component<{ active: Accessor }> = (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 ( diff --git a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts index 03b5df55ac09..0f8c96c86f9d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/subagent-tabs.ts @@ -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( diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index e3e1ad4cd1ef..4598eb3c4432 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -174,6 +174,7 @@ export const SessionProvider: ParentComponent = (props) => { } const clearClose = (id: string) => { recoveries.delete(id) + if (!closeMap[id]) return setCloseMap( produce((map) => { delete map[id] @@ -2553,7 +2554,7 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "loadMessages", sessionID: id, - mode: "replace", + mode: ready ? "reconcile" : "replace", focus: false, limit: MESSAGE_PAGE_LIMIT, })