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: () => {