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
2 changes: 1 addition & 1 deletion .changeset/subagent-avatars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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% {
Expand Down
5 changes: 5 additions & 0 deletions packages/kilo-ui/src/components/agent-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export function AgentAvatarPalette(props: { ids: string[]; children: JSX.Element
return <Palette.Provider value={parent ?? value}>{props.children}</Palette.Provider>
}

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))

Expand Down
9 changes: 9 additions & 0 deletions packages/kilo-ui/src/components/basic-tool.css
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
33 changes: 29 additions & 4 deletions packages/kilo-ui/src/components/board-message.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -14,13 +14,29 @@ function Member(props: { id: string }) {
)
}

export function BoardParticipantStack(props: { ids: string[] }) {
return (
<span data-component="board-participant-stack" aria-hidden="true">
<Show when={props.ids.length > 0} fallback={<Icon name="task" size="small" />}>
<For each={props.ids}>{(id) => <Member id={id} />}</For>
</Show>
</span>
)
}

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)
Expand Down Expand Up @@ -56,8 +72,17 @@ export function BoardRoute(props: Route) {
<Icon name="arrow-right" size="small" />
<span data-slot="board-route-recipient-icon" data-broadcast={to() === "ALL"}>
<Show when={to() === "ALL"} fallback={<Member id={to()} />}>
<Icon name="task" size="small" />
<Icon name="task" size="small" />
<Show
when={broadcast().length > 0}
fallback={
<>
<Icon name="task" size="small" />
<Icon name="task" size="small" />
</>
}
>
<BoardParticipantStack ids={broadcast()} />
</Show>
</Show>
</span>
<Tooltip
Expand Down
30 changes: 28 additions & 2 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { useDialog } from "../context/dialog"
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 { BoardMessage, BoardParticipantStack, BoardRoute } from "./board-message"
import { AgentAvatar, taskStatus } from "./agent-avatar"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
Expand Down Expand Up @@ -1193,6 +1193,23 @@ function McpTool(props: ToolProps) {
)
return items.length === rows.length ? items : undefined
})
const participants = createMemo(() => {
const seen = new Set<string>()
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 (
Expand Down Expand Up @@ -1248,10 +1265,19 @@ function McpTool(props: ToolProps) {
return (
<Show
when={!props.hideDetails}
fallback={<BasicTool hideDetails icon={board() ? "task" : "mcp"} status={props.status} trigger={trigger()} />}
fallback={
<BasicTool
hideDetails
icon={board() ? "task" : "mcp"}
iconNode={props.tool === "board_read" ? <BoardParticipantStack ids={participants()} /> : undefined}
status={props.status}
trigger={trigger()}
/>
}
>
<BasicTool
icon={board() ? "task" : "mcp"}
iconNode={props.tool === "board_read" ? <BoardParticipantStack ids={participants()} /> : undefined}
defer={board()}
status={props.status}
tool={props.tool}
Expand Down
83 changes: 83 additions & 0 deletions packages/kilo-ui/src/stories/message-part.stories.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 (
<DataProvider data={props.data ?? mockData} directory="/project" onOpenDiff={props.onOpenDiff}>
Expand Down Expand Up @@ -409,6 +472,26 @@ export const WithBashToolExpanded: Story = {
},
}

export const WithBoardRead: Story = {
render: () => (
<AllProviders data={mockDataBoardRead}>
<AgentAvatarPalette ids={["worker", "reviewer"]}>
<AssistantParts messages={[mockAssistantMessage]} />
</AgentAvatarPalette>
</AllProviders>
),
}

export const WithBoardBroadcast: Story = {
render: () => (
<AllProviders data={mockDataBoardBroadcast}>
<AgentAvatarPalette ids={["worker", "reviewer"]}>
<AssistantParts messages={[mockAssistantMessage]} />
</AgentAvatarPalette>
</AllProviders>
),
}

// --- Three context-group tools + text — exercises ContextToolGroupHeader collapse ---

export const WithContextGroup: Story = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
39 changes: 38 additions & 1 deletion packages/kilo-vscode/tests/fixtures/board-tool-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ 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"]
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,
}),
Expand Down Expand Up @@ -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(
() => (
<MarkedProvider
Expand All @@ -94,6 +101,22 @@ const dispose = render(
),
root,
)
const disposeBroadcast = render(
() => (
<AgentAvatarPalette ids={["worker", "reviewer"]}>
<BoardRoute from="main" to="ALL" fromLabel="Coordinator" toLabel="All agents" />
</AgentAvatarPalette>
),
broadcastRoot,
)
const disposeWorkerBroadcast = render(
() => (
<AgentAvatarPalette ids={["worker"]}>
<BoardRoute from="worker" to="ALL" fromLabel="Worker" toLabel="All agents" />
</AgentAvatarPalette>
),
workerBroadcastRoot,
)
const settle = async () => {
await Promise.resolve()
await window.happyDOM.waitUntilComplete()
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -149,6 +184,8 @@ try {
assert.deepEqual(decoded, outputs)
} finally {
dispose()
disposeBroadcast()
disposeWorkerBroadcast()
JSON.parse = decode
await window.happyDOM.cancelAsync()
await window.happyDOM.close()
Expand Down
Loading
Loading