diff --git a/.changeset/subagent-avatars.md b/.changeset/subagent-avatars.md new file mode 100644 index 000000000000..f495b8f425df --- /dev/null +++ b/.changeset/subagent-avatars.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Identify subagents with consistent theme-colored avatars in Task cards, background agents, subagent tabs, and swarm messages. Animate running avatars instead of showing a separate loading indicator. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-background-agents-1280-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-background-agents-1280-chromium-linux.png index 64afb32dda6e..64534c09cd01 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-background-agents-1280-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-background-agents-1280-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a356afcc1d81da64ec5b498840abeadc77c44bb2da9cebbb3ffff99dd7183d6 -size 9020 +oid sha256:16da0a97cf729959168dd4ebec51dce6cf47ee9f681d601247c775bc38830f63 +size 12601 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-single-background-agent-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-single-background-agent-420-chromium-linux.png index e7c1a5fc3b59..78a10205a983 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-single-background-agent-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-single-background-agent-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebd62d9dda76da0d8e430ed7c81e5cb66c3233bc6193f4aa077bd1d6d53fbef6 -size 5370 +oid sha256:c5777d424590ad3fd22cb1a782ea84e1269ff04203377798922627e40063a408 +size 7227 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-200-chromium-linux.png index 50311aaf6f78..b392a38b65f5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-200-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-200-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba38eac2577fa87faa72731583f5d6b0681a4636f77e247c896989c732d43fc9 -size 37024 +oid sha256:9df99cbf6043b0444aac6ef3bde54d60649d99b30a14a147ab4f512cfc9dd0ff +size 40030 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-chromium-linux.png index 3e58381a089f..fc8a25d1365a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-messages-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d4302f30f282fe7d5b5094c01991c9729a75a7c31f1040324bc5bccfa469102 -size 39172 +oid sha256:48b1142f018593b8cf0f9164ae5b1181f626fd4eedd5d3811db7dbf72edb7035 +size 42533 diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 81d67922830c..771bd1e17078 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -26,6 +26,7 @@ "./tabs": "./src/components/tabs.tsx", "./card": "./src/components/card.tsx", "./avatar": "./src/components/avatar.tsx", + "./agent-avatar": "./src/components/agent-avatar.tsx", "./logo": "./src/components/logo.tsx", "./favicon": "./src/components/favicon.tsx", "./file-icon": "./src/components/file-icon.tsx", diff --git a/packages/kilo-ui/src/components/agent-avatar-identity.test.ts b/packages/kilo-ui/src/components/agent-avatar-identity.test.ts new file mode 100644 index 000000000000..a7659ab1fa53 --- /dev/null +++ b/packages/kilo-ui/src/components/agent-avatar-identity.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { COLORS, identity, palette } from "./agent-avatar-identity" + +describe("agent avatar identity", () => { + test("uses the same identity for the same participant", () => { + expect(identity("ses_agent-one")).toEqual(identity("ses_agent-one")) + expect(identity("ses_agent-one").cells).not.toEqual(identity("ses_agent-two").cells) + }) + + test("keeps unknown participants neutral", () => { + expect(identity("").color).toBeUndefined() + expect(identity("unknown")).toEqual(identity("")) + expect(identity(" ")).toEqual(identity("")) + expect(identity("main").color).toBeNumber() + }) + + test("produces visible symmetric patterns within the avatar", () => { + for (const id of ["main", "ses_agent-one", "ses_agent-two", "participant", ""]) { + const avatar = identity(id) + expect(avatar.cells.length).toBeGreaterThan(0) + for (const cell of avatar.cells) { + expect(cell).toBeGreaterThanOrEqual(0) + expect(cell).toBeLessThan(25) + expect([0, 4, 20, 24]).not.toContain(cell) + expect(avatar.cells).toContain(Math.floor(cell / 5) * 5 + 4 - (cell % 5)) + } + } + }) + + test("gives the first siblings distinct colors and keeps earlier assignments stable", () => { + const ids = Array.from({ length: COLORS + 3 }, (_, index) => `ses_sibling_${index}`) + const colors = palette(ids) + expect(new Set(ids.slice(0, COLORS).map((id) => colors.get(id))).size).toBe(COLORS) + for (const id of ids) expect(colors.get(id)).toBeNumber() + // Adding a later sibling never changes the color of an earlier one. + const fewer = palette(ids.slice(0, 5)) + for (const id of ids.slice(0, 5)) expect(fewer.get(id)).toBe(colors.get(id)) + expect(palette(["", "unknown", "ses_x"]).get("")).toBeUndefined() + }) + + test("draws one connected glyph with a bounded size", () => { + for (let index = 0; index < 200; index++) { + const cells = identity(`ses_${index}`).cells + const lit = new Set(cells) + expect(cells.length).toBeGreaterThanOrEqual(7) + expect(cells.length).toBeLessThanOrEqual(18) + const seen = new Set([cells[0]]) + const queue = [cells[0]] + while (queue.length > 0) { + const cell = queue.pop()! + const near = [cell - 5, cell + 5, cell % 5 > 0 ? cell - 1 : -1, cell % 5 < 4 ? cell + 1 : -1] + for (const next of near) { + if (!lit.has(next) || seen.has(next)) continue + seen.add(next) + queue.push(next) + } + } + expect(seen.size).toBe(cells.length) + } + }) +}) diff --git a/packages/kilo-ui/src/components/agent-avatar-identity.ts b/packages/kilo-ui/src/components/agent-avatar-identity.ts new file mode 100644 index 000000000000..8e37a28178d1 --- /dev/null +++ b/packages/kilo-ui/src/components/agent-avatar-identity.ts @@ -0,0 +1,77 @@ +export const COLORS = 8 +const HALF = 15 + +/** + * Assign colors to sibling agents in spawn order. Each agent keeps its hashed + * color when it is still free; otherwise it takes the next free hue, so the + * first eight siblings never share a color. After that, colors repeat and the + * glyph shape is what tells agents apart. + */ +export function palette(ids: string[]) { + const used = new Set() + const result = new Map() + for (const id of ids) { + if (result.has(id)) continue + const color = identity(id).color + if (color == null) continue + if (used.size >= COLORS) used.clear() + const pick = Array.from({ length: COLORS }, (_, index) => (color + index) % COLORS).find((hue) => !used.has(hue)) + if (pick == null) continue + used.add(pick) + result.set(id, pick) + } + return result +} +const MIN = 6 +const MAX = 9 + +function fnv(input: string, seed: number) { + let hash = seed + for (let index = 0; index < input.length; index++) { + hash = Math.imul(hash ^ input.charCodeAt(index), 16777619) >>> 0 + } + return hash +} + +// Mirror three columns into a five-column grid, like the loading spinner grid. +// The half-cell index is row * 3 + min(column, 4 - column). +function expand(half: (index: number) => boolean) { + return Array.from({ length: 25 }, (_, index) => index).filter((index) => { + const x = index % 5 + return half(Math.floor(index / 5) * 3 + Math.min(x, 4 - x)) + }) +} + +export function identity(id: string) { + const known = id.trim() !== "" && id !== "unknown" + if (!known) { + const neutral = new Set([4, 7, 10, 13]) + return { color: undefined, cells: expand((index) => neutral.has(index)) } + } + const shape = fnv(id, 2166136261) + const tone = fnv(id, 0x9747b28c) + // Grow one connected shape from a center-column seed so the glyph reads as a + // single figure instead of scattered dots. The bounded count avoids + // near-empty and near-full blobs that look alike. + const count = MIN + (tone % (MAX - MIN + 1)) + const rank = (index: number) => Math.imul(shape ^ (index + 1), 0x27d4eb2d) >>> 0 + const lit = new Set([2 + (shape % 5) * 3]) + while (lit.size < count) { + const edge = Array.from({ length: HALF }, (_, index) => index).filter((index) => { + if (lit.has(index)) return false + // Half-cells 0 and 12 are the grid corners, which the round avatar does not draw. + if (index === 0 || index === 12) return false + const row = Math.floor(index / 3) + const col = index % 3 + return ( + (col > 0 && lit.has(index - 1)) || + (col < 2 && lit.has(index + 1)) || + (row > 0 && lit.has(index - 3)) || + (row < 4 && lit.has(index + 3)) + ) + }) + const next = edge.reduce((best, index) => (rank(index) > rank(best) ? index : best)) + lit.add(next) + } + return { color: (tone >>> 4) % COLORS, cells: expand((index) => lit.has(index)) } +} diff --git a/packages/kilo-ui/src/components/agent-avatar.css b/packages/kilo-ui/src/components/agent-avatar.css new file mode 100644 index 000000000000..0173758adf7a --- /dev/null +++ b/packages/kilo-ui/src/components/agent-avatar.css @@ -0,0 +1,87 @@ +[data-component="agent-avatar"] { + --agent-avatar-a: var(--icon-weak-base); + --agent-avatar-b: var(--icon-weak-base); + display: inline-block; + flex: 0 0 18px; + width: 18px; + height: 18px; + vertical-align: middle; + color: color-mix(in oklch, var(--agent-avatar-a), var(--agent-avatar-b)); + fill: currentColor; + + /* Unlit dots stay faintly visible, like the spinner's outer ring, but low + enough that the lit glyph stays readable. */ + circle { + opacity: 0.1; + } + + circle[data-lit] { + opacity: 1; + } + + /* While running, the glyph stays solid and the faint frame dots shimmer + around it, so the symbol remains recognizable. */ + &[data-status="running"] circle:not([data-lit]) { + animation: agent-avatar-pulse 1.4s ease-in-out infinite both; + } + + /* Eight well-separated hues: the six VS Code chart colors plus teal and pink, + ordered so neighbors in the palette are far apart in hue. Siblings take + colors in this order, so the first eight agents never share one. */ + &[data-color="0"] { + --agent-avatar-a: var(--vscode-charts-blue, var(--icon-info-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="1"] { + --agent-avatar-a: var(--vscode-charts-orange, var(--icon-warning-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="2"] { + --agent-avatar-a: var(--vscode-charts-green, var(--icon-success-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="3"] { + --agent-avatar-a: var(--vscode-charts-purple, var(--icon-info-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="4"] { + --agent-avatar-a: var(--vscode-charts-yellow, var(--icon-warning-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="5"] { + --agent-avatar-a: var(--vscode-charts-green, var(--icon-success-base)); + --agent-avatar-b: var(--vscode-charts-blue, var(--icon-info-base)); + } + + &[data-color="6"] { + --agent-avatar-a: var(--vscode-charts-red, var(--icon-critical-base)); + --agent-avatar-b: var(--agent-avatar-a); + } + + &[data-color="7"] { + --agent-avatar-a: var(--vscode-charts-purple, var(--icon-info-base)); + --agent-avatar-b: var(--vscode-charts-red, var(--icon-critical-base)); + } +} + +@keyframes agent-avatar-pulse { + 0%, + 100% { + opacity: 0.1; + } + 50% { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="agent-avatar"][data-status="running"] circle:not([data-lit]) { + animation: none; + opacity: 0.25; + } +} diff --git a/packages/kilo-ui/src/components/agent-avatar.tsx b/packages/kilo-ui/src/components/agent-avatar.tsx new file mode 100644 index 000000000000..628a5b26cda5 --- /dev/null +++ b/packages/kilo-ui/src/components/agent-avatar.tsx @@ -0,0 +1,56 @@ +import { createContext, createMemo, For, useContext, type Accessor, type JSX } from "solid-js" +import { identity, palette } from "./agent-avatar-identity" + +export type AgentAvatarStatus = "running" + +// Map a tool part status to the only avatar state that changes its rendering. +// Finished, errored, cancelled, and waiting children all keep the static glyph. +export function taskStatus(status: string | undefined): AgentAvatarStatus | undefined { + return status === "pending" || status === "running" ? "running" : undefined +} + +// Sibling-aware colors. Surfaces that know the child list of one parent session +// provide it here, so the same agent gets the same color in every surface and +// siblings avoid sharing a color until the palette runs out. +const Palette = createContext>>() + +export function AgentAvatarPalette(props: { ids: string[]; children: JSX.Element }) { + const parent = useContext(Palette) + const value = createMemo(() => palette(props.ids)) + // The outermost provider wins so nested transcripts keep the parent's colors. + return {props.children} +} + +// Corner cells are dropped so the dot grid reads as a circle. +const GRID = Array.from({ length: 25 }, (_, index) => index).filter((index) => ![0, 4, 20, 24].includes(index)) + +// Same cell geometry as the loading spinner, drawn as round dots on a 1px gap grid. +export function AgentAvatar(props: { id: string; status?: AgentAvatarStatus }) { + const shared = useContext(Palette) + const avatar = createMemo(() => identity(props.id)) + const color = createMemo(() => shared?.().get(props.id) ?? avatar().color) + const lit = createMemo(() => new Set(avatar().cells)) + return ( + + ) +} diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 23224ffb4b15..49714f6bb4ce 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -4,6 +4,11 @@ flex: 1 1 auto; min-width: 0; overflow: hidden; + + /* Variant B status badges intentionally extend into the existing gap. */ + &:has([data-component="agent-avatar"]) { + overflow: visible; + } } [data-slot="basic-tool-tool-info"] { @@ -36,6 +41,14 @@ [data-slot="basic-tool-icon"] { display: none; + + &:has([data-component="agent-avatar"]) { + display: inline-flex; + flex: 0 0 18px; + width: 18px; + height: 18px; + overflow: visible; + } } [data-slot="basic-tool-tool-subtitle"] { diff --git a/packages/kilo-ui/src/components/board-message.css b/packages/kilo-ui/src/components/board-message.css index 6e97fefade52..90ae47cda290 100644 --- a/packages/kilo-ui/src/components/board-message.css +++ b/packages/kilo-ui/src/components/board-message.css @@ -31,6 +31,16 @@ [data-slot="board-route-recipient-icon"] { display: inline-flex; } + + /* Parent session marker: the spinner grid, static, in the avatar slot size. */ + .board-route-parent { + width: 18px; + color: var(--text-weak); + + > rect { + animation: none !important; + } + } } [data-component="board-messages"] { @@ -66,7 +76,7 @@ @container (max-width: 260px) { [data-component="board-route"] { display: grid; - grid-template-columns: 16px auto minmax(0, 1fr); + grid-template-columns: 18px auto minmax(0, 1fr); gap: 4px 6px; .board-route-member { diff --git a/packages/kilo-ui/src/components/board-message.tsx b/packages/kilo-ui/src/components/board-message.tsx index a5575ddeca76..862fa727fbef 100644 --- a/packages/kilo-ui/src/components/board-message.tsx +++ b/packages/kilo-ui/src/components/board-message.tsx @@ -1,9 +1,19 @@ import { Show } from "solid-js" import { useI18n } from "../context/i18n" import { Icon } from "./icon" +import { AgentAvatar } from "./agent-avatar" import { Markdown } from "./markdown" import { Tooltip } from "./tooltip" +// The parent session keeps the plain spinner grid; only subagents get a glyph. +function Member(props: { id: string }) { + return ( + }> + + + ) +} + type Route = { from?: unknown; to?: unknown; fromLabel?: unknown; toLabel?: unknown } export function BoardRoute(props: Route) { @@ -35,7 +45,7 @@ export function BoardRoute(props: Route) { role="group" aria-label={i18n.t("ui.messagePart.board.route", { from: sender(), to: recipient() })} > - + - - + }> + diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ed539bb61e02..01379f802c0b 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -36,6 +36,7 @@ import { useClipboard } from "../context/clipboard" import { type UiI18n, useI18n } from "../context/i18n" import { BasicTool, useToolApprovalLine } from "./basic-tool" import { BoardMessage, BoardRoute } from "./board-message" +import { AgentAvatar, taskStatus } from "./agent-avatar" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" @@ -2169,9 +2170,9 @@ ToolRegistry.register({ animate={props.reveal} onClick={data.openFile ? () => data.openFile!(filepath) : undefined} /> - )} + )} - 0}> + 0}>
{(file) => ( @@ -2457,6 +2458,7 @@ ToolRegistry.register({ hideDetails approvalPlacement="hidden" icon="task" + iconNode={} status={props.status} trigger={trigger()} animated @@ -2904,24 +2906,26 @@ ToolRegistry.register({ const diffs = files().flatMap((file) => { const diff = view(file) return diff - ? [{ - file: file.relativePath, - patch: diff.patch, - status: - file.type === "add" - ? ("added" as const) - : file.type === "delete" - ? ("deleted" as const) - : ("modified" as const), - additions: - file.type === "add" && diff.additions === 0 - ? diff.fileDiff.hunks.reduce((sum, hunk) => sum + hunk.additionLines, 0) - : diff.additions, - deletions: - file.type === "delete" && diff.deletions === 0 - ? diff.fileDiff.hunks.reduce((sum, hunk) => sum + hunk.deletionLines, 0) - : diff.deletions, - }] + ? [ + { + file: file.relativePath, + patch: diff.patch, + status: + file.type === "add" + ? ("added" as const) + : file.type === "delete" + ? ("deleted" as const) + : ("modified" as const), + additions: + file.type === "add" && diff.additions === 0 + ? diff.fileDiff.hunks.reduce((sum, hunk) => sum + hunk.additionLines, 0) + : diff.additions, + deletions: + file.type === "delete" && diff.deletions === 0 + ? diff.fileDiff.hunks.reduce((sum, hunk) => sum + hunk.deletionLines, 0) + : diff.deletions, + }, + ] : [] }) const first = diffs[0] @@ -3070,11 +3074,7 @@ ToolRegistry.register({ {`\u2066${getDirectory(file.relativePath)}\u2069`} - - {getFilename(file.relativePath)} - + {getFilename(file.relativePath)}
diff --git a/packages/kilo-ui/src/styles/index.css b/packages/kilo-ui/src/styles/index.css index 2b3687ac3406..4814bb1feecc 100644 --- a/packages/kilo-ui/src/styles/index.css +++ b/packages/kilo-ui/src/styles/index.css @@ -6,6 +6,7 @@ /* Per-component Kilo overrides */ @import "../components/accordion.css"; +@import "../components/agent-avatar.css"; @import "../components/basic-tool.css"; @import "../components/board-message.css"; @import "../components/auto-approve-bar.css"; diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 8b615d6561a7..2dd1e447f9e7 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -73,7 +73,7 @@ test.describe("webview accessibility ratchet", () => { await expect(list).toBeHidden() }) - test("Background agents preserve running spinners and collapse after completion", async ({ page }) => { + test("Background agents preserve running avatars and collapse after completion", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }) await page.clock.install() await page.clock.pauseAt(new Date()) @@ -117,15 +117,15 @@ test.describe("webview accessibility ratchet", () => { const row = list.locator('[data-slot="task-header-agent"]') await expect(row).toContainText("Background agent 1") const node = await row.elementHandle() - const spinner = await row.locator('[data-component="spinner"]').elementHandle() - expect(spinner).not.toBeNull() + const avatar = await row.locator('[data-component="agent-avatar"]').elementHandle() + expect(avatar).not.toBeNull() for (const revision of [2, 3]) { await page.clock.runFor(1000) await expect(row).toContainText(`Background agent ${revision}`) await expect(row).toHaveAttribute("data-status", "running") expect(await node!.evaluate((node) => node.isConnected)).toBe(true) - expect(await spinner!.evaluate((node) => node.isConnected)).toBe(true) + expect(await avatar!.evaluate((node) => node.isConnected)).toBe(true) } await row.locator('[data-slot="task-header-agent-main"]').focus() @@ -138,8 +138,7 @@ test.describe("webview accessibility ratchet", () => { await expect(preview).toHaveAttribute("aria-hidden", "false") await expect(preview).toHaveAccessibleName("Open background agent: Background agent 4 (Done)") await expect(preview).toHaveAttribute("title", "Open background agent: Background agent 4 (Done)") - await expect(preview.locator('[data-component="icon"]')).toBeVisible() - await expect(preview.locator('[data-component="icon"] use')).toHaveAttribute("href", "#opencode-icon-circle-check") + await expect(preview.locator('[data-component="agent-avatar"]')).toBeVisible() await expect(agents.locator('[data-component="spinner"]')).toHaveCount(0) await toggle.click() @@ -204,10 +203,10 @@ test.describe("webview accessibility ratchet", () => { const clear = agents.getByRole("button", { name: "Clear finished", exact: true }) await expect(clear).toHaveText("") await expect(clear).toHaveAttribute("title", "Clear finished") - for (const [status, label, icon] of [ - ["completed", "Done", "circle-check"], - ["cancelled", "Cancelled", "circle-ban-sign"], - ["error", "Error", "warning"], + for (const [status, label] of [ + ["completed", "Done"], + ["cancelled", "Cancelled"], + ["error", "Error"], ] as const) { const item = agents.locator(`[data-slot="task-header-agents-item"][data-status="${status}"]`) const name = `Open background agent: Background agent ${status} (${label})` @@ -215,8 +214,7 @@ test.describe("webview accessibility ratchet", () => { await expect(item).toHaveText(`Background agent ${status}`) await expect(item).toHaveAccessibleName(name) await expect(item).toHaveAttribute("title", name) - await expect(item.locator('[data-component="icon"]')).toBeVisible() - await expect(item.locator('[data-component="icon"] use')).toHaveAttribute("href", `#opencode-icon-${icon}`) + await expect(item.locator('[data-component="agent-avatar"]')).toBeVisible() } await expect(agents.locator('[data-component="spinner"]')).toHaveCount(0) @@ -239,7 +237,7 @@ test.describe("webview accessibility ratchet", () => { await expect(items).toHaveCount(4) await expect(items.first()).toHaveAttribute("data-status", "running") await expect(items.first()).toHaveAttribute("aria-hidden", "false") - await expect(items.first().locator('[data-component="spinner"]')).toBeVisible() + await expect(items.first().locator('[data-component="agent-avatar"]')).toHaveAttribute("data-status", "running") await expect(agents.locator('[data-slot="task-header-agents-overflow"]')).toHaveAttribute("aria-hidden", "false") }) diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 10d29b96f9bd..c474583f5d31 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -276,10 +276,17 @@ const check = async (id: string, expected: string) => { const actual = state(id) if (id === "root") card(expected) const tab = host.querySelector(`[data-tab-id="${id}"] [data-activity]`) - if (id === "root" || id === "background" || (inspector() && (id === "task-child" || id === "task-grand"))) { + if (id === "root" || id === "background") { assert(tab, `Missing rendered tab for ${id}`) assert.equal(!!tab.querySelector('[data-component="spinner"]'), expected === "busy" || expected === "retry") } + if (inspector() && (id === "task-child" || id === "task-grand")) { + assert(tab, `Missing rendered subagent tab for ${id}`) + assert.equal( + !!tab.querySelector('[data-component="agent-avatar"]')?.getAttribute("data-status"), + expected === "busy" || expected === "retry", + ) + } if (tab && (id === "task-child" || id === "task-grand")) { assert.equal(tab.querySelector(".am-tab-icon")?.getAttribute("data-activity"), expected) assert.equal(!!tab.querySelector(".am-tab-icon")?.getAttribute("aria-label"), expected !== "idle") diff --git a/packages/kilo-vscode/tests/swarm-board.spec.ts b/packages/kilo-vscode/tests/swarm-board.spec.ts index 898051efaf81..0a0463062883 100644 --- a/packages/kilo-vscode/tests/swarm-board.spec.ts +++ b/packages/kilo-vscode/tests/swarm-board.spec.ts @@ -106,7 +106,9 @@ test("uses a distinct icon and fills short histories without manual paging", asy const rows = page.locator('.task-board-list [data-slot="board-message"]') await expect(rows.last().getByRole("group", { name: "Agent to All agents" })).toBeVisible() await expect(rows.last().locator('[data-slot="board-route-recipient-icon"] [data-component="icon"]')).toHaveCount(2) - await expect(rows.first().locator('[data-slot="board-route-recipient-icon"] [data-component="icon"]')).toHaveCount(1) + await expect( + rows.first().locator('[data-slot="board-route-recipient-icon"] [data-component="agent-avatar"]'), + ).toHaveCount(1) await expect(rows.last().locator("strong")).toHaveText("formatted") await expect(rows.last().locator("code")).toHaveText("code") expect(icon).not.toBe(await rows.first().locator('[data-component="board-route"] svg').first().innerHTML()) @@ -188,7 +190,7 @@ for (const width of [420, 200]) { await expect(routes).toHaveCount(2) await expect(routes.first()).toHaveCSS("display", width === 200 ? "grid" : "flex") await expect( - routes.first().locator('[data-slot="board-route-recipient-icon"] [data-component="icon"]'), + routes.first().locator('[data-slot="board-route-recipient-icon"] [data-component="agent-avatar"]'), ).toHaveCount(1) await expect(routes.last().locator('[data-slot="board-route-recipient-icon"] [data-component="icon"]')).toHaveCount( 2, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 1502ed9d34ad..89c412d41885 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -6,11 +6,12 @@ */ import { Icon } from "@kilocode/kilo-ui/icon" +import { AgentAvatar, AgentAvatarPalette } from "@kilocode/kilo-ui/agent-avatar" import { IconButton } from "@kilocode/kilo-ui/icon-button" 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" +import { children } from "../src/components/chat/background-agents" import { useLanguage } from "../src/context/language" import { SessionProvider, useSession, useSessionVisibility } from "../src/context/session" import { description, label, type Activity } from "../src/utils/session-activity" @@ -104,7 +105,7 @@ const SubagentContent: Component Activity }> tooltip={() => (state() === "idle" ? name : `${name}: ${language.t(description(state()))}`)} icon="task" iconNode={ - } spinner="am-tab-spinner" /> + } state={state()} stateLabel={state() === "idle" ? undefined : language.t(label(state()))} @@ -139,9 +140,16 @@ const SubagentContent: Component Activity }> export const SubagentPanel: Component = (props) => { const session = useSession() useSessionVisibility(() => (props.visible() ? props.active() : undefined)) + // Colors follow the parent's spawn order so tabs match the parent transcript. + const siblings = createMemo(() => { + const id = session.currentSessionID() + return id ? children(session.getSessionToolParts(id)) : [] + }) return ( - - - + + + + + ) } 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 7978f988827f..ec363fb5a03b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1520,6 +1520,10 @@ html[data-theme="kilo-vscode"] color: currentColor; } +.am-subagent-panel .am-tab-icon:has([data-component="agent-avatar"]) { + overflow: visible; +} + .am-tab-icon[data-run-status="success"] { color: var(--vscode-testing-iconPassed, #34d399); } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx index fded16433704..7393fed3e8d2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx @@ -12,6 +12,7 @@ import { Component, For, Show, createMemo, createSignal, onCleanup, onMount, createEffect, on } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" +import { AgentAvatar } from "@kilocode/kilo-ui/agent-avatar" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" @@ -236,15 +237,8 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { aria-expanded={open()} onClick={() => setOpen((value) => !value)} > - 0} - fallback={ - }> - {(name) => } - - } - > - + }> + {(name) => } {caption()} @@ -264,16 +258,7 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { aria-label={tooltip(agent())} onClick={() => openAgent(agent())} > - }> - {(name) => } - - } - > - - + {label(agent())} )} @@ -305,7 +290,6 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { ref={toggle} variant="ghost" size="small" - icon={waiting() > 0 ? "warning" : undefined} aria-label={accessible()} title={accessible()} aria-expanded={open()} @@ -351,7 +335,6 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => {
agent.permission || agent.question)}>
- {language.t("task.backgroundAgents.waiting")}
@@ -360,9 +343,7 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { agent.jobID === id)}> {(agent) => (
- }> - {(name) => } - +