import { useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import { api } from '@/api/client';
import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock';
interface Props {
message: Message;
sessionId: string;
}
function MarkdownBody({ content }: { content: string }) {
return (
<>{children}>,
code: (props) => {
const { children, className, ...rest } = props as {
children?: unknown;
className?: string;
};
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return ;
}
return (
{children as React.ReactNode}
);
},
a: ({ children, href }) => (
{children}
),
ul: ({ children }) => (
),
ol: ({ children }) => (
{children}
),
p: ({ children }) => {children}
,
h1: ({ children }) => {children}
,
h2: ({ children }) => {children}
,
h3: ({ children }) => {children}
,
blockquote: ({ children }) => (
{children}
),
table: ({ children }) => (
),
th: ({ children }) => (
{children} |
),
td: ({ children }) => (
{children} |
),
}}
>
{content}
);
}
function StatsLine({ message }: { message: Message }) {
const tokens = message.tokens_used;
if (typeof tokens !== 'number' || tokens <= 0) return null;
const started = message.started_at ? Date.parse(message.started_at) : NaN;
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
let tps: number | null = null;
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
const seconds = (finished - started) / 1000;
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
}
const ctxUsed = message.ctx_used;
const ctxMax = message.ctx_max;
const ctxPart =
typeof ctxUsed === 'number'
? typeof ctxMax === 'number' && ctxMax > 0
? `${ctxUsed} / ${ctxMax} ctx`
: `${ctxUsed} ctx`
: null;
const parts: string[] = [`${tokens} tokens`];
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
if (ctxPart) parts.push(ctxPart);
return (
{parts.join(' ยท ')}
);
}
function ActionRow({
message,
sessionId,
}: {
message: Message;
sessionId: string;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(message.content);
setJustCopied(true);
setTimeout(() => setJustCopied(false), 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'copy failed');
}
}
async function regenerate() {
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(sessionId, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
setRegenerating(false);
}
}
const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming';
return (
{isAssistant && (
)}
);
}
export function MessageBubble({ message, sessionId }: Props) {
if (message.role === 'tool') {
return ;
}
if (message.role === 'user') {
return (
);
}
const isStreaming = message.status === 'streaming';
const failed = message.status === 'failed';
const hasContent = message.content.length > 0;
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
return (
{message.tool_calls?.map((tc) => (
))}
{(hasContent || (!hasToolCalls && isStreaming)) && (
{hasContent ? : null}
{isStreaming && (
)}
)}
{failed && (
message failed
)}
{!isStreaming &&
}
{!isStreaming && (hasContent || hasToolCalls) && (
)}
);
}