/**
* Presentational building blocks.
*
* Each part is a thin, styling-light view over the data `useConversation`
* produces. They render semantic markup with `smooth-chat__*` class names whose
* styling lives entirely in `styles.css` and is driven by `--smooth-*` CSS
* variables — so retheming is CSS, never prop-drilling. Every part also forwards
* `className` so you can layer Tailwind utilities on top, and you can ignore
* these entirely and render the hook's state yourself.
*/
import { useState, type FormEvent, type KeyboardEvent } from 'react';
import { safeHttpUrl } from '../response.js';
import type { ChatMessage, Citation, ConnectionStatus } from '../types.js';
function cx(...parts: (string | false | undefined)[]): string {
return parts.filter(Boolean).join(' ');
}
/** A list of grounding sources rendered under an assistant bubble. */
export function Citations({ citations, className }: { citations: Citation[]; className?: string }) {
if (!citations.length) return null;
return (
{citations.length} source{citations.length === 1 ? '' : 's'}
{citations.map((c) => {
const href = safeHttpUrl(c.url);
return (
-
{href ? (
{c.title}
) : (
{c.title}
)}
{c.snippet ? {c.snippet} : null}
);
})}
);
}
/** A single message bubble (+ its citations + streaming cursor). */
export function MessageBubble({ message, className }: { message: ChatMessage; className?: string }) {
return (
<>
{message.text}
{message.streaming ? : null}
{message.role === 'assistant' && message.citations?.length ? : null}
>
);
}
/** The scrollable message column. Renders an optional greeting before any messages. */
export function MessageList({ messages, greeting, className }: { messages: ChatMessage[]; greeting?: string; className?: string }) {
return (
{messages.length === 0 && greeting ?
{greeting}
: null}
{messages.map((m) => (
))}
);
}
/** The input row. Calls `onSend` on submit / Enter (Shift+Enter inserts a newline). */
export function Composer({
onSend,
disabled,
placeholder = 'Type a message…',
className,
}: {
onSend: (text: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
}) {
const [value, setValue] = useState('');
const submit = () => {
const text = value.trim();
if (!text || disabled) return;
setValue('');
onSend(text);
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
submit();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
return (
);
}
const STATUS_LABEL: Record = {
idle: 'Idle',
connecting: 'Connecting…',
ready: 'Online',
error: 'Connection error',
closed: 'Disconnected',
};
/** A small connection-status label. */
export function ConnectionStatusLabel({ status, className }: { status: ConnectionStatus; className?: string }) {
return (
{STATUS_LABEL[status]}
);
}