fix(agents): style message tool rows as chat bubbles with inset media

- Render agent message-type tool rows (Send Message and buzz CLI message sends) as styled chat bubbles via a new CompactMessageSummary, gated on a presentation: "message" field on CompactToolSummary
- Detect buzz message-send commands from both the structured Buzz tool and shell invocations (including simple echo pipes) so transcripts show the actual sent content
- Add a mediaInset markdown prop that insets images and videos inside message bubbles so media no longer bleeds to the bubble edge; wire it into PromptUserMessage and MessageItem
- Extend agentSessionToolSummary tests to cover the presentation field across message and inline cases

Folds in PR-2 UI polish that was orphaned when the working tree was parked during the PR split.
This commit is contained in:
Taylor Ho
2026-06-15 15:45:56 -07:00
committed by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent a7d6df9f3c
commit c0aa2564ad
5 changed files with 347 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ChevronDown } from "lucide-react";
import { ChevronDown, Send } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -40,16 +40,27 @@ export function ToolItem({
>
<summary
className={cn(
"inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px",
"max-w-full cursor-pointer list-none py-px",
compactSummary.presentation === "inline" &&
"inline-flex items-center gap-1.5",
compactSummaryTone(),
)}
>
<CompactToolSummaryRow
duration={duration}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
/>
{compactSummary.presentation === "message" ? (
<CompactMessageSummary
duration={duration}
isError={item.isError || item.status === "failed"}
label={compactSummary.label}
preview={compactSummary.preview}
/>
) : (
<CompactToolSummaryRow
duration={duration}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
/>
)}
</summary>
<ToolDetailBlocks
@@ -131,6 +142,52 @@ function CompactToolSummaryRow({
);
}
function CompactMessageSummary({
duration,
isError,
label,
preview,
}: {
duration: string | null;
isError: boolean;
label: string;
preview: string | null;
}) {
const mutedTone = compactSummaryTone();
return (
<div className="flex max-w-[85%] flex-col items-start gap-1.5">
<div
className={cn(
"min-w-0 rounded-2xl border px-3 py-2 text-sm leading-relaxed shadow-sm",
isError
? "border-destructive/25 bg-destructive/10 text-destructive"
: "border-primary/15 bg-primary/6 text-foreground",
)}
data-testid="transcript-tool-message-preview"
>
<p className="whitespace-pre-wrap wrap-break-word">
{preview || "Message content unavailable."}
</p>
</div>
<div className="inline-flex max-w-full items-center gap-1.5 px-1">
<Send className={cn("h-3.5 w-3.5 shrink-0", mutedTone)} />
<span className={cn("truncate text-xs font-medium", mutedTone)}>
{label}
</span>
{duration ? (
<span className={cn("shrink-0 text-xs", mutedTone)}>{duration}</span>
) : null}
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180",
mutedTone,
)}
/>
</div>
</div>
);
}
function resolveImageSrc(source: string): string {
if (source.startsWith("data:image/")) {
return source;
@@ -159,7 +216,7 @@ function ViewImageToolPreview({
{/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */}
<img
alt={alt}
className="block max-h-64 max-w-sm cursor-pointer rounded-md object-contain"
className="ml-1.5 block max-h-64 max-w-[min(24rem,calc(100%-0.375rem))] cursor-pointer rounded-lg object-contain"
decoding="async"
loading="lazy"
onClick={() => setLightboxOpen(true)}

View File

@@ -291,7 +291,7 @@ function PromptUserMessage({
/>
<div className="group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1">
<div className="w-full min-w-0 rounded-2xl bg-muted p-2.5 text-sm leading-relaxed text-foreground">
<Markdown content={text || " "} tight />
<Markdown content={text || " "} mediaInset tight />
{contextOpen && context ? (
<PromptContextSections sections={context.sections} setup={setup} />
) : null}
@@ -607,7 +607,7 @@ function MessageItem({
<Markdown compact content={text || " "} />
) : (
<>
<Markdown content={text || " "} tight />
<Markdown content={text || " "} mediaInset tight />
<TranscriptTimestamp timestamp={item.timestamp} />
</>
)}

View File

@@ -36,6 +36,40 @@ test("buildCompactToolSummary formats Buzz send_message preview", () => {
assert.equal(summary.kind, "buzz");
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "Hello team");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary treats buzz messages send commands as messages", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "buzz-dev-mcp__shell",
args: {
command:
'buzz --format compact messages send --channel channel-1 --content "@Ned are you working"',
},
}),
);
assert.equal(summary.kind, "shell");
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "@Ned are you working");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary extracts simple piped buzz message content", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "shell",
args: {
command:
'echo "hello from stdin" | ./target/release/buzz messages send --channel channel-1 --content -',
},
}),
);
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "hello from stdin");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary formats shell command preview", () => {
@@ -48,6 +82,7 @@ test("buildCompactToolSummary formats shell command preview", () => {
assert.equal(summary.label, "Ran command");
assert.equal(summary.preview, "git status");
assert.equal(summary.presentation, "inline");
});
test("buildCompactToolSummary formats view_image thumbnail source", () => {

View File

@@ -29,6 +29,7 @@ export type CompactToolSummary = {
preview: string | null;
/** When set, the compact row renders a tiny image instead of text preview. */
thumbnailSrc: string | null;
presentation: "inline" | "message";
};
const DEVELOPER_TOOL_BASES = new Set([
@@ -46,12 +47,24 @@ type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
/** Build the muted compact summary label and preview for any tool row. */
export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
const kind = resolveCompactToolKind(item);
const messageSendPreview = extractMessageSendPreview(item, kind);
if (messageSendPreview !== undefined) {
return {
kind,
label: compactMessageSendLabel(item.status, item.isError),
preview: messageSendPreview,
thumbnailSrc: null,
presentation: "message",
};
}
const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind);
return {
kind,
label: compactToolLabel(kind, item, item.status, item.isError),
preview,
thumbnailSrc,
presentation: "inline",
};
}
@@ -196,6 +209,13 @@ function developerToolLabels(): Record<
};
}
function compactMessageSendLabel(status: ToolStatus, isError: boolean) {
if (isError || status === "failed") {
return "Send Message failed";
}
return "Send Message";
}
type CompactToolPreview = {
preview: string | null;
thumbnailSrc: string | null;
@@ -271,6 +291,210 @@ function extractBuzzToolPreview(args: Record<string, unknown>): string | null {
return getToolString(args, ["event_id", "eventId", "name"]);
}
function extractMessageSendPreview(
item: ToolItem,
kind: CompactToolKind,
): string | null | undefined {
if (isBuzzSendMessageTool(item)) {
return extractBuzzToolMessageContent(item.args);
}
if (kind !== "shell") {
return undefined;
}
const command = getToolString(item.args, ["command"]);
return command ? extractBuzzCliSendMessageContent(command) : undefined;
}
function isBuzzSendMessageTool(item: ToolItem) {
return [item.buzzToolName, item.toolName, item.title].some((value) => {
if (!value) return false;
return normalizeToolNameText(value) === "send_message";
});
}
function extractBuzzToolMessageContent(
args: Record<string, unknown>,
): string | null {
return getToolString(args, ["content", "message", "text", "body"]);
}
function extractBuzzCliSendMessageContent(
command: string,
): string | null | undefined {
const tokens = tokenizeShellCommand(command);
const commandRange = findBuzzMessagesSendCommand(tokens);
if (!commandRange) {
return undefined;
}
const content = getFlagValue(tokens, commandRange.sendIndex + 1, "--content");
if (!content) {
return null;
}
if (content !== "-") {
return content;
}
const pipedContent = extractSimpleEchoPipeContent(
tokens,
commandRange.buzzIndex,
);
return pipedContent ?? null;
}
function tokenizeShellCommand(command: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
let escaping = false;
const pushCurrent = () => {
if (current.length > 0) {
tokens.push(current);
current = "";
}
};
for (const char of command) {
if (escaping) {
current += char;
escaping = false;
continue;
}
if (char === "\\" && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (/\s/.test(char)) {
pushCurrent();
continue;
}
if (char === "|" || char === ";" || char === "&") {
pushCurrent();
tokens.push(char);
continue;
}
current += char;
}
if (escaping) {
current += "\\";
}
pushCurrent();
return tokens;
}
function findBuzzMessagesSendCommand(
tokens: string[],
): { buzzIndex: number; sendIndex: number } | null {
for (let i = 0; i < tokens.length; i++) {
if (!isBuzzExecutable(tokens[i])) {
continue;
}
const messagesIndex = tokens.indexOf("messages", i + 1);
if (messagesIndex === -1) {
continue;
}
if (
messagesIndex > i &&
hasCommandSeparator(tokens, i + 1, messagesIndex)
) {
continue;
}
if (tokens[messagesIndex + 1] === "send") {
return { buzzIndex: i, sendIndex: messagesIndex + 1 };
}
}
return null;
}
function isBuzzExecutable(token: string) {
return token === "buzz" || token.split(/[\\/]/).pop() === "buzz";
}
function hasCommandSeparator(tokens: string[], start: number, end: number) {
for (let i = start; i < end; i++) {
if (isCommandSeparator(tokens[i])) {
return true;
}
}
return false;
}
function isCommandSeparator(token: string) {
return token === "|" || token === ";" || token === "&";
}
function getFlagValue(tokens: string[], start: number, flag: string) {
for (let i = start; i < tokens.length; i++) {
const token = tokens[i];
if (isCommandSeparator(token)) {
return null;
}
if (token === flag) {
return tokens[i + 1] && !isCommandSeparator(tokens[i + 1])
? tokens[i + 1]
: null;
}
if (token.startsWith(`${flag}=`)) {
return token.slice(flag.length + 1);
}
}
return null;
}
function extractSimpleEchoPipeContent(
tokens: string[],
buzzIndex: number,
): string | null {
const pipeIndex = tokens.lastIndexOf("|", buzzIndex);
if (pipeIndex <= 0) {
return null;
}
const echoStart = findSegmentStart(tokens, pipeIndex - 1);
const leftSegment = tokens.slice(echoStart, pipeIndex);
if (leftSegment[0] !== "echo") {
return null;
}
const contentTokens = leftSegment
.slice(1)
.filter((token) => !token.startsWith("-"));
return contentTokens.length > 0 ? contentTokens.join(" ") : null;
}
function findSegmentStart(tokens: string[], beforeIndex: number) {
for (let i = beforeIndex; i >= 0; i--) {
if (isCommandSeparator(tokens[i])) {
return i + 1;
}
}
return 0;
}
function textPreview(preview: string | null): CompactToolPreview {
return { preview, thumbnailSrc: null };
}

View File

@@ -224,6 +224,7 @@ type MarkdownProps = {
agentMentionPubkeysByName?: Record<string, string>;
mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>;
mediaInset?: boolean;
searchQuery?: string;
tight?: boolean;
videoReviewContext?: VideoReviewContext;
@@ -748,6 +749,7 @@ function createMarkdownComponents(
variant: MarkdownVariant,
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
mediaInset = false,
): Components {
const paragraphClassName =
variant === "tight"
@@ -898,7 +900,12 @@ function createMarkdownComponents(
if (resolvedSrc?.endsWith(".mp4")) {
const entry = src ? imetaByUrl?.get(src) : undefined;
return (
<span data-block-media="">
<span
className={cn(
mediaInset && "mx-1.5 block max-w-[calc(100%-0.75rem)]",
)}
data-block-media=""
>
<MarkdownVideoPlayer
key={src ?? resolvedSrc}
alt={alt}
@@ -910,7 +917,13 @@ function createMarkdownComponents(
);
}
return (
<span data-block-media="" className="block">
<span
data-block-media=""
className={cn(
"block",
mediaInset && "mx-1.5 max-w-[calc(100%-0.75rem)]",
)}
>
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
</span>
);
@@ -1115,6 +1128,7 @@ function MarkdownInner({
imetaByUrl,
interactive = true,
agentMentionPubkeysByName,
mediaInset = false,
mentionNames,
mentionPubkeysByName,
searchQuery,
@@ -1161,8 +1175,9 @@ function MarkdownInner({
});
const components = React.useMemo(
() => createMarkdownComponents(variant, runtimeRef, interactive),
[variant, runtimeRef, interactive],
() =>
createMarkdownComponents(variant, runtimeRef, interactive, mediaInset),
[variant, runtimeRef, interactive, mediaInset],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
@@ -1276,6 +1291,7 @@ export const Markdown = React.memo(
prev.compact === next.compact &&
prev.customEmoji === next.customEmoji &&
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.tight === next.tight &&
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
prev.mentionPubkeysByName === next.mentionPubkeysByName &&