Merge pull request #11433 from Kilo-Org/candy-mouth

fix(agent-manager): stabilize concurrent subagent rendering
This commit is contained in:
Marius
2026-06-19 12:00:50 +02:00
committed by GitHub
11 changed files with 239 additions and 14 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent concurrent subagent updates from blanking the Agent Manager webview.
+10 -1
View File
@@ -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=="],
+1
View File
@@ -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",
@@ -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")
@@ -0,0 +1,72 @@
import { describe, expect, it } from "bun:test"
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", () => {
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"
)
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" },
}
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" },
)
const output = result.stdout.toString() + result.stderr.toString()
expect(result.exitCode, output).toBe(0)
})
})
@@ -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;
@@ -332,6 +332,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
suggestions={standaloneSuggestions}
readonly={props.readonly}
emptyState={props.emptyState}
announce={isSidebar()}
/>
</div>
</div>
@@ -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<MessageListProps> = (props) => {
@@ -73,6 +75,19 @@ export const MessageList: Component<MessageListProps> = (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<MessageListProps> = (props) => {
return (
<div class="message-list-container">
<Show when={props.announce === false}>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{announcement()}
</div>
</Show>
<Show when={isEmpty()}>
<div class="welcome-header">
<AccountSwitcher class="account-switcher-welcome" />
<KiloNotifications />
</div>
</Show>
<div ref={setScrollRef} onScroll={handleScroll} class="message-list" role="log" aria-live="polite">
<div
ref={setScrollRef}
onScroll={handleScroll}
class="message-list"
role={props.announce === false ? undefined : "log"}
aria-live={props.announce === false ? undefined : "polite"}
aria-busy={props.announce === false && session.status() !== "idle" ? "true" : undefined}
>
<div ref={autoScroll.contentRef} class={isEmpty() ? "message-list-content-empty" : "message-list-content"}>
<Show when={session.loading()}>
<div class="message-list-loading" role="status">
@@ -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<ToolProps> = (props) => {
</div>
</Show>
<Show when={result()}>{(text) => <Markdown text={text()} />}</Show>
<For each={childToolParts()}>
<Index each={childToolParts()}>
{(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<ToolProps> = (props) => {
</div>
)
}}
</For>
</Index>
</div>
</div>
</BasicTool>
@@ -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,
@@ -57,6 +57,7 @@ import {
buildCostBreakdown,
buildSessionToolParts,
childID,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
upsertSessionToolPart,
@@ -1290,12 +1291,16 @@ export const SessionProvider: ParentComponent = (props) => {
return true
}
function setTools(sessionID: string, tools: ToolPart[]) {
setStore("toolParts", sessionID, reconcileSessionToolParts(tools))
}
function rebuildToolParts(sessionID: string, messages: Message[], parts?: Record<string, Part[]>) {
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<string, Part[]> {
@@ -1310,16 +1315,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(