nanochat/services/frontend/components/chat/MessageBubble.tsx
Manmohan 94bec5f2a0
fix(frontend): assistant messages fill the chat column (#42)
Assistant responses were capped at max-w-[75%] of the column, so long
replies broke into a narrow block with dead space on the right. Cap
only applies to user bubbles now; assistant messages use w-full of the
max-w-3xl content column, matching how ChatGPT/Claude render replies.
Also bumps message vertical spacing from mb-3 to mb-5.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 20:23:56 -04:00

107 lines
3.1 KiB
TypeScript

'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
import { Check, Copy } from 'lucide-react';
import clsx from 'clsx';
import type { Message } from '@/types/chat';
import SteamTyping from '@/components/svg/SteamTyping';
interface Props {
message: Message;
isStreaming?: boolean;
}
function CodeBlock({ inline, className, children, ...props }: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLElement>) {
const [copied, setCopied] = useState(false);
const content = String(children ?? '').replace(/\n$/, '');
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const copy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative group">
<button
type="button"
onClick={copy}
aria-label="Copy code"
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700/70 text-slate-100 opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-opacity"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<pre>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}
export default function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
const isConsole = message.role === 'console';
if (isConsole) {
return (
<div className="flex justify-start mb-2 animate-fade-in">
<div className="font-mono text-sm bg-cream-light dark:bg-ink-soft border border-cream-border dark:border-ink-border text-brown-light dark:text-ink-text-soft px-4 py-3 rounded-xl max-w-[80%]">
{message.content}
</div>
</div>
);
}
return (
<div className={clsx('flex mb-5 animate-fade-in', isUser ? 'justify-end' : 'justify-start')}>
<div
className={clsx(
isUser
? 'max-w-[85%] md:max-w-[75%] bg-cream dark:bg-ink-elev border border-cream-border dark:border-ink-border rounded-[1.25rem] px-4 py-3'
: 'w-full bg-transparent px-1 py-1',
)}
>
{!isUser && isStreaming && message.content.length === 0 ? (
<SteamTyping />
) : isUser ? (
<div className="whitespace-pre-wrap leading-relaxed text-[0.95rem] text-gray-900 dark:text-ink-text">
{message.content}
</div>
) : (
<div className="markdown-body text-[0.95rem] text-gray-900 dark:text-ink-text leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{ code: CodeBlock as never }}
>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
);
}