diff --git a/.changeset/subagent-avatars.md b/.changeset/subagent-avatars.md index f495b8f425d..eb49f20aa42 100644 --- a/.changeset/subagent-avatars.md +++ b/.changeset/subagent-avatars.md @@ -2,4 +2,4 @@ "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. +Identify subagents with consistent theme-colored avatars in Task cards, background agents, subagent tabs, and swarm messages. Show all participants in board message headers, and animate running avatars instead of showing a separate loading indicator. 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 b392a38b65f..ea799ec7151 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:9df99cbf6043b0444aac6ef3bde54d60649d99b30a14a147ab4f512cfc9dd0ff -size 40030 +oid sha256:fea711f03eb18f2df4749a208a73b850178851bc131fe347b2616f84752c0cd7 +size 42704 diff --git a/packages/kilo-ui/src/components/agent-avatar.css b/packages/kilo-ui/src/components/agent-avatar.css index 0173758adf7..3dc6358ddf3 100644 --- a/packages/kilo-ui/src/components/agent-avatar.css +++ b/packages/kilo-ui/src/components/agent-avatar.css @@ -69,6 +69,23 @@ } } +[data-component="board-participant-stack"] { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + width: max-content; + height: 18px; + overflow: visible; + gap: 3px; + + > * { + position: relative; + flex: 0 0 18px; + width: 18px; + height: 18px; + } +} + @keyframes agent-avatar-pulse { 0%, 100% { diff --git a/packages/kilo-ui/src/components/agent-avatar.tsx b/packages/kilo-ui/src/components/agent-avatar.tsx index 628a5b26cda..eb8d21a1732 100644 --- a/packages/kilo-ui/src/components/agent-avatar.tsx +++ b/packages/kilo-ui/src/components/agent-avatar.tsx @@ -21,6 +21,11 @@ export function AgentAvatarPalette(props: { ids: string[]; children: JSX.Element return {props.children} } +export function useAgentAvatarIds() { + const shared = useContext(Palette) + return createMemo(() => (shared ? [...shared().keys()] : [])) +} + // 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)) diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 49714f6bb4c..06ff361834b 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -49,6 +49,15 @@ height: 18px; overflow: visible; } + + &:has([data-component="board-participant-stack"]) { + display: inline-flex; + flex: 0 0 auto; + width: auto; + min-width: 18px; + height: 18px; + overflow: visible; + } } [data-slot="basic-tool-tool-subtitle"] { diff --git a/packages/kilo-ui/src/components/board-message.tsx b/packages/kilo-ui/src/components/board-message.tsx index 862fa727fbe..00b8e7e1ff3 100644 --- a/packages/kilo-ui/src/components/board-message.tsx +++ b/packages/kilo-ui/src/components/board-message.tsx @@ -1,7 +1,7 @@ -import { Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { useI18n } from "../context/i18n" import { Icon } from "./icon" -import { AgentAvatar } from "./agent-avatar" +import { AgentAvatar, useAgentAvatarIds } from "./agent-avatar" import { Markdown } from "./markdown" import { Tooltip } from "./tooltip" @@ -14,13 +14,29 @@ function Member(props: { id: string }) { ) } +export function BoardParticipantStack(props: { ids: string[] }) { + return ( + + ) +} + type Route = { from?: unknown; to?: unknown; fromLabel?: unknown; toLabel?: unknown } export function BoardRoute(props: Route) { const i18n = useI18n() + const ids = useAgentAvatarIds() const text = (value: unknown) => (typeof value === "string" ? value : "") const from = () => text(props.from) const to = () => text(props.to) + const broadcast = createMemo(() => { + const values = ids().filter((id) => id !== "main" && id !== from()) + if (from() !== "main" && values.length > 0) values.unshift("main") + return values + }) const label = (id: string, value: unknown) => { if (id === "ALL") return i18n.t("ui.messagePart.board.all") const title = text(value) @@ -56,8 +72,17 @@ export function BoardRoute(props: Route) { }> - - + 0} + fallback={ + <> + + + + } + > + + { + const seen = new Set() + const ids: string[] = [] + for (const item of messages() ?? []) { + for (const id of [item.from, item.to]) { + if (!id || id === "ALL" || seen.has(id)) continue + seen.add(id) + ids.push(id) + } + } + const main = ids.indexOf("main") + if (main > 0) { + ids.splice(main, 1) + ids.unshift("main") + } + return ids + }) const trigger = () => { if (props.tool === "board_post") return ( @@ -1248,10 +1265,19 @@ function McpTool(props: ToolProps) { return ( } + fallback={ + : undefined} + status={props.status} + trigger={trigger()} + /> + } > : undefined} defer={board()} status={props.status} tool={props.tool} diff --git a/packages/kilo-ui/src/stories/message-part.stories.tsx b/packages/kilo-ui/src/stories/message-part.stories.tsx index cea172ffe6f..3c1bf353b52 100644 --- a/packages/kilo-ui/src/stories/message-part.stories.tsx +++ b/packages/kilo-ui/src/stories/message-part.stories.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" import { UserMessageDisplay, AssistantParts } from "../components/message-part" +import { AgentAvatarPalette } from "../components/agent-avatar" import { DataProvider } from "@opencode-ai/ui/context/data" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" @@ -274,6 +275,68 @@ const mockDataContextGroup = createMockData([completedToolPart, grepCompleted, g const mockDataEdit = createMockData([editCompletedPart]) const mockDataWrite = createMockData([writeCompletedPart]) +const boardReadPart: ToolPart = { + id: "part-board-read-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-board-read-001", + tool: "board_read", + state: { + status: "completed", + input: {}, + output: JSON.stringify({ + messages: [ + { + from: "main", + to: "worker", + fromLabel: "Coordinator", + toLabel: "Worker", + body: "**First message**", + }, + { + from: "worker", + to: "reviewer", + fromLabel: "Worker", + toLabel: "Reviewer", + body: "**Second message**", + }, + ], + hasMore: false, + }), + title: "Read agent messages 2", + metadata: {}, + time: { start: now - 4000, end: now - 3500 }, + }, +} + +const mockDataBoardRead = createMockData([boardReadPart]) + +const boardBroadcastPart: ToolPart = { + id: "part-board-broadcast-001", + sessionID: SESSION_ID, + messageID: ASST_MSG_ID, + type: "tool", + callID: "call-board-broadcast-001", + tool: "board_post", + state: { + status: "completed", + input: { to: "ALL", type: "INFO", body: "Broadcast update" }, + output: JSON.stringify({ + from: "main", + to: "ALL", + fromLabel: "Coordinator", + type: "INFO", + body: "Broadcast update", + }), + title: "INFO to ALL", + metadata: { from: "main", to: "ALL", fromLabel: "Coordinator" }, + time: { start: now - 3000, end: now - 2500 }, + }, +} + +const mockDataBoardBroadcast = createMockData([boardBroadcastPart]) + function AllProviders(props: { children: any; data?: MockData; onOpenDiff?: () => void }) { return ( @@ -409,6 +472,26 @@ export const WithBashToolExpanded: Story = { }, } +export const WithBoardRead: Story = { + render: () => ( + + + + + + ), +} + +export const WithBoardBroadcast: Story = { + render: () => ( + + + + + + ), +} + // --- Three context-group tools + text — exercises ContextToolGroupHeader collapse --- export const WithContextGroup: Story = { diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index eab12f0d860..518b968e8f4 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -352,7 +352,6 @@ export class AgentManagerProvider implements Disposable { } this.log("Opening Agent Manager panel") this.host.capture("Agent Manager Opened", { source: PLATFORM }) - const panel = this.host.openPanel({ onBeforeMessage: (msg) => this.onMessage(msg), worktreeDirectories: () => this.getWorktreeDirectories(), diff --git a/packages/kilo-vscode/tests/fixtures/board-tool-render.tsx b/packages/kilo-vscode/tests/fixtures/board-tool-render.tsx index 2f017b65c8f..88ea95b317b 100644 --- a/packages/kilo-vscode/tests/fixtures/board-tool-render.tsx +++ b/packages/kilo-vscode/tests/fixtures/board-tool-render.tsx @@ -29,6 +29,8 @@ const { createSignal } = await import("solid-js") const { createStore } = await import("solid-js/store") const { render } = await import("solid-js/web") const { Part } = await import("@kilocode/kilo-ui/message-part") +const { AgentAvatarPalette } = await import("@kilocode/kilo-ui/agent-avatar") +const { BoardRoute } = await import("@kilocode/kilo-ui/board-message") const { MarkedProvider, createMarkedParser } = await import("@kilocode/kilo-ui/context/marked") const labels = ["initial", "hidden", "latest", "reopened", "search", "search-updated"] @@ -36,6 +38,7 @@ const outputs = labels.map((label) => JSON.stringify({ messages: [ { from: "worker", to: "main", fromLabel: `Worker ${label}`, toLabel: "Coordinator", body: `**${label}** body` }, + { from: "reviewer", to: "worker", fromLabel: "Reviewer", toLabel: `Worker ${label}`, body: "Secondary body" }, ], hasMore: false, }), @@ -81,6 +84,10 @@ JSON.parse = (text, reviver) => { } const root = document.createElement("div") document.body.append(root) +const broadcastRoot = document.createElement("div") +document.body.append(broadcastRoot) +const workerBroadcastRoot = document.createElement("div") +document.body.append(workerBroadcastRoot) const dispose = render( () => ( ( + + + + ), + broadcastRoot, +) +const disposeWorkerBroadcast = render( + () => ( + + + + ), + workerBroadcastRoot, +) const settle = async () => { await Promise.resolve() await window.happyDOM.waitUntilComplete() @@ -109,6 +132,10 @@ const update = async (index: number) => { } const visible = (label: string) => { assert.equal(trigger().getAttribute("aria-expanded"), "true") + const stack = root.querySelector('[data-component="board-participant-stack"]') + assert(stack) + assert.equal(stack.querySelectorAll('[data-component="icon"]').length, 1) + assert.equal(stack.querySelectorAll('[data-component="agent-avatar"]').length, 2) assert.equal(root.querySelector('[data-slot="board-message-body"] strong')?.textContent, label) assert.equal(root.querySelector(".board-route-sender")?.textContent, `Worker ${label}`) assert.equal(root.querySelector(".board-route-recipient")?.textContent, "Coordinator") @@ -117,6 +144,14 @@ const visible = (label: string) => { try { await settle() + const recipient = broadcastRoot.querySelector('[data-slot="board-route-recipient-icon"]') + assert(recipient) + assert.equal(recipient.querySelectorAll('[data-component="board-participant-stack"]').length, 1) + assert.equal(recipient.querySelectorAll('[data-component="agent-avatar"]').length, 2) + const workerRecipient = workerBroadcastRoot.querySelector('[data-slot="board-route-recipient-icon"]') + assert(workerRecipient) + assert.equal(workerRecipient.querySelectorAll('[data-component="board-participant-stack"]').length, 0) + assert.equal(workerRecipient.querySelectorAll('[data-component="icon"]').length, 2) for (const index of [0, 1, 2]) { if (index) await update(index) assert.equal(trigger().getAttribute("aria-expanded"), "false") @@ -129,7 +164,7 @@ try { trigger().click() await settle() visible("latest") - assert.deepEqual(parsed, ["**latest** body"]) + assert.deepEqual(parsed, ["**latest** body", "Secondary body"]) trigger().click() await settle() @@ -149,6 +184,8 @@ try { assert.deepEqual(decoded, outputs) } finally { dispose() + disposeBroadcast() + disposeWorkerBroadcast() JSON.parse = decode await window.happyDOM.cancelAsync() await window.happyDOM.close() diff --git a/packages/kilo-vscode/tests/swarm-board.spec.ts b/packages/kilo-vscode/tests/swarm-board.spec.ts index 0a046306288..f316f9692c2 100644 --- a/packages/kilo-vscode/tests/swarm-board.spec.ts +++ b/packages/kilo-vscode/tests/swarm-board.spec.ts @@ -192,8 +192,14 @@ for (const width of [420, 200]) { await expect( 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="board-participant-stack"]'), + ).toHaveCount(1) + await expect( + routes.last().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, + 1, ) }) } diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 35d774322af..b811a51ba7e 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -17,6 +17,7 @@ import { messageTurns } from "../context/session-queue" import { transcriptRows } from "../context/transcript-rows" import { ChatView } from "../components/chat/ChatView" import { Part } from "@kilocode/kilo-ui/message-part" +import { AgentAvatarPalette } from "@kilocode/kilo-ui/agent-avatar" import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides" import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" @@ -1396,7 +1397,9 @@ export const AgentMessages: Story = { const parts = board() return ( - {(part) => } + + {(part) => } + ) },