689 lines
22 KiB
TypeScript
689 lines
22 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import {
|
|
Check,
|
|
ChevronRight,
|
|
Clock3,
|
|
Copy,
|
|
ImageIcon,
|
|
Wrench,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { AttachmentTile } from "@/components/AttachmentTile";
|
|
import { ImageLightbox } from "@/components/ImageLightbox";
|
|
import { MarkdownText } from "@/components/MarkdownText";
|
|
import { SlashCommandText } from "@/components/SlashCommandText";
|
|
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
|
|
import { UserMessageText } from "@/components/UserMessageText";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
import { formatTurnLatency } from "@/lib/format";
|
|
import { toMediaAttachment } from "@/lib/media";
|
|
import { matchingSlashCommand } from "@/lib/slash-command";
|
|
import type {
|
|
CliAppInfo,
|
|
McpPresetInfo,
|
|
SlashCommand,
|
|
UICliAppAttachment,
|
|
UIMcpPresetAttachment,
|
|
UIImage,
|
|
UIMediaAttachment,
|
|
UIMessage,
|
|
} from "@/lib/types";
|
|
|
|
interface MessageBubbleProps {
|
|
message: UIMessage;
|
|
/** When false, hide this message's copy button. Default true. */
|
|
showCopyAction?: boolean;
|
|
cliApps?: CliAppInfo[];
|
|
mcpPresets?: McpPresetInfo[];
|
|
slashCommands?: SlashCommand[];
|
|
onOpenFilePreview?: (path: string) => void;
|
|
onForkFromHere?: () => void;
|
|
}
|
|
|
|
function ForkArrowIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden
|
|
>
|
|
<path d="M16 3h5v5" />
|
|
<path d="M8 3H3v5" />
|
|
<path d="m21 3-7.536 7.536A5 5 0 0 0 12 14.07V21" />
|
|
<path d="m3 3 7.536 7.536A5 5 0 0 1 12 14.07V15" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MessageCopyButton({ content }: { content: string }) {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
const copyResetRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (copyResetRef.current !== null) {
|
|
window.clearTimeout(copyResetRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const onCopy = useCallback(() => {
|
|
void copyTextToClipboard(content).then((ok) => {
|
|
if (!ok) return;
|
|
setCopied(true);
|
|
if (copyResetRef.current !== null) {
|
|
window.clearTimeout(copyResetRef.current);
|
|
}
|
|
copyResetRef.current = window.setTimeout(() => {
|
|
setCopied(false);
|
|
copyResetRef.current = null;
|
|
}, 1_500);
|
|
});
|
|
}, [content]);
|
|
|
|
const label = copied ? t("message.copiedReply") : t("message.copyReply");
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={onCopy}
|
|
aria-label={label}
|
|
className={cn(
|
|
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
)}
|
|
>
|
|
{copied ? (
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
) : (
|
|
<Copy className="h-4 w-4" aria-hidden />
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center">{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
|
export function MessageBubble({
|
|
message,
|
|
showCopyAction = true,
|
|
cliApps = [],
|
|
mcpPresets = [],
|
|
slashCommands = [],
|
|
onOpenFilePreview,
|
|
onForkFromHere,
|
|
}: MessageBubbleProps) {
|
|
const { t } = useTranslation();
|
|
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
|
const mentionCliApps = useMemo(
|
|
() => mergeCliMentionApps(cliApps, message.cliApps),
|
|
[cliApps, message.cliApps],
|
|
);
|
|
const mentionMcpPresets = useMemo(
|
|
() => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
|
|
[mcpPresets, message.mcpPresets],
|
|
);
|
|
|
|
if (message.kind === "trace") {
|
|
return <TraceGroup message={message} animClass={baseAnim} />;
|
|
}
|
|
|
|
if (message.role === "user") {
|
|
const images = message.images ?? [];
|
|
const media = message.media ?? [];
|
|
const hasImages = images.length > 0;
|
|
const hasMedia = media.length > 0;
|
|
const hasText = message.content.trim().length > 0;
|
|
const slashCommand = matchingSlashCommand(message.content, slashCommands);
|
|
const messageText = slashCommand ? (
|
|
<>
|
|
<SlashCommandText command={slashCommand.command} />
|
|
<UserMessageText
|
|
text={message.content.slice(slashCommand.command.length)}
|
|
cliApps={mentionCliApps}
|
|
mcpPresets={mentionMcpPresets}
|
|
/>
|
|
</>
|
|
) : (
|
|
<UserMessageText
|
|
text={message.content}
|
|
cliApps={mentionCliApps}
|
|
mcpPresets={mentionMcpPresets}
|
|
/>
|
|
);
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
|
|
baseAnim,
|
|
)}
|
|
>
|
|
{hasImages ? <UserImages images={images} align="right" /> : null}
|
|
{!hasImages && hasMedia ? (
|
|
<MessageMedia media={media} align="right" />
|
|
) : null}
|
|
{hasText ? (
|
|
<p
|
|
className={cn(
|
|
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] bg-secondary/70 px-4 py-2",
|
|
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
|
|
)}
|
|
>
|
|
{messageText}
|
|
</p>
|
|
) : null}
|
|
{hasText && showCopyAction ? (
|
|
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
|
<div className="flex min-h-8 items-center justify-end text-muted-foreground">
|
|
<MessageCopyButton content={message.content} />
|
|
</div>
|
|
</TooltipProvider>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const empty = message.content.trim().length === 0;
|
|
const media = message.media ?? [];
|
|
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
|
|
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
|
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
|
const automationSourceKind = message.source?.kind;
|
|
const automationSourceName = message.source?.label?.trim();
|
|
const automationSourceLabel = (
|
|
automationSourceKind === "cron"
|
|
|| automationSourceKind === "local_trigger"
|
|
|| automationSourceKind === "trigger"
|
|
)
|
|
? (automationSourceName || t("message.automationSourceFallback"))
|
|
: "";
|
|
const automationTriggeredLabel = t("message.automationTriggered");
|
|
|
|
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
|
const showCopyButton = showCopyAction && showAssistantActions;
|
|
const showForkButton = showAssistantActions && !!onForkFromHere;
|
|
const forkLabel = t("message.forkFromHere");
|
|
const latencyMs = message.latencyMs;
|
|
const showLatencyFooter =
|
|
message.role === "assistant"
|
|
&& latencyMs != null
|
|
&& !message.isStreaming
|
|
&& (!empty || hasReasoning || media.length > 0);
|
|
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
|
|
return (
|
|
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
|
{hasReasoning ? (
|
|
<ReasoningBubble
|
|
text={reasoning}
|
|
streaming={reasoningStreaming}
|
|
hasBodyBelow={!empty}
|
|
/>
|
|
) : null}
|
|
{empty && message.isStreaming && !hasReasoning ? (
|
|
<ThinkingState />
|
|
) : empty && message.isStreaming ? null : (
|
|
<>
|
|
{automationSourceLabel ? (
|
|
<AutomationSourceBadge
|
|
label={automationSourceLabel}
|
|
triggerLabel={automationTriggeredLabel}
|
|
/>
|
|
) : null}
|
|
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
|
<MarkdownText
|
|
streaming={!!message.isStreaming}
|
|
onOpenFilePreview={onOpenFilePreview}
|
|
>
|
|
{message.content}
|
|
</MarkdownText>
|
|
</div>
|
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
|
{showAssistantFooterRow ? (
|
|
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
|
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
|
{showCopyButton ? (
|
|
<MessageCopyButton content={message.content} />
|
|
) : null}
|
|
{showForkButton ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={onForkFromHere}
|
|
aria-label={forkLabel}
|
|
className={cn(
|
|
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
)}
|
|
>
|
|
<ForkArrowIcon className="h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
{showLatencyFooter ? (
|
|
<span
|
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
|
title={t("message.turnLatencyTitle")}
|
|
>
|
|
{formatTurnLatency(latencyMs)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</TooltipProvider>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
|
|
"border border-sky-500/15 bg-sky-500/[0.06]",
|
|
"text-[11px] font-medium leading-none text-sky-700",
|
|
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
|
|
)}
|
|
title={triggerLabel}
|
|
>
|
|
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
|
|
<span className="min-w-0 truncate">{label}</span>
|
|
<span className="text-current/45" aria-hidden>·</span>
|
|
<span className="shrink-0">{triggerLabel}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function mergeMcpMentionPresets(
|
|
presets: McpPresetInfo[],
|
|
attachments: UIMcpPresetAttachment[] | undefined,
|
|
): McpPresetInfo[] {
|
|
if (!attachments?.length) return presets;
|
|
const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
|
|
for (const attachment of attachments) {
|
|
const name = attachment.name?.trim();
|
|
if (!name) continue;
|
|
const existing = byName.get(name.toLowerCase());
|
|
byName.set(name.toLowerCase(), {
|
|
name,
|
|
display_name: attachment.display_name || existing?.display_name || name,
|
|
category: attachment.category || existing?.category || "mcp",
|
|
description: existing?.description || "",
|
|
docs_url: existing?.docs_url || "",
|
|
transport: attachment.transport || existing?.transport || "mcp",
|
|
requires: existing?.requires || "",
|
|
note: existing?.note || "",
|
|
install_supported: existing?.install_supported ?? true,
|
|
installed: true,
|
|
configured: attachment.configured ?? existing?.configured ?? true,
|
|
available: existing?.available ?? true,
|
|
status: attachment.status || existing?.status || "configured",
|
|
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
|
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
|
required_fields: existing?.required_fields || [],
|
|
connection_summary: existing?.connection_summary || "",
|
|
});
|
|
}
|
|
return Array.from(byName.values());
|
|
}
|
|
|
|
function mergeCliMentionApps(
|
|
cliApps: CliAppInfo[],
|
|
attachments: UICliAppAttachment[] | undefined,
|
|
): CliAppInfo[] {
|
|
if (!attachments?.length) return cliApps;
|
|
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
|
|
for (const attachment of attachments) {
|
|
const name = attachment.name?.trim();
|
|
if (!name) continue;
|
|
const existing = byName.get(name.toLowerCase());
|
|
byName.set(name.toLowerCase(), {
|
|
name,
|
|
display_name: attachment.display_name || existing?.display_name || name,
|
|
category: attachment.category || existing?.category || "cli",
|
|
description: existing?.description || "",
|
|
requires: existing?.requires || "",
|
|
source: existing?.source || "attached",
|
|
entry_point: attachment.entry_point || existing?.entry_point || "",
|
|
install_supported: existing?.install_supported ?? true,
|
|
installed: true,
|
|
available: existing?.available ?? true,
|
|
status: existing?.status || "installed",
|
|
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
|
|
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
|
|
skill_installed: existing?.skill_installed ?? true,
|
|
});
|
|
}
|
|
return Array.from(byName.values());
|
|
}
|
|
|
|
function MessageMedia({
|
|
media,
|
|
align,
|
|
}: {
|
|
media: UIMediaAttachment[];
|
|
align: "left" | "right";
|
|
}) {
|
|
if (media.length === 0) return null;
|
|
const images: UIImage[] = [];
|
|
const nonImages: UIMediaAttachment[] = [];
|
|
for (const item of media) {
|
|
const normalized = toMediaAttachment(item);
|
|
if (normalized.kind === "image") {
|
|
images.push({ url: normalized.url, name: normalized.name });
|
|
} else {
|
|
nonImages.push(normalized);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"mt-2 flex flex-wrap gap-2",
|
|
align === "right" ? "justify-end" : "justify-start",
|
|
)}
|
|
>
|
|
{images.length > 0 ? (
|
|
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
|
|
) : null}
|
|
{nonImages.map((item, i) => (
|
|
<AttachmentTile key={`${item.url ?? item.name ?? item.kind}-${i}`} attachment={item} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Right-aligned preview row for images attached to a user turn.
|
|
*
|
|
* The URL is expected to be a self-contained ``data:`` URL (the Composer
|
|
* hands the normalized base64 payload to the optimistic bubble so that the
|
|
* preview survives React StrictMode double-mount — blob URLs would be
|
|
* revoked by the Composer's cleanup before remount). Historical replays
|
|
* have no URL (the backend strips data URLs before persisting), so we
|
|
* render a labelled placeholder tile instead of a broken ``<img>``.
|
|
*/
|
|
function UserImages({
|
|
images,
|
|
align = "right",
|
|
size = "compact",
|
|
}: {
|
|
images: UIImage[];
|
|
align?: "left" | "right";
|
|
size?: "compact" | "large";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Only real-URL images can open in the lightbox; historical-replay
|
|
// placeholders (no URL) have nothing to zoom into.
|
|
const viewableImages: UIImage[] = [];
|
|
const originalToViewable = new Map<number, number>();
|
|
for (let i = 0; i < images.length; i += 1) {
|
|
const img = images[i];
|
|
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
|
originalToViewable.set(i, viewableImages.length);
|
|
viewableImages.push(img);
|
|
}
|
|
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"flex flex-wrap items-end gap-2",
|
|
size === "large" && "gap-3",
|
|
align === "right" ? "ml-auto justify-end" : "mr-auto justify-start",
|
|
)}
|
|
>
|
|
{images.map((img, i) => (
|
|
<UserImageCell
|
|
key={`${img.url ?? "placeholder"}-${i}`}
|
|
image={img}
|
|
size={size}
|
|
placeholderLabel={t("message.imageAttachment")}
|
|
openLabel={t("lightbox.open")}
|
|
onOpen={
|
|
originalToViewable.has(i)
|
|
? () => setLightboxIndex(originalToViewable.get(i)!)
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
<ImageLightbox
|
|
images={viewableImages}
|
|
index={lightboxIndex}
|
|
onIndexChange={setLightboxIndex}
|
|
onOpenChange={(open) => {
|
|
if (!open) setLightboxIndex(null);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function UserImageCell({
|
|
image,
|
|
size,
|
|
placeholderLabel,
|
|
openLabel,
|
|
onOpen,
|
|
}: {
|
|
image: UIImage;
|
|
size: "compact" | "large";
|
|
placeholderLabel: string;
|
|
openLabel: string;
|
|
onOpen?: () => void;
|
|
}) {
|
|
const hasUrl = typeof image.url === "string" && image.url.length > 0;
|
|
const tileClasses = cn(
|
|
"relative overflow-hidden border border-border/60 bg-muted/40",
|
|
size === "large"
|
|
? "w-[min(100%,34rem)] rounded-[20px] bg-transparent"
|
|
: "h-24 w-24 rounded-[14px]",
|
|
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
|
|
);
|
|
|
|
if (hasUrl && onOpen) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
|
className={cn(
|
|
tileClasses,
|
|
"block cursor-zoom-in p-0 transition-transform duration-150 motion-reduce:transition-none",
|
|
"hover:scale-[1.01] hover:ring-2 hover:ring-primary/25",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
|
)}
|
|
>
|
|
<img
|
|
src={image.url}
|
|
alt={image.name ?? ""}
|
|
loading="lazy"
|
|
decoding="async"
|
|
draggable={false}
|
|
className={cn(
|
|
"block",
|
|
size === "large"
|
|
? "h-auto max-h-[36rem] w-full rounded-[inherit] object-contain"
|
|
: "h-full w-full object-cover",
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={tileClasses} title={image.name ?? undefined}>
|
|
<div
|
|
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground"
|
|
aria-label={placeholderLabel}
|
|
>
|
|
<ImageIcon className="h-4 w-4 flex-none" aria-hidden />
|
|
<span className="line-clamp-2 text-center leading-tight">
|
|
{image.name ?? placeholderLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Quiet pre-token state that occupies a stable line in the answer column. */
|
|
function ThinkingState() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<span
|
|
aria-label={t("message.assistantTyping")}
|
|
className="inline-flex min-h-7 items-center py-1 text-[13px]"
|
|
>
|
|
<StreamingLabelSheen active>
|
|
{t("message.reasoningStreaming", { defaultValue: "Thinking…" })}
|
|
</StreamingLabelSheen>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
|
export function StreamingLabelSheen({
|
|
children,
|
|
active,
|
|
className,
|
|
}: {
|
|
children: ReactNode;
|
|
active: boolean;
|
|
className?: string;
|
|
}) {
|
|
const sheenText =
|
|
typeof children === "string" || typeof children === "number"
|
|
? String(children)
|
|
: undefined;
|
|
return (
|
|
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
|
|
<span
|
|
data-sheen-text={active ? sheenText : undefined}
|
|
className={cn(
|
|
"block w-fit max-w-full truncate font-medium leading-normal",
|
|
active ? "streaming-text-sheen" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface ReasoningBubbleProps {
|
|
text: string;
|
|
streaming: boolean;
|
|
hasBodyBelow: boolean;
|
|
}
|
|
|
|
export function ReasoningBubble({
|
|
text,
|
|
streaming,
|
|
hasBodyBelow,
|
|
}: ReasoningBubbleProps) {
|
|
return (
|
|
<ReasoningRow
|
|
text={text}
|
|
streaming={streaming}
|
|
className={cn(
|
|
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
|
hasBodyBelow && "mb-2",
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface TraceGroupProps {
|
|
message: UIMessage;
|
|
animClass: string;
|
|
}
|
|
|
|
/**
|
|
* Collapsible group of tool-call / progress breadcrumbs. Defaults to
|
|
* collapsed because tool traces are supporting evidence, not the answer.
|
|
* A single click expands the exact calls when the user wants details.
|
|
*/
|
|
export function TraceGroup({ message, animClass }: TraceGroupProps) {
|
|
const { t } = useTranslation();
|
|
const lines = message.traces ?? [message.content];
|
|
const count = lines.length;
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div className={cn("w-full", animClass)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={cn(
|
|
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
|
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
|
)}
|
|
aria-expanded={open}
|
|
>
|
|
<Wrench className="h-3.5 w-3.5" aria-hidden />
|
|
<span className="font-medium">
|
|
{count === 1
|
|
? t("message.toolSingle")
|
|
: t("message.toolMany", { count })}
|
|
</span>
|
|
<ChevronRight
|
|
aria-hidden
|
|
className={cn(
|
|
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
|
open && "rotate-90",
|
|
)}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<ul
|
|
className={cn(
|
|
"mt-1 space-y-0.5 border-l border-muted-foreground/20 pl-3",
|
|
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
|
)}
|
|
>
|
|
{lines.map((line, i) => (
|
|
<li
|
|
key={i}
|
|
className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-muted-foreground/90"
|
|
>
|
|
{line}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|