From 50dd4258658fe019fcd88c700654496b5ecb694f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 21:45:41 +0200 Subject: [PATCH] Render skill reads as chat bubbles --- src/client/src/chatGroups.test.ts | 11 +++++++++++ src/client/src/chatGroups.ts | 5 +++-- src/client/src/chatMessages.test.ts | 6 ++++++ src/client/src/chatMessages.ts | 20 ++++++++++++++++++-- src/client/src/chatTranscript.test.ts | 19 +++++++++++++++++++ src/client/src/chatTranscript.ts | 19 +++++++++++++++++++ src/client/src/components/ChatView.ts | 6 ++++++ src/client/src/components/shared.ts | 10 ++++++---- 8 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/client/src/chatGroups.test.ts b/src/client/src/chatGroups.test.ts index d80762f..faa3596 100644 --- a/src/client/src/chatGroups.test.ts +++ b/src/client/src/chatGroups.test.ts @@ -30,6 +30,17 @@ describe("groupChatMessages", () => { ]); }); + it("keeps skill reads out of event groups", () => { + const messages: ChatLine[] = [ + { role: "assistant", parts: [{ type: "thinking", text: "plan" }, { type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] }, + ]; + + expect(groupChatMessages(messages)).toEqual([ + { kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "plan" }] }] }, + { kind: "message", index: 0, message: { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] } }, + ]); + }); + it("preserves message metadata when grouping", () => { const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } }; diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index 9c42c4f..8bd4b01 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -28,7 +28,8 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts, ...metadata }, absoluteIndex); if (readableParts.length) { flushEvents(); - groups.push({ kind: "message", message: { role: message.role, parts: readableParts, ...metadata }, index: absoluteIndex }); + const role = readableParts.every((part) => part.type === "skillRead") ? "skill" : message.role; + groups.push({ kind: "message", message: { role, parts: readableParts, ...metadata }, index: absoluteIndex }); } }); flushEvents(); @@ -48,6 +49,6 @@ export function summarizeChatGroup(messages: ChatLine[]): string { function isReadablePart(message: ChatLine, part: ChatPart): boolean { if (message.source === "compaction" || message.source === "branch_summary") return false; - if (part.type === "skillInvocation") return true; + if (part.type === "skillInvocation" || part.type === "skillRead") return true; return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash"); } diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index e6504f6..387dd59 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -29,6 +29,12 @@ describe("chat message normalization", () => { ]); }); + it("normalizes skill reads into skill chat lines", () => { + expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "/home/user/.agents/skills/playwright/SKILL.md" } }] })).toEqual([ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/home/user/.agents/skills/playwright/SKILL.md" }] }, + ]); + }); + it("formats bash execution records as bash chat lines", () => { expect(normalizeMessage({ role: "bashExecution", diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 9ebfea9..927ea69 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -51,7 +51,8 @@ export function normalizeMessage(message: unknown): ChatLine[] { if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)]; const visible = parts.filter((part) => part.type !== "empty"); - return visible.length > 0 ? [withMessageMeta({ role, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : []; + const displayRole = role === "assistant" && visible.length > 0 && visible.every((part) => part.type === "skillRead") ? "skill" : role; + return visible.length > 0 ? [withMessageMeta({ role: displayRole, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : []; } function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined { @@ -142,7 +143,13 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] { const thinking = getString(part, "thinking") ?? text; return thinking !== undefined && thinking !== "" ? [{ type: "thinking", text: thinking }] : []; } - if (type === "toolCall") return [{ type: "toolCall", toolName: getString(part, "name") ?? "tool", summary: summarizeArgs(getProperty(part, "arguments")) }]; + if (type === "toolCall") { + const toolName = getString(part, "name") ?? "tool"; + const args = getProperty(part, "arguments"); + const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined; + if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }]; + return [{ type: "toolCall", toolName, summary: summarizeArgs(args) }]; + } if (type === "image") return [{ type: "text", text: "[image]" }]; return objectFallback(part); }).map((part) => part.type === "text" && getString(message, "role") === "toolResult" @@ -150,6 +157,15 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] { : part); } +function parseSkillReadPath(path: string | undefined): { name: string; path: string } | undefined { + if (path === undefined || path === "") return undefined; + const normalized = path.replace(/\\/g, "/"); + if (!normalized.endsWith("/SKILL.md") && normalized !== "SKILL.md") return undefined; + const name = normalized.split("/").at(-2); + if (name === undefined || name === "") return undefined; + return { name, path }; +} + function objectFallback(value: unknown): ChatPart[] { if (value == null) return []; if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }]; diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 10895f3..e26eff5 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -42,6 +42,25 @@ describe("applyTranscriptEvent", () => { ]); }); + it("replaces streamed skill reads when the finalized assistant tool call arrives after the tool result", () => { + const streamed: ChatLine[] = [ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] }, + { role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] }, + ]; + + expect(applyTranscriptEvent(streamed, { + type: "message.end", + message: { + role: "assistant", + content: [{ type: "toolCall", name: "read", arguments: { path: "/skills/playwright/SKILL.md" } }], + timestamp: "2026-05-09T12:00:00.000Z", + }, + })).toEqual([ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }], meta: { timestamp: "2026-05-09T12:00:00.000Z" } }, + { role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] }, + ]); + }); + it("does not merge different finalized user messages", () => { const messages = [textMessage("user", "first queued prompt")]; diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 6f4be8d..6933c4c 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -21,12 +21,31 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined { const ended = normalizeMessage(rawMessage)[0]; if (ended === undefined) return undefined; + const skillReadIndex = ended.role === "skill" ? findMatchingSkillRead(messages, ended) : -1; + if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), ended, ...messages.slice(skillReadIndex + 1)]; const last = messages.at(-1); if (last?.role !== ended.role) return [...messages, ended]; if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended]; return [...messages, ended]; } +function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number { + const endedReads = skillReadPaths(ended); + if (endedReads.length === 0) return -1; + for (let index = messages.length - 1; index >= 0; index--) { + const paths = skillReadPaths(messages[index]); + if (paths.length === endedReads.length && paths.every((path, pathIndex) => path === endedReads[pathIndex])) return index; + } + return -1; +} + +function skillReadPaths(message: ChatLine | undefined): string[] { + if (message === undefined || message.role !== "skill") return []; + return message.parts + .filter((part): part is Extract => part.type === "skillRead") + .map((part) => part.path); +} + function sameMessageText(left: ChatLine, right: ChatLine): boolean { return messageText(left) === messageText(right); } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index a4fc46f..4577112 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -332,6 +332,12 @@ export class ChatView extends LitElement { `; + if (part.type === "skillRead") return html` +
+ Loaded ${part.name} + read ${part.path} +
+ `; if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; if (part.type === "toolResult") return html`
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index f6a7722..f4c90e9 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -4,12 +4,13 @@ export type ChatPart = | { type: "text"; text: string } | { type: "thinking"; text: string } | { type: "skillInvocation"; name: string; location: string; content: string } + | { type: "skillRead"; name: string; path: string } | { type: "toolCall"; toolName: string; summary: string } | { type: "toolResult"; toolName: string; text: string; isError: boolean } | { type: "empty" }; export interface ChatLine { - role: "user" | "assistant" | "tool" | "system" | "bash"; + role: "user" | "assistant" | "tool" | "system" | "bash" | "skill"; parts: ChatPart[]; source?: "compaction" | "branch_summary"; meta?: { @@ -156,6 +157,7 @@ export const chatStyles = css` .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; } .msg.system { color: #ff7b72; } .msg.bash { border-color: #3fb950; background: #0f1b12; } + .msg.skill { border-color: #a371f7; background: #21132f; } .msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; } .msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; } .msg.event-group > summary .label { margin: 0; } @@ -206,9 +208,9 @@ export const chatStyles = css` .summary { color: #8b949e; margin-left: 6px; } .part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; } .part > formatted-text { display: block; max-width: 100%; min-width: 0; overflow: visible; } - .skill-invocation { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; } - .skill-invocation > summary { color: #d2a8ff; } - .skill-invocation > small { display: block; margin: 6px 0 8px; color: #8b949e; } + .skill-invocation, .skill-read { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; } + .skill-invocation > summary, .skill-read > strong { color: #d2a8ff; } + .skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: #8b949e; } summary { cursor: pointer; color: #8b949e; } pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } .shell-output { color: #e6edf3; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }