diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index c06c8add89..8238521425 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -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({ > - + {compactSummary.presentation === "message" ? ( + + ) : ( + + )} +
+

+ {preview || "Message content unavailable."} +

+
+
+ + + {label} + + {duration ? ( + {duration} + ) : null} + +
+ + ); +} + 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 */} {alt} setLightboxOpen(true)} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 358e369a7f..a7d3b99a98 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -291,7 +291,7 @@ function PromptUserMessage({ />
- + {contextOpen && context ? ( ) : null} @@ -607,7 +607,7 @@ function MessageItem({ ) : ( <> - + )} diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index 7b416eb594..fe25dc2cae 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -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", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index f82fbd4c2d..02ea90e2f2 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -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; /** 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 | 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 | 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 }; } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 61e498f9ea..3a3eb50fc7 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -224,6 +224,7 @@ type MarkdownProps = { agentMentionPubkeysByName?: Record; mentionNames?: string[]; mentionPubkeysByName?: Record; + mediaInset?: boolean; searchQuery?: string; tight?: boolean; videoReviewContext?: VideoReviewContext; @@ -748,6 +749,7 @@ function createMarkdownComponents( variant: MarkdownVariant, runtimeRef: React.RefObject, 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 ( - + + ); @@ -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 &&