From 867beac3a19881861536d6c22a9efcb5ae379cc4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 17:30:39 +0200 Subject: [PATCH 1/4] fix(agent-manager): stabilize concurrent subagent rendering --- .../stabilize-agent-manager-transcripts.md | 5 ++ .../kilo-vscode/tests/accessibility.spec.ts | 60 +++++++++++++++++++ .../tests/unit/task-tool-identity.test.ts | 58 ++++++++++++++++++ .../agent-manager/agent-manager.css | 41 +++++++++++++ .../src/components/chat/ChatView.tsx | 1 + .../src/components/chat/MessageList.tsx | 29 ++++++++- .../src/components/chat/TaskToolExpanded.tsx | 14 ++--- .../webview-ui/src/context/session.tsx | 13 ++-- 8 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 .changeset/stabilize-agent-manager-transcripts.md create mode 100644 packages/kilo-vscode/tests/unit/task-tool-identity.test.ts diff --git a/.changeset/stabilize-agent-manager-transcripts.md b/.changeset/stabilize-agent-manager-transcripts.md new file mode 100644 index 00000000000..be190dce0ca --- /dev/null +++ b/.changeset/stabilize-agent-manager-transcripts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent concurrent subagent updates from blanking the Agent Manager webview. diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 2f1565ab4df..fead3c81f38 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -49,6 +49,66 @@ test.describe("webview accessibility ratchet", () => { }) } + test("Agent Manager keeps virtualized transcript fragments laid out", async ({ page }) => { + await open(page, "agentmanager--sidebar-search-open") + + const visibility = await page.locator("#storybook-root").evaluate((root) => { + const layout = document.createElement("div") + layout.className = "am-layout" + root.append(layout) + const names = ["assistant-message", "tool-trigger", "file", "code", "diff"] + const values = names.map((name) => { + const node = document.createElement("div") + node.dataset.component = name + layout.append(node) + return getComputedStyle(node).contentVisibility + }) + layout.remove() + return values + }) + + expect(visibility).toEqual(["visible", "visible", "visible", "visible", "visible"]) + }) + + test("Agent Manager avoids generated separator text in updating tool rows", async ({ page }) => { + await open(page, "agentmanager--sidebar-search-open") + + const content = await page.locator("#storybook-root").evaluate((root) => { + const layout = document.createElement("div") + layout.className = "am-layout" + const wrapper = document.createElement("div") + wrapper.dataset.component = "tool-part-wrapper" + wrapper.dataset.partType = "tool" + const collapsible = document.createElement("div") + collapsible.className = "tool-collapsible" + collapsible.dataset.component = "collapsible" + const title = document.createElement("span") + title.dataset.slot = "basic-tool-tool-title" + const subtitle = document.createElement("span") + subtitle.dataset.slot = "basic-tool-tool-subtitle" + collapsible.append(title, subtitle) + wrapper.append(collapsible) + layout.append(wrapper) + root.append(layout) + const value = getComputedStyle(subtitle, "::before").content + layout.remove() + return value + }) + + expect(content).toBe("none") + }) + + test("sidebar keeps transcript announcements while Agent Manager bounds them", async ({ page }) => { + await open(page, "chat--chat-view-with-messages") + await expect(page.locator(".message-list")).toHaveAttribute("role", "log") + await expect(page.locator(".message-list")).toHaveAttribute("aria-live", "polite") + + await open(page, "chat--chat-view-agent-manager-completed") + await expect(page.locator(".message-list")).not.toHaveAttribute("role") + await expect(page.locator(".message-list")).not.toHaveAttribute("aria-live") + await expect(page.locator('.sr-only[role="status"]')).toHaveAttribute("aria-live", "polite") + }) + test("Profile login exposes a keyboard-operable named control", async ({ page }) => { await open(page, "profile--not-logged-in") diff --git a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts new file mode 100644 index 00000000000..ae516186e72 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const SESSION = path.join(ROOT, "webview-ui/src/context/session.tsx") +const TASK = path.join(ROOT, "webview-ui/src/components/chat/TaskToolExpanded.tsx") + +describe("task tool index identity", () => { + it("connects keyed reconciliation to indexed child rows", () => { + const session = fs.readFileSync(SESSION, "utf8") + const task = fs.readFileSync(TASK, "utf8") + expect(session).toContain('setStore("toolParts", sessionID, reconcile(tools, { key: "id" }))') + expect(task).toContain("") + }) + + it("preserves a streamed child tool proxy across status updates", () => { + const result = Bun.spawnSync( + [ + "bun", + "--conditions=browser", + "-e", + ` + import { createRoot } from "solid-js" + import { createStore, reconcile } from "solid-js/store" + import { upsertSessionToolPart } from "./webview-ui/src/context/session-utils.ts" + + const running = { + id: "tool-1", + type: "tool", + tool: "read", + state: { status: "running", input: { filePath: "src/app.ts" }, title: "Reading" }, + } + const completed = { + id: "tool-1", + type: "tool", + tool: "read", + state: { status: "completed", input: { filePath: "src/app.ts" }, title: "Read", output: "done" }, + } + + createRoot((dispose) => { + const [store, setStore] = createStore({ tools: [running] }) + const row = store.tools[0] + const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) + setStore("tools", reconcile(tools, { key: "id" })) + if (store.tools[0] !== row) throw new Error("tool proxy was replaced") + if (store.tools[0].state.status !== "completed") throw new Error("tool proxy was not updated") + dispose() + }) + `, + ], + { cwd: ROOT, stdout: "pipe", stderr: "pipe" }, + ) + + const output = result.stdout.toString() + result.stderr.toString() + expect(result.exitCode, output).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 995e0443cf9..0bdd63ddee3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -7,6 +7,47 @@ overflow: hidden; } +/* Virtua already removes off-screen transcript rows. Avoid a second layer of + layout skipping, which can leave Chromium accessibility traversing fragments + whose layout items were discarded while Agent Manager scrolls. */ +.am-layout + :is( + [data-component="assistant-message"], + [data-component="tool-trigger"], + [data-component="file"], + [data-component="code"], + [data-component="diff"] + ) { + content-visibility: visible; +} + +/* Generated separators become accessibility text nodes. Keep them out of + concurrently updating Agent Manager tool rows while preserving sidebar UI. */ +html[data-theme="kilo-vscode"] + .am-layout + [data-component="tool-part-wrapper"][data-part-type="tool"] + [data-component="collapsible"] + [data-slot="basic-tool-tool-title"] + + :is( + [data-slot="basic-tool-tool-subtitle"], + [data-slot="basic-tool-tool-arg"], + [data-slot="message-part-meta-line"], + [data-slot="webfetch-meta"], + [data-component="shell-submessage"] + )::before, +html[data-theme="kilo-vscode"] + .am-layout + [data-component="tool-part-wrapper"][data-part-type="tool"] + [data-component="collapsible"] + [data-slot="message-part-title-text"] + + :is([data-slot="message-part-meta-line"], [data-slot="basic-tool-tool-subtitle"])::before, +html[data-theme="kilo-vscode"] + .am-layout + [data-component="tool-part-wrapper"][data-part-type="tool"] + [data-slot="context-tool-group-summary"]::before { + content: none; +} + .am-sidebar { position: relative; min-width: 200px; diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index ed2ce65d7b2..d9e69e6528d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -332,6 +332,7 @@ export const ChatView: Component = (props) => { suggestions={standaloneSuggestions} readonly={props.readonly} emptyState={props.emptyState} + announce={isSidebar()} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 19e8f81b969..6a80c9454a6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -63,6 +63,8 @@ interface MessageListProps { readonly?: boolean /** Optionally replace the standard welcome content while the conversation is empty. */ emptyState?: () => JSX.Element + /** Announce transcript changes as a live log. Disable for multi-session surfaces with concurrent streams. */ + announce?: boolean } export const MessageList: Component = (props) => { @@ -73,6 +75,19 @@ export const MessageList: Component = (props) => { const autoScroll = createAutoScroll({ working: () => session.status() !== "idle", }) + const [announcement, setAnnouncement] = createSignal("") + createEffect( + (prev: { sid?: string; working: boolean }) => { + const sid = session.currentSessionID() + const working = session.status() !== "idle" + if (working && (!prev.working || prev.sid !== sid)) setAnnouncement(language.t("session.status.working")) + if (!working && prev.working && prev.sid === sid) { + setAnnouncement(language.t("settings.agentBehaviour.editMode.save")) + } + return { sid, working } + }, + { sid: undefined, working: false }, + ) // Explicit output-producing actions resume auto-scroll before appending. const onResumeAutoScroll = () => autoScroll.resume() @@ -260,13 +275,25 @@ export const MessageList: Component = (props) => { return (
+ +
+ {announcement()} +
+
-
+
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 62162fda12e..a05380b2456 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -7,7 +7,7 @@ * Call registerExpandedTaskTool() once at app startup to activate. */ -import { Component, createEffect, createMemo, createSignal, For, Show, onCleanup } from "solid-js" +import { Component, createEffect, createMemo, createSignal, Index, Show, onCleanup } from "solid-js" import { ToolRegistry, ToolProps, getToolInfo } from "@kilocode/kilo-ui/message-part" import { BasicTool, initialOpen } from "@kilocode/kilo-ui/basic-tool" import { Icon } from "@kilocode/kilo-ui/icon" @@ -141,15 +141,13 @@ const TaskToolRenderer: Component = (props) => {
{(text) => } - + {(item) => { - const info = createMemo(() => getToolInfo(item.tool, item.state?.input)) + const info = createMemo(() => getToolInfo(item().tool, item().state?.input)) const subtitle = createMemo(() => { if (info().subtitle) return info().subtitle - const state = item.state as { status: string; title?: string } - if (state.status === "completed" || state.status === "running") { - return state.title - } + const state = item().state as { status: string; title?: string } + if (state.status === "completed" || state.status === "running") return state.title return undefined }) return ( @@ -162,7 +160,7 @@ const TaskToolRenderer: Component = (props) => {
) }} - +
diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 01788f5d667..b4e6de2a8f2 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1290,12 +1290,16 @@ export const SessionProvider: ParentComponent = (props) => { return true } + function setTools(sessionID: string, tools: ToolPart[]) { + setStore("toolParts", sessionID, reconcile(tools, { key: "id" })) + } + function rebuildToolParts(sessionID: string, messages: Message[], parts?: Record) { const tools = buildSessionToolParts( messages, (msg) => parts?.[msg.id] ?? store.parts[msg.id] ?? stash.peek(msg.id) ?? msg.parts, ) - setStore("toolParts", sessionID, tools) + setTools(sessionID, tools) } function messageParts(messages: Message[]): Record { @@ -1310,16 +1314,17 @@ export const SessionProvider: ParentComponent = (props) => { const sid = sessionID ?? part.sessionID if (!sid) return if (part.type !== "tool") return - setStore("toolParts", sid, (tools = []) => upsertSessionToolPart(tools, part, { id: messageID, sessionID: sid })) + const tools = upsertSessionToolPart(store.toolParts[sid] ?? [], part, { id: messageID, sessionID: sid }) + setTools(sid, tools) } function dropToolPart(sessionID: string | undefined, partID: string) { if (!sessionID) return - setStore("toolParts", sessionID, (tools = []) => removeSessionToolPart(tools, partID)) + setTools(sessionID, removeSessionToolPart(store.toolParts[sessionID] ?? [], partID)) } function dropMessageTools(sessionID: string, messageID: string) { - setStore("toolParts", sessionID, (tools = []) => removeSessionToolPartsForMessage(tools, messageID)) + setTools(sessionID, removeSessionToolPartsForMessage(store.toolParts[sessionID] ?? [], messageID)) } function handleMessagesLoaded( From 8e622255d5428030842857abd7d723ef47340a62 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 18:31:47 +0200 Subject: [PATCH 2/4] test(agent-manager): verify rendered tool identity --- .../tests/unit/task-tool-identity.test.ts | 62 ++++++++++++------- .../webview-ui/src/context/session-utils.ts | 5 ++ .../webview-ui/src/context/session.tsx | 3 +- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts index ae516186e72..acb6ccafe66 100644 --- a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts +++ b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts @@ -1,29 +1,29 @@ import { describe, expect, it } from "bun:test" -import fs from "node:fs" import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") -const SESSION = path.join(ROOT, "webview-ui/src/context/session.tsx") -const TASK = path.join(ROOT, "webview-ui/src/components/chat/TaskToolExpanded.tsx") describe("task tool index identity", () => { - it("connects keyed reconciliation to indexed child rows", () => { - const session = fs.readFileSync(SESSION, "utf8") - const task = fs.readFileSync(TASK, "utf8") - expect(session).toContain('setStore("toolParts", sessionID, reconcile(tools, { key: "id" }))') - expect(task).toContain("") - }) - - it("preserves a streamed child tool proxy across status updates", () => { + it("preserves a rendered child tool row across status updates", () => { const result = Bun.spawnSync( [ "bun", "--conditions=browser", "-e", ` - import { createRoot } from "solid-js" - import { createStore, reconcile } from "solid-js/store" - import { upsertSessionToolPart } from "./webview-ui/src/context/session-utils.ts" + import { Window } from "happy-dom" + + const window = new Window() + globalThis.window = window + globalThis.document = window.document + globalThis.Node = window.Node + + const { Index, createComponent, createRenderEffect } = await import("solid-js") + const { createStore } = await import("solid-js/store") + const { render } = await import("solid-js/web") + const { reconcileSessionToolParts, upsertSessionToolPart } = await import( + "./webview-ui/src/context/session-utils.ts" + ) const running = { id: "tool-1", @@ -37,16 +37,30 @@ describe("task tool index identity", () => { tool: "read", state: { status: "completed", input: { filePath: "src/app.ts" }, title: "Read", output: "done" }, } - - createRoot((dispose) => { - const [store, setStore] = createStore({ tools: [running] }) - const row = store.tools[0] - const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) - setStore("tools", reconcile(tools, { key: "id" })) - if (store.tools[0] !== row) throw new Error("tool proxy was replaced") - if (store.tools[0].state.status !== "completed") throw new Error("tool proxy was not updated") - dispose() - }) + const root = document.createElement("div") + const [store, setStore] = createStore({ tools: [running] }) + const dispose = render( + () => + createComponent(Index, { + get each() { + return store.tools + }, + children: (item) => { + const row = document.createElement("div") + createRenderEffect(() => { + row.textContent = item().state.status + }) + return row + }, + }), + root, + ) + const row = root.firstElementChild + const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) + setStore("tools", reconcileSessionToolParts(tools)) + if (root.firstElementChild !== row) throw new Error("rendered tool row was replaced") + if (row?.textContent !== "completed") throw new Error("rendered tool row was not updated") + dispose() `, ], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }, diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 9e7798119a7..ae448a3261b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -1,3 +1,4 @@ +import { reconcile } from "solid-js/store" import type { Message, Part, ToolPart } from "../types/messages" export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..." @@ -85,6 +86,10 @@ export function buildSessionToolParts( return tools } +export function reconcileSessionToolParts(tools: ToolPart[]) { + return reconcile(tools, { key: "id" }) +} + export function upsertSessionToolPart( current: ToolPart[], part: Part, diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index b4e6de2a8f2..6360afb01a0 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -57,6 +57,7 @@ import { buildCostBreakdown, buildSessionToolParts, childID, + reconcileSessionToolParts, removeSessionToolPart, removeSessionToolPartsForMessage, upsertSessionToolPart, @@ -1291,7 +1292,7 @@ export const SessionProvider: ParentComponent = (props) => { } function setTools(sessionID: string, tools: ToolPart[]) { - setStore("toolParts", sessionID, reconcile(tools, { key: "id" })) + setStore("toolParts", sessionID, reconcileSessionToolParts(tools)) } function rebuildToolParts(sessionID: string, messages: Message[], parts?: Record) { From 9d2c8965ec2528a0f576473a30789b66cbf9c075 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 18:37:59 +0200 Subject: [PATCH 3/4] test(agent-manager): remove implicit DOM dependency --- .../tests/unit/task-tool-identity.test.ts | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts index acb6ccafe66..2526322cd5f 100644 --- a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts +++ b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts @@ -4,26 +4,19 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") describe("task tool index identity", () => { - it("preserves a rendered child tool row across status updates", () => { + it("preserves a child tool proxy across status updates", () => { const result = Bun.spawnSync( [ "bun", "--conditions=browser", "-e", ` - import { Window } from "happy-dom" - - const window = new Window() - globalThis.window = window - globalThis.document = window.document - globalThis.Node = window.Node - - const { Index, createComponent, createRenderEffect } = await import("solid-js") - const { createStore } = await import("solid-js/store") - const { render } = await import("solid-js/web") - const { reconcileSessionToolParts, upsertSessionToolPart } = await import( - "./webview-ui/src/context/session-utils.ts" - ) + import { createRoot } from "solid-js" + import { createStore } from "solid-js/store" + import { + reconcileSessionToolParts, + upsertSessionToolPart, + } from "./webview-ui/src/context/session-utils.ts" const running = { id: "tool-1", @@ -37,30 +30,16 @@ describe("task tool index identity", () => { tool: "read", state: { status: "completed", input: { filePath: "src/app.ts" }, title: "Read", output: "done" }, } - const root = document.createElement("div") - const [store, setStore] = createStore({ tools: [running] }) - const dispose = render( - () => - createComponent(Index, { - get each() { - return store.tools - }, - children: (item) => { - const row = document.createElement("div") - createRenderEffect(() => { - row.textContent = item().state.status - }) - return row - }, - }), - root, - ) - const row = root.firstElementChild - const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) - setStore("tools", reconcileSessionToolParts(tools)) - if (root.firstElementChild !== row) throw new Error("rendered tool row was replaced") - if (row?.textContent !== "completed") throw new Error("rendered tool row was not updated") - dispose() + + createRoot((dispose) => { + const [store, setStore] = createStore({ tools: [running] }) + const row = store.tools[0] + const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) + setStore("tools", reconcileSessionToolParts(tools)) + if (store.tools[0] !== row) throw new Error("tool proxy was replaced") + if (row.state.status !== "completed") throw new Error("tool proxy was not updated") + dispose() + }) `, ], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }, From 2dcd90e1769d7823e3761e583746f904aa3b29e0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 18:47:04 +0200 Subject: [PATCH 4/4] test(agent-manager): cover rendered tool identity --- bun.lock | 11 +++- packages/kilo-vscode/package.json | 1 + .../tests/unit/task-tool-identity.test.ts | 55 +++++++++++++------ 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index 9695753def6..78bafbc5e60 100644 --- a/bun.lock +++ b/bun.lock @@ -354,6 +354,7 @@ "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "happy-dom": "20.8.9", "knip": "5.85.0", "prettier": "3.6.2", "qrcode": "^1.5.4", @@ -2204,6 +2205,8 @@ "@types/vscode": ["@types/vscode@1.120.0", "", {}, "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -2486,6 +2489,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3076,6 +3081,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.10.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -4270,7 +4277,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4630,6 +4637,8 @@ "cheerio/undici": ["undici@7.27.2", "", {}, "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA=="], + "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index b934755d50d..602dd43c24d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1100,6 +1100,7 @@ "esbuild-plugin-solid": "^0.6.0", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", + "happy-dom": "20.8.9", "knip": "5.85.0", "prettier": "3.6.2", "qrcode": "^1.5.4", diff --git a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts index 2526322cd5f..acb6ccafe66 100644 --- a/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts +++ b/packages/kilo-vscode/tests/unit/task-tool-identity.test.ts @@ -4,19 +4,26 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") describe("task tool index identity", () => { - it("preserves a child tool proxy across status updates", () => { + it("preserves a rendered child tool row across status updates", () => { const result = Bun.spawnSync( [ "bun", "--conditions=browser", "-e", ` - import { createRoot } from "solid-js" - import { createStore } from "solid-js/store" - import { - reconcileSessionToolParts, - upsertSessionToolPart, - } from "./webview-ui/src/context/session-utils.ts" + import { Window } from "happy-dom" + + const window = new Window() + globalThis.window = window + globalThis.document = window.document + globalThis.Node = window.Node + + const { Index, createComponent, createRenderEffect } = await import("solid-js") + const { createStore } = await import("solid-js/store") + const { render } = await import("solid-js/web") + const { reconcileSessionToolParts, upsertSessionToolPart } = await import( + "./webview-ui/src/context/session-utils.ts" + ) const running = { id: "tool-1", @@ -30,16 +37,30 @@ describe("task tool index identity", () => { tool: "read", state: { status: "completed", input: { filePath: "src/app.ts" }, title: "Read", output: "done" }, } - - createRoot((dispose) => { - const [store, setStore] = createStore({ tools: [running] }) - const row = store.tools[0] - const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) - setStore("tools", reconcileSessionToolParts(tools)) - if (store.tools[0] !== row) throw new Error("tool proxy was replaced") - if (row.state.status !== "completed") throw new Error("tool proxy was not updated") - dispose() - }) + const root = document.createElement("div") + const [store, setStore] = createStore({ tools: [running] }) + const dispose = render( + () => + createComponent(Index, { + get each() { + return store.tools + }, + children: (item) => { + const row = document.createElement("div") + createRenderEffect(() => { + row.textContent = item().state.status + }) + return row + }, + }), + root, + ) + const row = root.firstElementChild + const tools = upsertSessionToolPart(store.tools, completed, { id: "message-1", sessionID: "child-1" }) + setStore("tools", reconcileSessionToolParts(tools)) + if (root.firstElementChild !== row) throw new Error("rendered tool row was replaced") + if (row?.textContent !== "completed") throw new Error("rendered tool row was not updated") + dispose() `, ], { cwd: ROOT, stdout: "pipe", stderr: "pipe" },