diff --git a/.changeset/board-post-sending-route.md b/.changeset/board-post-sending-route.md
new file mode 100644
index 000000000000..ee7269f50f7a
--- /dev/null
+++ b/.changeset/board-post-sending-route.md
@@ -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.
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-message-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-message-pending-chromium-linux.png
new file mode 100644
index 000000000000..6145eb763a5b
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/agent-message-pending-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:13354cd75ccb15dea7149f1fac7b8f8ec34cf38489a0ec473365274a4ed2bbc0
+size 5469
diff --git a/packages/kilo-ui/src/components/board-message.css b/packages/kilo-ui/src/components/board-message.css
index 28a4d7a94230..3e99183034bc 100644
--- a/packages/kilo-ui/src/components/board-message.css
+++ b/packages/kilo-ui/src/components/board-message.css
@@ -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"] {
diff --git a/packages/kilo-ui/src/components/board-message.tsx b/packages/kilo-ui/src/components/board-message.tsx
index f2a11c27acf0..b59697dc4684 100644
--- a/packages/kilo-ui/src/components/board-message.tsx
+++ b/packages/kilo-ui/src/components/board-message.tsx
@@ -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()
@@ -29,7 +35,7 @@ function Member(props: { id: string; label?: string; onSessionClick?: BoardSessi
return (
}>
- }>
+ }>
-
+
@@ -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
@@ -105,10 +112,11 @@ export function BoardRoute(props: Route) {
-
+
{sender()}
-
+
+
+
}
+ fallback={
+
+ }
>
0}
diff --git a/packages/kilo-ui/src/components/board-route.test.ts b/packages/kilo-ui/src/components/board-route.test.ts
new file mode 100644
index 000000000000..01ab2647396a
--- /dev/null
+++ b/packages/kilo-ui/src/components/board-route.test.ts
@@ -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,
+ })
+ })
+})
diff --git a/packages/kilo-ui/src/components/board-route.ts b/packages/kilo-ui/src/components/board-route.ts
new file mode 100644
index 000000000000..4bee46badb37
--- /dev/null
+++ b/packages/kilo-ui/src/components/board-route.ts
@@ -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 }
+}
diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx
index 7017df50d50a..f13230c25924 100644
--- a/packages/kilo-ui/src/components/message-part.tsx
+++ b/packages/kilo-ui/src/components/message-part.tsx
@@ -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"
@@ -1096,6 +1097,7 @@ export interface ToolProps {
tool: string
partID?: string
callID?: string
+ sessionID?: string
output?: string
status?: string
attachments?: FilePart[]
@@ -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()
const ids: string[] = []
@@ -1220,10 +1231,11 @@ function McpTool(props: ToolProps) {
if (props.tool === "board_post")
return (
@@ -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
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 b811a51ba7e1..f21dd3f20d67 100644
--- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx
+++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx
@@ -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 (
+
+ {(part) => }
+
+ )
+ },
+}
+
export const McpToolCards: Story = {
name: "MCP Tool Cards — collapsed",
render: () => {