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

Prevent concurrent subagent updates from blanking the Agent Manager webview.
11 changes: 10 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions packages/kilo-vscode/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
72 changes: 72 additions & 0 deletions packages/kilo-vscode/tests/unit/task-tool-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
41 changes: 41 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
suggestions={standaloneSuggestions}
readonly={props.readonly}
emptyState={props.emptyState}
announce={isSidebar()}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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()
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
Expand All @@ -162,7 +160,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
</div>
)
}}
</For>
</Index>
</div>
</div>
</BasicTool>
Expand Down
5 changes: 5 additions & 0 deletions packages/kilo-vscode/webview-ui/src/context/session-utils.ts
Original file line number Diff line number Diff line change
@@ -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..."
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading