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/board-post-sending-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Resolve agent board route metadata from the session store while a message is still being written, so sender and recipient avatars and titles appear without waiting for the completed tool result.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 33 additions & 7 deletions packages/kilo-ui/src/components/board-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,52 @@
max-width: none;
}

> [data-component="icon"],
[data-slot="board-route-arrow"],
[data-slot="board-route-recipient-icon"] {
flex: 0 0 auto;
}

[data-slot="board-route-recipient-icon"] {
display: inline-flex;
}

/* Parent session marker: the spinner grid, static, in the avatar slot size. */
/* Parent session marker, in the avatar slot size. */
.board-route-parent {
width: 18px;
color: var(--text-weak);
}

> rect {
animation: none !important;
/* While the post is still being written or stored, the sender glyph
pulses (see agent-avatar.css) and the arrow travels toward the
recipient in the same 1.4s rhythm, so the row reads as "sending". */
&[data-pending="true"] [data-slot="board-route-arrow"] {
color: var(--text-base);

> [data-component="icon"] {
animation: board-route-send 1.4s ease-in-out infinite both;
}
}
}

@keyframes board-route-send {
0% {
transform: translateX(-4px);
opacity: 0;
}
35%,
65% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(4px);
opacity: 0;
}
}

@media (prefers-reduced-motion: reduce) {
[data-component="board-route"][data-pending="true"] [data-slot="board-route-arrow"] > [data-component="icon"] {
animation: none;
}
}

/* Clickable avatars appear both inside a route and as a tool-header stack, so
these rules stay outside the route block. */
[data-slot="board-route-avatar"][data-clickable="true"] {
Expand Down
32 changes: 25 additions & 7 deletions packages/kilo-ui/src/components/board-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ 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; label?: string; onSessionClick?: BoardSessionNavigation; semantic?: boolean }) {
function Member(props: {
id: string
label?: string
onSessionClick?: BoardSessionNavigation
semantic?: boolean
active?: boolean
}) {
const open = () => props.onSessionClick
const semantic = () => props.semantic !== false
const clickable = () => props.id !== "main" && !!open()
Expand All @@ -29,7 +35,7 @@ function Member(props: { id: string; label?: string; onSessionClick?: BoardSessi

return (
<Show when={props.id !== "main"} fallback={<Icon class="board-route-parent" name="task" size="small" />}>
<Show when={clickable()} fallback={<AgentAvatar id={props.id} />}>
<Show when={clickable()} fallback={<AgentAvatar id={props.id} status={props.active ? "running" : undefined} />}>
<span
data-slot="board-route-avatar"
data-clickable="true"
Expand All @@ -40,7 +46,7 @@ function Member(props: { id: string; label?: string; onSessionClick?: BoardSessi
onClick={click}
onKeyDown={semantic() ? key : undefined}
>
<AgentAvatar id={props.id} />
<AgentAvatar id={props.id} status={props.active ? "running" : undefined} />
</span>
</Show>
</Show>
Expand Down Expand Up @@ -72,7 +78,8 @@ type Route = {
semantic?: boolean
}

export function BoardRoute(props: Route) {
/** `pending` animates the route while the post is still being written or stored. */
export function BoardRoute(props: Route & { pending?: boolean }) {
const i18n = useI18n()
const ids = useAgentAvatarIds()
const open = () => props.onSessionClick
Expand Down Expand Up @@ -105,22 +112,33 @@ export function BoardRoute(props: Route) {
<span
data-component="board-route"
data-broadcast={to() === "ALL"}
data-pending={props.pending ? "true" : undefined}
role="group"
aria-label={i18n.t("ui.messagePart.board.route", { from: sender(), to: recipient() })}
>
<Member id={from()} label={sender()} onSessionClick={open()} semantic={props.semantic} />
<Member id={from()} label={sender()} onSessionClick={open()} semantic={props.semantic} active={props.pending} />
<Tooltip
class="board-route-member board-route-sender"
contentClass="board-route-tooltip"
value={detail(sender(), from())}
>
{sender()}
</Tooltip>
<Icon name="arrow-right" size="small" />
<span data-slot="board-route-arrow">
<Icon name="arrow-right" size="small" />
</span>
<span data-slot="board-route-recipient-icon" data-broadcast={to() === "ALL"}>
<Show
when={to() === "ALL"}
fallback={<Member id={to()} label={recipient()} onSessionClick={open()} semantic={props.semantic} />}
fallback={
<Member
id={to()}
label={recipient()}
onSessionClick={open()}
semantic={props.semantic}
active={props.pending && !to()}
/>
}
>
<Show
when={broadcast().length > 0}
Expand Down
55 changes: 55 additions & 0 deletions packages/kilo-ui/src/components/board-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { preview } from "./board-route"

const sessions = [
{ id: "root", title: "Fix comment UI cutoff issue" },
{ id: "child", parentID: "root", title: "Find PR comment overflow (@explore subagent)" },
{ id: "sibling", parentID: "root", title: "Check serializer compatibility" },
{ id: "orphan", parentID: "missing", title: "Detached" },
]

describe("board route preview", () => {
test("maps the root session to main and resolves titles", () => {
expect(preview(sessions, "child", "main")).toEqual({
from: "child",
fromLabel: "Find PR comment overflow (@explore subagent)",
to: "main",
toLabel: "Fix comment UI cutoff issue",
})
expect(preview(sessions, "root", "child")).toEqual({
from: "main",
fromLabel: "Fix comment UI cutoff issue",
to: "child",
toLabel: "Find PR comment overflow (@explore subagent)",
})
})

test("aliases a recipient given by root ID to main", () => {
expect(preview(sessions, "child", "root")).toMatchObject({ to: "main", toLabel: "Fix comment UI cutoff issue" })
})

test("keeps broadcasts and hides unknown or partial recipient IDs", () => {
expect(preview(sessions, "child", "ALL")).toMatchObject({ to: "ALL", toLabel: undefined })
expect(preview(sessions, "child", "sib")).toMatchObject({ to: "", toLabel: undefined })
expect(preview(sessions, "child", undefined)).toMatchObject({ to: "", toLabel: undefined })
expect(preview(sessions, "child", "sibling")).toMatchObject({
to: "sibling",
toLabel: "Check serializer compatibility",
})
})

test("does not guess main when the lineage is incomplete", () => {
expect(preview(sessions, "orphan", "main")).toEqual({
from: "orphan",
fromLabel: "Detached",
to: "",
toLabel: undefined,
})
expect(preview([], "unknown", "main")).toEqual({
from: "unknown",
fromLabel: undefined,
to: "",
toLabel: undefined,
})
})
})
33 changes: 33 additions & 0 deletions packages/kilo-ui/src/components/board-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type Node = { id: string; parentID?: string; title?: string }

const LIMIT = 32

/**
* Optimistic route for a board_post that is still pending. The stored route
* (from, to, labels) only arrives with the tool result, but the sender and the
* usual recipients are already in the session store, so the trigger can show
* the real avatars and titles while the model still streams the message body.
* Unknown or partially streamed recipient IDs resolve to "" so the avatar does
* not flicker through hash colors.
*/
export function preview(sessions: readonly Node[], sessionID: string, to: unknown) {
const byID = new Map(sessions.map((node) => [node.id, node]))
const root = (id: string, depth = 0): string | undefined => {
const node = byID.get(id)
if (!node) return undefined
if (!node.parentID) return node.id
if (depth >= LIMIT) return undefined
return root(node.parentID, depth + 1)
}
const top = root(sessionID)
const alias = (id: string) => (top && id === top ? "main" : id)
const label = (id: string) => byID.get(id)?.title?.trim() || undefined
const value = typeof to === "string" ? to.trim() : ""
const target = (() => {
if (value === "ALL") return { id: "ALL", label: undefined }
if (value === "main") return { id: top ? "main" : "", label: top ? label(top) : undefined }
if (!byID.has(value)) return { id: "", label: undefined }
return { id: alias(value), label: label(value) }
})()
return { from: alias(sessionID), fromLabel: label(sessionID), to: target.id, toLabel: target.label }
}
21 changes: 17 additions & 4 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useClipboard } from "../context/clipboard"
import { type UiI18n, useI18n } from "../context/i18n"
import { BasicTool, useToolApprovalLine } from "./basic-tool"
import { BoardMessage, BoardParticipantStack, BoardRoute } from "./board-message"
import { preview } from "./board-route"
import { AgentAvatar, taskStatus } from "./agent-avatar"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
Expand Down Expand Up @@ -1096,6 +1097,7 @@ export interface ToolProps {
tool: string
partID?: string
callID?: string
sessionID?: string
output?: string
status?: string
attachments?: FilePart[]
Expand Down Expand Up @@ -1199,6 +1201,15 @@ function McpTool(props: ToolProps) {
)
return items.length === rows.length ? items : undefined
})
// The stored route only arrives with the result. While the model still
// streams the post, derive the sender and recipient from the session store
// so the trigger shows the real avatars and titles from the first frame.
const data = props.tool === "board_post" ? useData() : undefined
const live = () => props.status === "pending" || props.status === "running"
const guess = createMemo(() => {
if (!data || !live() || !props.sessionID) return undefined
return preview(data.store.session, props.sessionID, props.input.to)
})
const participants = createMemo(() => {
const seen = new Set<string>()
const ids: string[] = []
Expand All @@ -1220,10 +1231,11 @@ function McpTool(props: ToolProps) {
if (props.tool === "board_post")
return (
<BoardRoute
from={props.metadata.from ?? result()?.from}
to={props.metadata.to ?? result()?.to ?? props.input.to}
fromLabel={props.metadata.fromLabel ?? result()?.fromLabel}
toLabel={props.metadata.toLabel ?? result()?.toLabel}
from={props.metadata.from ?? result()?.from ?? guess()?.from}
to={props.metadata.to ?? result()?.to ?? guess()?.to ?? props.input.to}
fromLabel={props.metadata.fromLabel ?? result()?.fromLabel ?? guess()?.fromLabel}
toLabel={props.metadata.toLabel ?? result()?.toLabel ?? guess()?.toLabel}
pending={live()}
onSessionClick={navigate}
semantic={false}
/>
Expand Down Expand Up @@ -1463,6 +1475,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
tool={part.tool}
partID={part.id}
callID={part.callID}
sessionID={part.sessionID}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
Expand Down
47 changes: 47 additions & 0 deletions packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,53 @@ export const AgentMessages200: Story = {
name: "Agent messages with long titles (200px)",
}

// A post that is still streaming: the route is derived from the session store
// (sender title, recipient resolved to main) and the arrow animates.
export const AgentMessagePending: Story = {
name: "Agent message, sending",
render: () => {
const parts: ToolPart[] = [
{
id: "part_board_pending",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call_board_pending",
tool: "board_post",
state: {
status: "running",
input: { to: "main", type: "RESULT", body: "Parser checks are complete." },
title: "Post agent message",
metadata: {},
time: { start: now - 1000 },
},
},
{
id: "part_board_partial",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call_board_partial",
tool: "board_post",
state: { status: "pending", input: { to: "ses_ser" }, raw: "" },
},
]
const data = {
...dataWith(parts),
session: [
{ id: "ses_root", title: "Fix comment UI cutoff issue" },
{ id: SESSION_ID, parentID: "ses_root", title: "Find PR comment overflow (@explore subagent)" },
{ id: "ses_serializer", parentID: "ses_root", title: "Check serializer compatibility" },
],
}
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<For each={parts}>{(part) => <Part part={part} message={baseAssistantMessage} />}</For>
</StoryProviders>
)
},
}

export const McpToolCards: Story = {
name: "MCP Tool Cards — collapsed",
render: () => {
Expand Down
Loading