learning_ai_common_plat/packages/ai-ui/src/ChatStream.tsx
2026-05-27 12:07:23 -07:00

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>
);
}