137 lines
4.0 KiB
TypeScript
137 lines
4.0 KiB
TypeScript
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
|
import { MessageBubble, type MessageBubbleProps } from './MessageBubble.js';
|
|
import { PromptComposer } from './PromptComposer.js';
|
|
import { useChat } from './useChat.js';
|
|
import type { Message, UseChatOptions } from './types.js';
|
|
|
|
export interface ChatStreamProps extends UseChatOptions {
|
|
/** Render slot above the messages list (e.g. a header or model picker). */
|
|
header?: ReactNode;
|
|
/** Render slot below the composer (e.g. a disclaimer). */
|
|
footer?: ReactNode;
|
|
/** Slot to render when there are no messages yet. */
|
|
emptyState?: ReactNode;
|
|
/** Per-message custom renderer. Defaults to <MessageBubble />. */
|
|
renderMessage?: (message: Message) => ReactNode;
|
|
/** Forwarded to every <MessageBubble> when default renderer is used. */
|
|
messageProps?: Omit<MessageBubbleProps, 'message'>;
|
|
/** Placeholder shown inside <PromptComposer>. */
|
|
placeholder?: string;
|
|
/** Tailwind escape hatch. */
|
|
className?: string;
|
|
/** Inline style escape hatch. */
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<ChatStream>` — opinionated composition of `useChat` +
|
|
* `<MessageBubble>` + `<PromptComposer>`. The 80% path for any chat UI
|
|
* in a ByteLyst product. Drop it in, point it at an endpoint, done.
|
|
*
|
|
* Behavior:
|
|
* - Auto-scrolls to bottom on new message (sticky-bottom pattern).
|
|
* - Shows Stop affordance while streaming.
|
|
* - Surfaces transport errors inline below the messages list.
|
|
* - Renders `emptyState` when there are zero messages.
|
|
*
|
|
* For more control, compose the primitives yourself — `useChat` returns
|
|
* everything you need.
|
|
*/
|
|
export function ChatStream({
|
|
header,
|
|
footer,
|
|
emptyState,
|
|
renderMessage,
|
|
messageProps,
|
|
placeholder,
|
|
className,
|
|
style,
|
|
...chatOptions
|
|
}: ChatStreamProps) {
|
|
const chat = useChat(chatOptions);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Sticky-bottom scroll — only auto-scroll if the user was already at
|
|
// (or near) the bottom. Otherwise their reading position is preserved.
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
if (distanceFromBottom < 80) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, [chat.messages]);
|
|
|
|
const messages = chat.messages;
|
|
const showEmpty = messages.length === 0 && !chat.isLoading;
|
|
|
|
return (
|
|
<div
|
|
data-testid="bl-chat-stream"
|
|
className={className}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
minHeight: 0,
|
|
gap: 'var(--bl-space-3, 12px)',
|
|
...style,
|
|
}}
|
|
>
|
|
{header}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="bl-chat-messages"
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 'var(--bl-space-3, 12px)',
|
|
padding: 'var(--bl-space-2, 8px)',
|
|
}}
|
|
>
|
|
{showEmpty
|
|
? emptyState
|
|
: messages.map(message =>
|
|
renderMessage ? (
|
|
<span key={message.id}>{renderMessage(message)}</span>
|
|
) : (
|
|
<MessageBubble key={message.id} message={message} {...messageProps} />
|
|
),
|
|
)}
|
|
|
|
{chat.error && (
|
|
<div
|
|
role="alert"
|
|
data-testid="bl-chat-error"
|
|
style={{
|
|
padding: 'var(--bl-space-3, 12px)',
|
|
borderRadius: 'var(--bl-radius-card, 12px)',
|
|
background: 'var(--bl-danger-muted, rgba(239,68,68,0.1))',
|
|
color: 'var(--bl-danger, #ef4444)',
|
|
fontSize: '0.85rem',
|
|
}}
|
|
>
|
|
{chat.error.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<PromptComposer
|
|
value={chat.input}
|
|
onChange={chat.handleInputChange}
|
|
onSubmit={chat.handleSubmit}
|
|
disabled={chat.isLoading}
|
|
showStop={chat.isLoading}
|
|
onStop={chat.stop}
|
|
placeholder={placeholder}
|
|
/>
|
|
|
|
{footer}
|
|
</div>
|
|
);
|
|
}
|