mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
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:
committed by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
a7d6df9f3c
commit
c0aa2564ad
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user