Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
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
51 changes: 51 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,51 @@
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 child tool proxy 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"

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) => {
Comment thread
marius-kilocode marked this conversation as resolved.
Outdated
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" },
)

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
14 changes: 10 additions & 4 deletions packages/kilo-vscode/webview-ui/src/context/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
buildCostBreakdown,
buildSessionToolParts,
childID,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
upsertSessionToolPart,
Expand Down Expand Up @@ -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[]> {
Expand All @@ -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(
Expand Down
Loading