Files
Sybil-2/web/src/components/chat/chat-messages-panel.tsx
T
buzzert ef3d79c428 Add persistent agent messaging and shared chat bubbles
Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
2026-10-14 12:20:11 -07:00

650 lines
25 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { ComponentChildren, JSX } from "preact";
import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api";
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
type Props = {
messages: Message[];
isLoading: boolean;
isSending: boolean;
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
};
type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "initiated" | "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
startedAt?: string;
completedAt?: string;
durationMs?: number;
error?: string | null;
resultPreview?: string | null;
};
function asToolLogMetadata(value: unknown): ToolLogMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function getToolSummary(message: Message, metadata: ToolLogMetadata) {
if (typeof metadata.summary === "string" && metadata.summary.trim()) return metadata.summary.trim();
if (metadata.status === "failed" && typeof metadata.error === "string" && metadata.error.trim()) {
return `Tool failed: ${metadata.error.trim()}`;
}
if (typeof metadata.resultPreview === "string" && metadata.resultPreview.trim()) return metadata.resultPreview.trim();
if (message.content.trim()) return message.content.trim();
const toolName = metadata.toolName?.trim() || message.name?.trim() || "unknown_tool";
return `Ran tool '${toolName}'.`;
}
function getToolLabel(message: Message, metadata: ToolLogMetadata) {
const raw = metadata.toolName?.trim() || message.name?.trim();
if (!raw) return "Tool call";
return raw
.replace(/_/g, " ")
.split(/\s+/)
.filter(Boolean)
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
.join(" ");
}
function getToolIconName(toolName: string | null | undefined) {
const lowered = toolName?.toLowerCase() ?? "";
if (lowered.includes("search")) return "search";
if (lowered.includes("url") || lowered.includes("fetch") || lowered.includes("http")) return "fetch";
return "generic";
}
function formatDuration(durationMs: unknown) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) return null;
return `${Math.round(durationMs)} ms`;
}
function formatToolTimestamp(...values: Array<string | null | undefined>) {
const value = values.find((candidate) => candidate && !Number.isNaN(new Date(candidate).getTime()));
if (!value) return null;
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
}
type ToolCallVisualState = "initiated" | "completed" | "failed";
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
type ToolStackStyle = JSX.CSSProperties & {
"--tool-stack-x"?: string;
"--tool-stack-y"?: string;
"--tool-stack-z"?: string;
"--tool-stack-scale"?: string;
"--tool-stack-opacity"?: string;
"--tool-stack-delay"?: string;
"--tool-stack-from-transform"?: string;
"--tool-stack-to-transform"?: string;
"--tool-stack-from-opacity"?: string;
"--tool-stack-to-opacity"?: string;
};
type ToolStackContainerStyle = JSX.CSSProperties & {
"--tool-stack-from-height"?: string;
"--tool-stack-to-height"?: string;
};
type ToolStackMotionDirection = "expand" | "collapse" | null;
const COLLAPSED_TOOL_STACK_LIMIT = 4;
const TOOL_STACK_CARD_HEIGHT = 62;
const TOOL_STACK_CARD_GAP = 10;
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
if (metadata.status === "failed") return "failed";
if (metadata.status === "initiated") return "initiated";
return "completed";
}
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
return [
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
]
.filter(Boolean)
.join(" • ");
}
function buildMessageRenderItems(messages: Message[]) {
const items: MessageRenderItem[] = [];
let toolRun: Message[] = [];
const flushToolRun = () => {
if (!toolRun.length) return;
if (toolRun.length === 1) {
items.push({ kind: "message", message: toolRun[0] });
} else {
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
}
toolRun = [];
};
for (const message of messages) {
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
toolRun.push(message);
continue;
}
flushToolRun();
items.push({ kind: "message", message });
}
flushToolRun();
return items;
}
function getToolCallMessageIDs(messages: Message[]) {
const ids = new Set<string>();
for (const message of messages) {
if (message.role === "tool" && asToolLogMetadata(message.metadata)) ids.add(message.id);
}
return ids;
}
function getToolStackHeight(messageCount: number, expanded: boolean) {
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
return expanded
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
}
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
const collapsedHeight = getToolStackHeight(messageCount, false);
const expandedHeight = getToolStackHeight(messageCount, true);
const targetHeight = expanded ? expandedHeight : collapsedHeight;
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
return {
"--tool-stack-from-height": fromHeight,
"--tool-stack-to-height": targetHeight,
height: targetHeight,
};
}
function getExpandedToolLayout(index: number, messageCount: number) {
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
return {
opacity: "1",
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
x: "0px",
y,
z: "0px",
scale: "1",
zIndex: messageCount - index,
};
}
function getCollapsedToolLayout(index: number, messageCount: number) {
const depth = messageCount - index - 1;
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
const x = `${visibleDepth * 11}px`;
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
const z = `${visibleDepth * -36}px`;
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
return {
opacity,
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
x,
y,
z,
scale,
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
};
}
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
const expandedLayout = getExpandedToolLayout(index, messageCount);
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
const targetLayout = expanded ? expandedLayout : collapsedLayout;
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
return {
"--tool-stack-x": targetLayout.x,
"--tool-stack-y": targetLayout.y,
"--tool-stack-z": targetLayout.z,
"--tool-stack-scale": targetLayout.scale,
"--tool-stack-opacity": targetLayout.opacity,
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
"--tool-stack-from-transform": fromLayout.transform,
"--tool-stack-to-transform": targetLayout.transform,
"--tool-stack-from-opacity": fromLayout.opacity,
"--tool-stack-to-opacity": targetLayout.opacity,
opacity: targetLayout.opacity,
transform: targetLayout.transform,
zIndex: targetLayout.zIndex,
};
}
function ToolCallCard({
message,
className,
style,
}: {
message: Message;
className?: string;
style?: JSX.CSSProperties;
}) {
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (!toolLogMetadata) return null;
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const toolState = getToolVisualState(toolLogMetadata);
const isFailed = toolState === "failed";
const isInitiated = toolState === "initiated";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return (
<div
className={cn(
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-400/44 bg-[linear-gradient(90deg,hsl(350_64%_20%),hsl(342_58%_9%))]"
: isInitiated
? "border-amber-300/44 bg-[linear-gradient(90deg,hsl(43_72%_20%),hsl(260_48%_13%))]"
: "border-cyan-400/44 bg-[linear-gradient(90deg,hsl(184_82%_14%),hsl(208_66%_10%))]",
className
)}
style={style}
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
: isInitiated
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
);
}
function ToolCallStackCardSurface({
messageID,
animateEntry,
isHidden,
children,
}: {
messageID: string;
animateEntry: boolean;
isHidden: boolean;
children: ComponentChildren;
}) {
const [shouldAnimateEntry] = useState(() => animateEntry);
return (
<div
className={cn("tool-call-stack-card-surface", shouldAnimateEntry && !isHidden && "tool-call-stack-card-enter")}
data-tool-stack-card-id={messageID}
>
{children}
</div>
);
}
function ToolCallStack({
groupKey,
messages,
expanded,
entryMessageIDs,
onToggle,
}: {
groupKey: string;
messages: Message[];
expanded: boolean;
entryMessageIDs: Set<string>;
onToggle: (groupKey: string) => void;
}) {
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
const [motionRevision, setMotionRevision] = useState(0);
const motionResetTimerRef = useRef<number | null>(null);
const handleToggle = () => {
setMotionDirection(expanded ? "collapse" : "expand");
setMotionRevision((current) => current + 1);
if (typeof window !== "undefined") {
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
motionResetTimerRef.current = window.setTimeout(() => {
setMotionDirection(null);
motionResetTimerRef.current = null;
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
}
onToggle(groupKey);
};
return (
<div className="flex justify-start">
<div
className={cn(
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
)}
data-tool-stack-group={groupKey}
data-expanded={expanded ? "true" : "false"}
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
>
{messages.map((message, index) => {
const depth = messages.length - index - 1;
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
const shouldAnimateEntry = entryMessageIDs.has(message.id) && !isHidden;
return (
<div
key={message.id}
className={cn(
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
isHidden && "pointer-events-none"
)}
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
aria-hidden={isHidden ? "true" : undefined}
>
<ToolCallStackCardSurface messageID={message.id} animateEntry={shouldAnimateEntry} isHidden={isHidden}>
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
</ToolCallStackCardSurface>
</div>
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
onClick={handleToggle}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
</div>
);
}
type RenderedThinkingTrace = {
text: string;
revision: number;
};
const THINKING_TRACE_EXIT_MS = 220;
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
const metadata = asStreamingAssistantMetadata(message.metadata);
const traceText = metadata?.ephemeralTrace?.trim() || null;
const traceRevision = metadata?.traceRevision ?? 0;
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
traceText ? { text: traceText, revision: traceRevision } : null
);
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
const activeTraceRef = useRef<HTMLDivElement | null>(null);
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
const exitTimerRef = useRef<number | null>(null);
useLayoutEffect(() => {
const isSameTrace =
(!activeTrace && !traceText) ||
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
if (isSameTrace) return;
if (exitTimerRef.current !== null) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
if (activeTrace) {
setOutgoingTrace(activeTrace);
exitTimerRef.current = window.setTimeout(() => {
setOutgoingTrace(null);
exitTimerRef.current = null;
}, THINKING_TRACE_EXIT_MS);
} else {
setOutgoingTrace(null);
}
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
}, [traceRevision, traceText]);
useEffect(() => {
return () => {
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
};
}, []);
useLayoutEffect(() => {
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
(element): element is HTMLDivElement => element !== null
);
if (!traceElements.length) return;
const reserveMeasuredHeight = () => {
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
if (measuredHeight > 0) {
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
}
};
reserveMeasuredHeight();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(reserveMeasuredHeight);
for (const element of traceElements) observer.observe(element);
return () => observer.disconnect();
}, [activeTrace, outgoingTrace]);
const hasAnswer = message.content.trim().length > 0;
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
return (
<div
className="streaming-assistant-slot"
data-streaming-assistant="true"
data-streaming-phase={phase}
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
>
{outgoingTrace ? (
<div
ref={outgoingTraceRef}
key={`outgoing-trace-${outgoingTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
data-thinking-trace="true"
data-thinking-trace-state="outgoing"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
</div>
) : null}
{activeTrace ? (
<div
ref={activeTraceRef}
key={`active-trace-${activeTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
data-thinking-trace="true"
data-thinking-trace-state="active"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
</div>
) : null}
{activeTrace && !hasAnswer ? (
<span className="sr-only" role="status" aria-live="polite">
Assistant is working
</span>
) : null}
{hasAnswer ? (
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
/>
</div>
) : null}
{showTyping ? (
<span
className="streaming-assistant-layer inline-flex items-center gap-1"
data-streaming-pending="true"
aria-label="Assistant is typing"
role="status"
>
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
) : null}
</div>
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
const entryToolCallMessageIDs = useMemo(() => {
const seenIDs = seenToolCallMessageIDsRef.current;
if (!seenIDs) return new Set<string>();
const entryIDs = new Set<string>();
for (const id of toolCallMessageIDs) {
if (!seenIDs.has(id)) entryIDs.add(id);
}
return entryIDs;
}, [toolCallMessageIDs]);
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!toolCallMessageIDs.size) return;
const seenIDs = seenToolCallMessageIDsRef.current ?? new Set<string>();
for (const id of toolCallMessageIDs) seenIDs.add(id);
seenToolCallMessageIDsRef.current = seenIDs;
}, [toolCallMessageIDs]);
const toggleToolGroup = (groupKey: string) => {
setExpandedToolGroups((current) => {
const next = new Set(current);
if (next.has(groupKey)) next.delete(groupKey);
else next.add(groupKey);
return next;
});
};
return (
<>
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
<div className="mx-auto max-w-4xl space-y-6">
{renderItems.map((item) => {
if (item.kind === "tool_group") {
return (
<ToolCallStack
key={`tool-group-${item.key}`}
groupKey={item.key}
messages={item.messages}
expanded={expandedToolGroups.has(item.key)}
entryMessageIDs={entryToolCallMessageIDs}
onToggle={toggleToolGroup}
/>
);
}
const { message } = item;
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) {
return (
<div key={message.id} className="flex justify-start">
<ToolCallCard message={message} className="max-w-[85%]" />
</div>
);
}
const isUser = message.role === "user";
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
const attachments = getMessageAttachments(message.metadata);
return (
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"min-w-0 space-y-3",
isUser
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
: "max-w-[85%] text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
? (event) => onMessageContextMenu(event, message.id)
: undefined
}
>
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{streamingAssistantMetadata ? (
<StreamingAssistantPresentation message={message} isSending={isSending} />
) : isPendingAssistant ? (
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
) : message.content.trim() ? (
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
/>
) : null}
</div>
</div>
);
})}
{isSending && !hasPendingAssistant ? (
<div className="flex justify-start">
<div className="max-w-[85%] text-base leading-7 text-violet-50">
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
</div>
</div>
) : null}
</div>
</>
);
}