/* Copyright 2026 Marimo. All rights reserved. */
import type {
ContentBlock,
ToolCallContent,
ToolCallLocation,
} from "@zed-industries/agent-client-protocol";
import {
BotMessageSquareIcon,
FileAudio2Icon,
FileIcon,
FileImageIcon,
FileJsonIcon,
FileTextIcon,
FileVideoCameraIcon,
RotateCcwIcon,
WifiIcon,
WifiOffIcon,
WrenchIcon,
XCircleIcon,
XIcon,
} from "lucide-react";
import React from "react";
import { JsonRpcError, mergeToolCalls } from "use-acp";
import { z } from "zod";
import { ReadonlyDiff } from "@/components/editor/code/readonly-diff";
import { JsonOutput } from "@/components/editor/output/JsonOutput";
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { uniqueByTakeLast } from "@/utils/arrays";
import { logNever } from "@/utils/assertNever";
import { cn } from "@/utils/cn";
import { capitalize, Strings } from "@/utils/strings";
import { SimpleAccordion } from "./common";
import type {
AgentNotificationEvent,
AgentThoughtNotificationEvent,
ConnectionChangeNotificationEvent,
ContentBlockOf,
CurrentModeUpdateNotificationEvent,
ErrorNotificationEvent,
PlanNotificationEvent,
SessionNotificationEventData,
ToolCallNotificationEvent,
ToolCallUpdateNotificationEvent,
UserNotificationEvent,
} from "./types";
import {
isAgentMessages,
isAgentThoughts,
isPlans,
isToolCalls,
isUserMessages,
} from "./utils";
/**
* Merges consecutive text blocks into a single text block to prevent
* fragmented display when agent messages are streamed in chunks.
*/
function mergeConsecutiveTextBlocks(
contentBlocks: ContentBlock[],
): ContentBlock[] {
if (contentBlocks.length === 0) {
return contentBlocks;
}
const merged: ContentBlock[] = [];
let currentTextBlock: string | null = null;
for (const block of contentBlocks) {
if (block.type === "text") {
// Accumulate text content
if (currentTextBlock === null) {
currentTextBlock = block.text;
} else {
currentTextBlock += block.text;
}
} else {
// If we have accumulated text, flush it before adding non-text block
if (currentTextBlock !== null) {
merged.push({ type: "text", text: currentTextBlock });
currentTextBlock = null;
}
merged.push(block);
}
}
// Flush any remaining text
if (currentTextBlock !== null) {
merged.push({ type: "text", text: currentTextBlock });
}
return merged;
}
export const ErrorBlock = (props: {
data: ErrorNotificationEvent["data"];
onRetry?: () => void;
onDismiss?: () => void;
}) => {
const error = props.data;
let message = props.data.message;
// Don't show WebSocket connection errors
if (message.includes("WebSocket")) {
return null;
}
if (error instanceof JsonRpcError) {
const dataStr =
typeof error.data === "string" ? error.data : JSON.stringify(error.data);
message = `${dataStr} (code: ${error.code})`;
}
return (
Agent Error
{message}
{props.onRetry && (
Retry
)}
{props.onDismiss && (
Dismiss
)}
);
};
export const ReadyToChatBlock = () => {
return (
Agent is connected
You can start chatting with your agent now
);
};
export const ConnectionChangeBlock = (props: {
data: ConnectionChangeNotificationEvent["data"];
isConnected: boolean;
onRetry?: () => void;
timestamp?: number;
isOnlyBlock: boolean;
}) => {
const { status } = props.data;
if (props.isConnected && props.isOnlyBlock) {
return ;
}
const getStatusConfig = () => {
switch (status) {
case "connected":
return {
icon: ,
title: "Connected to Agent",
message: "Successfully established connection with the AI agent",
bgColor: "bg-[var(--blue-2)]",
borderColor: "border-[var(--blue-6)]",
textColor: "text-[var(--blue-11)]",
iconColor: "text-[var(--blue-10)]",
};
case "disconnected":
return {
icon: ,
title: "Disconnected from Agent",
message: "Connection to the AI agent has been lost",
bgColor: "bg-[var(--amber-2)]",
borderColor: "border-[var(--amber-6)]",
textColor: "text-[var(--amber-11)]",
iconColor: "text-[var(--amber-10)]",
};
case "connecting":
return {
icon: ,
title: "Connecting to Agent",
message: "Establishing connection with the AI agent...",
bgColor: "bg-[var(--gray-2)]",
borderColor: "border-[var(--gray-6)]",
textColor: "text-[var(--gray-11)]",
iconColor: "text-[var(--gray-10)]",
};
case "error":
return {
icon: ,
title: "Connection Error",
message: "Failed to connect to the AI agent",
bgColor: "bg-[var(--red-2)]",
borderColor: "border-[var(--red-6)]",
textColor: "text-[var(--red-11)]",
iconColor: "text-[var(--red-10)]",
};
default:
return {
icon: ,
title: "Connection Status Changed",
message: `Agent connection status: ${status}`,
bgColor: "bg-[var(--gray-2)]",
borderColor: "border-[var(--gray-6)]",
textColor: "text-[var(--gray-11)]",
iconColor: "text-[var(--gray-10)]",
};
}
};
const config = getStatusConfig();
const showRetry = status === "disconnected" || status === "error";
return (
{config.icon}
{config.title}
{props.timestamp && (
{new Date(props.timestamp).toLocaleTimeString()}
)}
{config.message}
{showRetry && props.onRetry && (
Retry Connection
)}
);
};
export const AgentThoughtsBlock = (props: {
startTimestamp: number;
endTimestamp: number;
data: AgentThoughtNotificationEvent[];
}) => {
const startAsSeconds = props.startTimestamp / 1000;
const endAsSeconds = props.endTimestamp / 1000;
const totalSeconds = Math.round(endAsSeconds - startAsSeconds) || "1";
return (
);
};
export const PlansBlock = (props: { data: PlanNotificationEvent[] }) => {
// Dedupe plans by text, take the last one which may have a status update
let plans = props.data.flatMap((item) => item.entries);
plans = uniqueByTakeLast(plans, (item) => item.content);
return (
To-dos{" "}
{plans.length}
);
};
export const UserMessagesBlock = (props: { data: UserNotificationEvent[] }) => {
return (
item.content)} />
);
};
export const AgentMessagesBlock = (props: {
data: AgentNotificationEvent[];
}) => {
// Merge consecutive text chunks to prevent fragmented display
const mergedContent = mergeConsecutiveTextBlocks(
props.data.map((item) => item.content),
);
return (
);
};
export const ContentBlocks = (props: { data: ContentBlock[] }) => {
const renderBlock = (block: ContentBlock) => {
if (block.type === "text") {
return ;
}
if (block.type === "image") {
return ;
}
if (block.type === "audio") {
return ;
}
if (block.type === "resource") {
return ;
}
if (block.type === "resource_link") {
return ;
}
logNever(block);
return null;
};
return (
{props.data.map((item, index) => {
return (
{renderBlock(item)}
);
})}
);
};
export const ImageBlock = (props: { data: ContentBlockOf<"image"> }) => {
return (
);
};
export const AudioBlock = (props: { data: ContentBlockOf<"audio"> }) => {
return (
);
};
export const ResourceBlock = (props: { data: ContentBlockOf<"resource"> }) => {
if ("text" in props.data.resource) {
return (
{props.data.resource.mimeType && (
)}
{props.data.resource.uri}
Formatted for agents, not humans.
{props.data.resource.mimeType === "text/plain" ? (
{props.data.resource.text}
) : (
)}
);
}
};
export const ResourceLinkBlock = (props: {
data: ContentBlockOf<"resource_link">;
}) => {
if (props.data.uri.startsWith("http")) {
return (
{props.data.name}
);
}
// Show image in popover for image mime types
if (props.data.mimeType?.startsWith("image/")) {
return (
{props.data.name || props.data.title || props.data.uri}
);
}
return (
{props.data.mimeType && }
{props.data.name || props.data.title || props.data.uri}
);
};
export const MimeIcon = (props: { mimeType: string }) => {
const classNames = "h-2 w-2 flex-shrink-0";
if (props.mimeType.startsWith("image/")) {
return ;
}
if (props.mimeType.startsWith("audio/")) {
return ;
}
if (props.mimeType.startsWith("video/")) {
return ;
}
if (props.mimeType.startsWith("text/")) {
return ;
}
if (props.mimeType.startsWith("application/")) {
return ;
}
return ;
};
export const SessionNotificationsBlock = <
T extends SessionNotificationEventData,
>(props: {
data: T[];
startTimestamp: number;
endTimestamp: number;
isLastBlock: boolean;
}) => {
if (props.data.length === 0) {
return null;
}
const kind = props.data[0].sessionUpdate;
const renderItems = (items: T[]) => {
if (isToolCalls(items)) {
return (
);
}
if (isAgentThoughts(items)) {
return (
);
}
if (isUserMessages(items)) {
return ;
}
if (isAgentMessages(items)) {
return ;
}
if (isPlans(items)) {
return ;
}
if (kind === "available_commands_update") {
return null; // nothing to show
}
if (kind === "current_mode_update") {
const lastItem = items.at(-1);
return lastItem?.sessionUpdate === "current_mode_update" ? (
) : null;
}
return (
);
};
return (
{renderItems(props.data)}
);
};
export const CurrentModeBlock = (props: {
data: CurrentModeUpdateNotificationEvent;
}) => {
const { currentModeId } = props.data;
return Mode: {currentModeId}
;
};
export const ToolNotificationsBlock = (props: {
data: (ToolCallNotificationEvent | ToolCallUpdateNotificationEvent)[];
isLastBlock: boolean;
}) => {
const toolCalls = mergeToolCalls(props.data);
return (
{toolCalls.map((item) => (
{handleBackticks(toolTitle(item))}
}
defaultIcon={ }
>
))}
);
};
export const DiffBlocks = (props: {
data: Extract[];
}) => {
return (
{props.data.map((item) => {
return (
{/* File path header */}
{item.path}
);
})}
);
};
function toolTitle(
item: Pick,
) {
let title = item.title;
// Hack: sometimes title comes back: "undefined", so lets undo that
if (title === '"undefined"') {
title = undefined;
}
const prefix = title || Strings.startCase(item.kind || "") || "Tool call";
const firstLocation = item.locations?.[0];
// Add the first location if it is not in the title already
if (firstLocation && !prefix.includes(firstLocation.path)) {
return `${prefix}: ${firstLocation.path}`;
}
return prefix;
}
function handleBackticks(text: string) {
if (text.startsWith("`") && text.endsWith("`")) {
return {text.slice(1, -1)} ;
}
return text;
}
export const LocationsBlock = (props: { data: ToolCallLocation[] }) => {
// Only show locations if there are multiple locations, otherwise it is
// in the title
if (props.data.length <= 1) {
return null;
}
const locations = props.data.map((item) => {
if (item.line) {
return `${item.path}:${item.line}`;
}
return item.path;
});
return {locations.join("\n")}
;
};
export const ToolBodyBlock = (props: {
data:
| Omit
| Omit;
}) => {
const { content, locations, status, kind, rawInput } = props.data;
const textContent = content
?.filter((item) => item.type === "content")
.map((item) => item.content);
const diffs = content?.filter((item) => item.type === "diff");
const isFailed = status === "failed";
const hasLocations = locations && locations.length > 0;
// Completely empty
if (!content && !hasLocations && rawInput) {
// HACK: if the raw input is `abs_path`, `old_string`, `new_string` then handle it as if it is a diff
const rawDiff = rawDiffSchema.safeParse(rawInput);
if (rawDiff.success) {
return (
);
}
// Show rawInput
return (
);
}
const noContent = !textContent || textContent.length === 0;
const noDiffs = !diffs || diffs.length === 0;
if (noContent && noDiffs && hasLocations) {
return (
{capitalize(kind || "")}{" "}
{locations?.map((item) => item.path).join(", ")}
);
}
return (
{locations && }
{textContent && }
{diffs && !isFailed && }
);
};
const rawDiffSchema = z.object({
abs_path: z.string(),
old_string: z.string(),
new_string: z.string(),
});