feat(ai-ui): Wave 2 MVP @bytelyst/ai-ui@0.1.0

This commit is contained in:
saravanakumardb1 2026-05-27 12:07:23 -07:00
parent ed5fb707ad
commit c9a7f905af
15 changed files with 4575 additions and 8731 deletions

View File

@ -59,6 +59,7 @@ jobs:
--filter @bytelyst/quick-actions \
--filter @bytelyst/react-auth \
--filter @bytelyst/dashboard-shell \
--filter @bytelyst/ai-ui \
run build
- name: Enforce size budgets

View File

@ -65,4 +65,11 @@ module.exports = [
limit: '30 KB',
gzip: true,
},
// ── AI-native UI (35 KB — streaming + parsing is heavy) ─────────
{
name: '@bytelyst/ai-ui',
path: 'packages/ai-ui/dist/index.js',
limit: '35 KB',
gzip: true,
},
];

107
packages/ai-ui/README.md Normal file
View File

@ -0,0 +1,107 @@
# `@bytelyst/ai-ui`
AI-native UI primitives for ByteLyst products. The flagship deliverable
of Wave 2 in `learning_ai_uxui_web/docs/ROADMAP_2026.md`.
> **Status:** `0.1.0` MVP — 3 components + 1 hook + 1 utility.
> Subsequent 0.x bumps add `<ToolCallCard>`, `<CitationChip>`,
> `<AgentTimeline>`, `<ModelPicker>`, `<ToolPalette>`, and the
> `useToolCalls` / `usePromptHistory` / `useTokenCount` hooks.
## Quick start
```tsx
import { ChatStream } from '@bytelyst/ai-ui';
export function ChatPage() {
return (
<div style={{ height: '70vh' }}>
<ChatStream
endpoint="/api/chat"
placeholder="Ask anything…"
emptyState={<EmptyHint />}
/>
</div>
);
}
```
## Architectural decisions
Per `learning_ai_common_plat/docs/ROADMAP_2026_DECISIONS.md`:
- **§10 hook shape** — `useChat()` returns the **same shape as Vercel
AI SDK's `useChat`**. Drop-in compatible for any engineer who has
already written a chat UI with the AI SDK.
- **Transport is pluggable** — products inject `endpoint`,
`streamProtocol`, `fetcher`, or `headers`. We are not locked to
Vercel's SSE wire format.
## Components
### `<ChatStream>`
Opinionated composition of `useChat` + `<MessageBubble>` +
`<PromptComposer>`. Auto-scrolls (sticky-bottom), shows Stop while
streaming, surfaces transport errors inline.
### `<MessageBubble>`
Single chat message atom. Variants by role (user / assistant / system /
tool). Built-in "thinking" indicator while streaming with no content
yet. Honors `--bl-*` design tokens.
### `<PromptComposer>`
Multi-line input. Enter submits, Shift+Enter newlines, Cmd/Ctrl+Enter
alternates submit. Auto-grows up to `maxLines`. Slot for leading
actions (slash-commands etc.).
## Hooks
### `useChat(options?)`
Vercel-AI-SDK-shaped:
```ts
const {
messages, input, setInput, handleInputChange, handleSubmit,
append, isLoading, error, stop, reload, setMessages
} = useChat({
endpoint: '/api/chat',
streamProtocol: 'text', // 'text' | 'sse' | 'data'
initialMessages: [],
onFinish: (msg) => console.log('done', msg),
onError: (err) => console.error(err),
});
```
### `streamText(response, protocol?, signal?)`
Low-level async generator that yields text deltas from a streaming
HTTP response. Supports `'text'`, `'sse'`, and `'data'` protocols.
## Wire protocols
| Protocol | Body format | Use when |
|---|---|---|
| `'text'` (default) | Plain text, streamed chunk-by-chunk | Any backend that pipes an LLM stream directly |
| `'sse'` | Server-Sent Events with `data: ...` frames | OpenAI-compatible, Anthropic, most HF inference |
| `'data'` | Vercel AI SDK "data stream" prefix protocol | Backends using `streamText()` from `ai/server` |
For tool calls / citations / images, use `'sse'` or `'data'` once the
matching components ship in `0.2.x`.
## Tests
```sh
pnpm --filter @bytelyst/ai-ui test
```
## Roadmap
- `0.2.0``<ToolCallCard>`, `<CitationChip>`, `useToolCalls()`
- `0.3.0``<AgentTimeline>`, `<ModelPicker>`
- `0.4.0``<ToolPalette>` with MCP discovery
- `0.5.0``<Markdown>` + `<CodeDiff>` (Wave 2 stretch)
- `1.0.0` — API freeze when 3+ products consume in production

View File

@ -0,0 +1,36 @@
{
"name": "@bytelyst/ai-ui",
"version": "0.1.0",
"type": "module",
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,136 @@
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>
);
}

View File

@ -0,0 +1,163 @@
import type { CSSProperties, ReactNode } from 'react';
import type { Message } from './types.js';
export interface MessageBubbleProps {
message: Message;
/**
* Optional action buttons rendered in the footer (copy / retry /
* feedback). Receives the message so each handler can be wired
* statelessly.
*/
actions?: (message: Message) => ReactNode;
/** Override the rendered role label (default: capitalized role). */
roleLabel?: string;
/** Tailwind-friendly className escape hatch. */
className?: string;
/** Inline style escape hatch. */
style?: CSSProperties;
}
/**
* Atom: a single chat message bubble. Variants by role (user / assistant /
* system / tool). Honors `--bl-*` design tokens so it inherits whatever
* scheme + density is active on the surrounding `<html>`.
*
* The "thinking" indicator (three pulsing dots) appears for assistant
* messages while `isStreaming` is true and content is empty i.e. the
* window between request-sent and first-token-received.
*/
export function MessageBubble({
message,
actions,
roleLabel,
className,
style,
}: MessageBubbleProps) {
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
const isSystem = message.role === 'system';
const isTool = message.role === 'tool';
const thinking = isAssistant && message.isStreaming && !message.content;
// Variant colors via design tokens — tone shifts so the eye can
// separate user from assistant without relying on alignment alone.
const variantStyle: CSSProperties = isUser
? {
background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
color: 'var(--bl-text-primary, inherit)',
marginInlineStart: 'auto',
borderInlineEnd: '1px solid var(--bl-accent, #6366f1)',
}
: isAssistant
? {
background: 'var(--bl-surface-card, #fff)',
color: 'var(--bl-text-primary, inherit)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
}
: isSystem
? {
background: 'transparent',
color: 'var(--bl-text-tertiary, #888)',
fontStyle: 'italic',
border: '1px dashed var(--bl-border-subtle, rgba(0,0,0,0.06))',
}
: /* tool */ {
background: 'var(--bl-surface-muted, #f6f6f6)',
color: 'var(--bl-text-secondary, #555)',
fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)',
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
};
return (
<article
data-testid={`bl-message-${message.id}`}
data-role={message.role}
data-streaming={message.isStreaming ? 'true' : 'false'}
className={className}
style={{
maxWidth: isSystem || isTool ? '100%' : '85%',
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
borderRadius: 'var(--bl-radius-card, 12px)',
fontSize: '0.95rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...variantStyle,
...style,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '0.75rem',
color: 'var(--bl-text-tertiary, #888)',
marginBottom: message.content || thinking ? 'var(--bl-space-1, 4px)' : 0,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
<span>{roleLabel ?? capitalize(message.role)}</span>
{message.createdAt && (
<time dateTime={message.createdAt.toISOString()}>
{formatTime(message.createdAt)}
</time>
)}
</header>
{thinking ? <ThinkingDots /> : <div>{message.content}</div>}
{actions && (
<footer
style={{
marginTop: 'var(--bl-space-2, 8px)',
display: 'flex',
gap: 'var(--bl-space-2, 8px)',
}}
>
{actions(message)}
</footer>
)}
</article>
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function formatTime(d: Date): string {
// 24-h HH:MM — locale-agnostic so visual-regression snapshots stay stable.
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
function ThinkingDots() {
// Three blinking dots; sequenced with CSS-only animation-delay so we
// don't depend on @bytelyst/motion (which lands in Wave 4).
const dot: CSSProperties = {
display: 'inline-block',
width: 6,
height: 6,
margin: '0 2px',
borderRadius: '50%',
background: 'currentColor',
opacity: 0.4,
animation: 'bl-ai-pulse 1.2s infinite ease-in-out',
};
return (
<span
aria-label="Assistant is typing"
role="status"
style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 0' }}
>
<span style={{ ...dot, animationDelay: '0ms' }} />
<span style={{ ...dot, animationDelay: '180ms' }} />
<span style={{ ...dot, animationDelay: '360ms' }} />
<style>{`@keyframes bl-ai-pulse{0%,80%,100%{opacity:.25;transform:scale(0.85)}40%{opacity:1;transform:scale(1)}}`}</style>
</span>
);
}

View File

@ -0,0 +1,209 @@
import {
forwardRef,
useImperativeHandle,
useRef,
type CSSProperties,
type FormEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
export interface PromptComposerProps {
/** Controlled input value. */
value: string;
/** Standard onChange — accepts the Vercel-AI-SDK-shaped event. */
onChange: (e: { target: { value: string } }) => void;
/** Called on Enter (without Shift) or send-button click. */
onSubmit: (e?: { preventDefault?: () => void }) => void | Promise<void>;
/** Disable input + submit (e.g. while streaming). */
disabled?: boolean;
/** Placeholder when empty. */
placeholder?: string;
/** Render a Stop button instead of Send (e.g. during streaming). */
showStop?: boolean;
/** Called when the Stop button is clicked. */
onStop?: () => void;
/** Auto-grow up to this many lines before scrolling. Default 8. */
maxLines?: number;
/** Extra controls rendered to the left of the send button. */
leadingActions?: ReactNode;
/** Tailwind escape hatch. */
className?: string;
/** Inline style escape hatch. */
style?: CSSProperties;
/** ARIA label for the textarea. */
ariaLabel?: string;
}
export interface PromptComposerHandle {
focus: () => void;
clear: () => void;
}
/**
* Multi-line prompt input with submit-on-Enter + Shift-Enter newline,
* auto-growing textarea, optional Stop affordance during streaming, and
* slots for slash-commands or other leading actions (Wave 2 extension).
*
* Keyboard:
* Enter submit
* Shift+Enter newline
* Cmd/Ctrl+Enter submit (alternate; useful when textarea grows)
* Escape blur
*/
export const PromptComposer = forwardRef<PromptComposerHandle, PromptComposerProps>(
function PromptComposer(
{
value,
onChange,
onSubmit,
disabled = false,
placeholder = 'Send a message…',
showStop = false,
onStop,
maxLines = 8,
leadingActions,
className,
style,
ariaLabel = 'Message',
},
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => textareaRef.current?.focus(),
clear: () => {
onChange({ target: { value: '' } });
textareaRef.current?.focus();
},
}),
[onChange],
);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.currentTarget.blur();
return;
}
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter';
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
if ((isPlainEnter || isCmdEnter) && !disabled && value.trim()) {
e.preventDefault();
void onSubmit({ preventDefault: () => undefined });
}
};
const handleFormSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!disabled && value.trim()) void onSubmit(e);
};
// Compute auto-grow height — line-height (1.5) × maxLines, capped.
const maxHeight = `calc(1.5em * ${maxLines} + var(--bl-space-3, 12px) * 2)`;
return (
<form
data-testid="bl-prompt-composer"
onSubmit={handleFormSubmit}
className={className}
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 'var(--bl-space-2, 8px)',
padding: 'var(--bl-space-2, 8px)',
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
borderRadius: 'var(--bl-radius-card, 12px)',
background: 'var(--bl-surface-card, #fff)',
...style,
}}
>
{leadingActions && (
<div
data-testid="bl-prompt-leading"
style={{ display: 'flex', alignItems: 'center', gap: 'var(--bl-space-1, 4px)' }}
>
{leadingActions}
</div>
)}
<textarea
ref={textareaRef}
data-testid="bl-prompt-textarea"
aria-label={ariaLabel}
value={value}
placeholder={placeholder}
disabled={disabled}
rows={1}
onChange={onChange}
onKeyDown={handleKeyDown}
// Auto-grow: reset, then expand to scrollHeight on each keystroke.
onInput={e => {
const ta = e.currentTarget;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
}}
style={{
flex: 1,
resize: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--bl-text-primary, inherit)',
fontFamily: 'inherit',
fontSize: '0.95rem',
lineHeight: 1.5,
padding: 'var(--bl-space-2, 8px)',
maxHeight,
overflowY: 'auto',
}}
/>
{showStop ? (
<button
type="button"
data-testid="bl-prompt-stop"
onClick={() => onStop?.()}
aria-label="Stop generating"
style={sendButtonStyle('var(--bl-danger, #ef4444)')}
>
</button>
) : (
<button
type="submit"
data-testid="bl-prompt-send"
disabled={disabled || !value.trim()}
aria-label="Send message"
style={{
...sendButtonStyle('var(--bl-accent, #6366f1)'),
opacity: disabled || !value.trim() ? 0.4 : 1,
cursor: disabled || !value.trim() ? 'not-allowed' : 'pointer',
}}
>
</button>
)}
</form>
);
},
);
function sendButtonStyle(bg: string): CSSProperties {
return {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
border: 'none',
borderRadius: 'var(--bl-radius-pill, 999px)',
background: bg,
color: 'var(--bl-accent-foreground, #fff)',
fontSize: 16,
cursor: 'pointer',
transition: 'transform 80ms ease-out, opacity 120ms ease-out',
};
}

View File

@ -0,0 +1,379 @@
import * as React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, render, screen, fireEvent, renderHook, waitFor } from '@testing-library/react';
import { MessageBubble } from '../MessageBubble.js';
import { PromptComposer } from '../PromptComposer.js';
import { ChatStream } from '../ChatStream.js';
import { useChat } from '../useChat.js';
import { streamText } from '../useStreamingText.js';
import type { Message } from '../types.js';
// ── Test helpers ─────────────────────────────────────────────────────────────
/**
* Build a streaming Response that yields each chunk after a tick, so the
* hook's async-iteration loop actually runs through multiple React updates.
*/
function makeStreamingResponse(chunks: string[], ok = true, status = 200): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
for (const chunk of chunks) {
await Promise.resolve(); // microtask yield
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
return new Response(body, {
status,
headers: { 'Content-Type': 'text/plain' },
}) as Response & { ok: boolean };
}
// ── MessageBubble ────────────────────────────────────────────────────────────
describe('MessageBubble', () => {
beforeEach(() => cleanup());
it('renders user message with content', () => {
const msg: Message = { id: '1', role: 'user', content: 'Hi there' };
render(<MessageBubble message={msg} />);
expect(screen.getByTestId('bl-message-1')).toBeDefined();
expect(screen.getByText('Hi there')).toBeDefined();
});
it('renders the role label (capitalized) by default', () => {
const msg: Message = { id: '1', role: 'assistant', content: 'Hello' };
render(<MessageBubble message={msg} />);
expect(screen.getByText('Assistant')).toBeDefined();
});
it('shows thinking dots while assistant is streaming with empty content', () => {
const msg: Message = { id: '1', role: 'assistant', content: '', isStreaming: true };
render(<MessageBubble message={msg} />);
expect(screen.getByRole('status', { name: /typing/i })).toBeDefined();
});
it('does NOT show thinking dots for streaming user messages', () => {
// User messages don't stream — guard against accidental indicator.
const msg: Message = { id: '1', role: 'user', content: '', isStreaming: true };
render(<MessageBubble message={msg} />);
expect(screen.queryByRole('status')).toBeNull();
});
it('renders custom actions slot', () => {
const msg: Message = { id: '1', role: 'assistant', content: 'X' };
render(
<MessageBubble
message={msg}
actions={m => <button data-testid="act">copy {m.id}</button>}
/>,
);
expect(screen.getByTestId('act').textContent).toBe('copy 1');
});
it('exposes data-role and data-streaming on the article', () => {
const msg: Message = { id: '1', role: 'tool', content: '{}', isStreaming: false };
render(<MessageBubble message={msg} />);
const el = screen.getByTestId('bl-message-1');
expect(el.getAttribute('data-role')).toBe('tool');
expect(el.getAttribute('data-streaming')).toBe('false');
});
});
// ── PromptComposer ───────────────────────────────────────────────────────────
describe('PromptComposer', () => {
beforeEach(() => cleanup());
it('renders textarea + send button', () => {
render(<PromptComposer value="" onChange={() => {}} onSubmit={() => {}} />);
expect(screen.getByTestId('bl-prompt-textarea')).toBeDefined();
expect(screen.getByTestId('bl-prompt-send')).toBeDefined();
});
it('propagates input into the controlled value via onChange', () => {
// Stateful wrapper exercises the actual controlled-input round-trip,
// which is more meaningful than asserting on the synthetic event shape
// (and avoids a happy-dom + React 19 controlled-input quirk).
function Wrapper() {
const [v, setV] = React.useState('');
return (
<>
<PromptComposer
value={v}
onChange={e => setV(e.target.value)}
onSubmit={() => {}}
/>
<output data-testid="echo">{v}</output>
</>
);
}
render(<Wrapper />);
const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
fireEvent.input(ta, { target: { value: 'hello' } });
expect(screen.getByTestId('echo').textContent).toBe('hello');
});
it('submits on plain Enter when value is non-empty', () => {
const onSubmit = vi.fn();
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
expect(onSubmit).toHaveBeenCalledOnce();
});
it('does NOT submit on Shift+Enter (newline)', () => {
const onSubmit = vi.fn();
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
key: 'Enter',
shiftKey: true,
});
expect(onSubmit).not.toHaveBeenCalled();
});
it('submits on Cmd+Enter', () => {
const onSubmit = vi.fn();
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
key: 'Enter',
metaKey: true,
});
expect(onSubmit).toHaveBeenCalledOnce();
});
it('does not submit when disabled', () => {
const onSubmit = vi.fn();
render(
<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} disabled />,
);
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
expect(onSubmit).not.toHaveBeenCalled();
});
it('shows Stop instead of Send when showStop=true and calls onStop', () => {
const onStop = vi.fn();
render(
<PromptComposer
value=""
onChange={() => {}}
onSubmit={() => {}}
showStop
onStop={onStop}
/>,
);
expect(screen.queryByTestId('bl-prompt-send')).toBeNull();
fireEvent.click(screen.getByTestId('bl-prompt-stop'));
expect(onStop).toHaveBeenCalledOnce();
});
it('blurs on Escape', () => {
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={() => {}} />);
const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
ta.focus();
expect(document.activeElement).toBe(ta);
fireEvent.keyDown(ta, { key: 'Escape' });
expect(document.activeElement).not.toBe(ta);
});
});
// ── streamText (protocol parsing) ────────────────────────────────────────────
describe('streamText', () => {
it('yields plain text chunks verbatim', async () => {
const r = makeStreamingResponse(['Hel', 'lo ', 'world']);
const out: string[] = [];
for await (const c of streamText(r, 'text')) out.push(c);
expect(out.join('')).toBe('Hello world');
});
it('parses SSE data: lines and skips [DONE]', async () => {
const r = makeStreamingResponse([
'data: "Hello"\n\n',
'data: " there"\n\n',
'data: [DONE]\n\n',
]);
const out: string[] = [];
for await (const c of streamText(r, 'sse')) out.push(c);
expect(out.join('')).toBe('Hello there');
});
it('unwraps JSON SSE objects with content/text/delta fields', async () => {
const r = makeStreamingResponse([
'data: {"content":"A"}\n\n',
'data: {"text":"B"}\n\n',
'data: {"delta":"C"}\n\n',
]);
const out: string[] = [];
for await (const c of streamText(r, 'sse')) out.push(c);
expect(out.join('')).toBe('ABC');
});
it("parses Vercel 'data' prefix protocol 0: text frames", async () => {
const r = makeStreamingResponse(['0:"Hi"\n', '0:" there"\n', '2:[{"x":1}]\n']);
const out: string[] = [];
for await (const c of streamText(r, 'data')) out.push(c);
expect(out.join('')).toBe('Hi there');
});
it('terminates cleanly when the abort signal fires mid-stream', async () => {
const ac = new AbortController();
const r = makeStreamingResponse(['one ', 'two ', 'three']);
const out: string[] = [];
let i = 0;
for await (const c of streamText(r, 'text', ac.signal)) {
out.push(c);
if (++i === 1) ac.abort();
}
expect(out.length).toBe(1);
});
});
// ── useChat ──────────────────────────────────────────────────────────────────
describe('useChat', () => {
it('starts with empty messages by default', () => {
const { result } = renderHook(() => useChat());
expect(result.current.messages).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.input).toBe('');
});
it('honors initialMessages + initialInput', () => {
const init: Message[] = [{ id: 'a', role: 'user', content: 'seed' }];
const { result } = renderHook(() =>
useChat({ initialMessages: init, initialInput: 'draft' }),
);
expect(result.current.messages).toEqual(init);
expect(result.current.input).toBe('draft');
});
it('handleSubmit POSTs the conversation and streams a reply', async () => {
const onFinish = vi.fn();
const fetcher = vi.fn(async () =>
makeStreamingResponse(['Hello', ' ', 'world']),
);
const { result } = renderHook(() =>
useChat({ fetcher: fetcher as unknown as typeof fetch, onFinish }),
);
act(() => result.current.setInput('Hi'));
await act(async () => {
await result.current.handleSubmit();
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(fetcher).toHaveBeenCalledOnce();
const args = (fetcher.mock.calls[0] as unknown) as [string, RequestInit];
expect(args[0]).toBe('/api/chat');
expect(args[1].method).toBe('POST');
expect(result.current.messages.length).toBe(2);
expect(result.current.messages[0]).toMatchObject({ role: 'user', content: 'Hi' });
expect(result.current.messages[1]).toMatchObject({
role: 'assistant',
content: 'Hello world',
isStreaming: false,
});
expect(onFinish).toHaveBeenCalledOnce();
expect(result.current.input).toBe('');
});
it('surfaces transport errors via the error field', async () => {
const fetcher = vi.fn(async () => new Response('boom', { status: 500 }));
const onError = vi.fn();
const { result } = renderHook(() =>
useChat({ fetcher: fetcher as unknown as typeof fetch, onError }),
);
act(() => result.current.setInput('die'));
await act(async () => {
await result.current.handleSubmit();
});
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
expect(result.current.error?.message).toMatch(/500/);
expect(onError).toHaveBeenCalledOnce();
expect(result.current.isLoading).toBe(false);
});
it('does not submit empty input', async () => {
const fetcher = vi.fn();
const { result } = renderHook(() =>
useChat({ fetcher: fetcher as unknown as typeof fetch }),
);
await act(async () => {
await result.current.handleSubmit();
});
expect(fetcher).not.toHaveBeenCalled();
});
it('stop() aborts the in-flight request', async () => {
let sawAbort = false;
const fetcher = vi.fn(async (_url: RequestInfo, init?: RequestInit) => {
init?.signal?.addEventListener('abort', () => {
sawAbort = true;
});
// Keep the stream open indefinitely until aborted.
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('partial'));
// Never closes.
},
});
return new Response(body, { status: 200 });
});
const { result } = renderHook(() =>
useChat({ fetcher: fetcher as unknown as typeof fetch }),
);
act(() => result.current.setInput('long'));
void result.current.handleSubmit();
await waitFor(() => expect(result.current.isLoading).toBe(true));
act(() => result.current.stop());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(sawAbort).toBe(true);
// Trailing assistant bubble should have isStreaming cleared.
const trailing = result.current.messages.at(-1);
expect(trailing?.isStreaming).toBeFalsy();
});
});
// ── ChatStream (integration) ─────────────────────────────────────────────────
describe('ChatStream', () => {
beforeEach(() => cleanup());
it('renders empty state when there are no messages', () => {
const fetcher = vi.fn();
render(
<ChatStream
fetcher={fetcher as unknown as typeof fetch}
emptyState={<div data-testid="empty">Say hi 👋</div>}
/>,
);
expect(screen.getByTestId('empty')).toBeDefined();
});
it('end-to-end: type → submit → see user + assistant bubble', async () => {
const fetcher = vi.fn(async () =>
makeStreamingResponse(['Hi', ' there!']),
);
render(
<ChatStream fetcher={fetcher as unknown as typeof fetch} />,
);
fireEvent.change(screen.getByTestId('bl-prompt-textarea'), {
target: { value: 'hello' },
});
fireEvent.click(screen.getByTestId('bl-prompt-send'));
await waitFor(() => {
expect(screen.getByText('hello')).toBeDefined();
expect(screen.getByText('Hi there!')).toBeDefined();
});
});
});

View File

@ -0,0 +1,36 @@
/**
* @bytelyst/ai-ui AI-native UI primitives.
*
* MVP exports (Wave 2 v0.1.0):
* - <ChatStream> composed chat surface (the 80% path)
* - <MessageBubble> single message atom
* - <PromptComposer> multi-line input with submit-on-Enter
* - useChat() Vercel AI SDK-shaped hook
* - streamText() low-level SSE/text/data stream consumer
*
* Coming in 0.2.x (per ROADMAP §Wave 2):
* - <ToolCallCard>, <CitationChip>, <AgentTimeline>
* - <ModelPicker>, <ToolPalette> (MCP discovery)
* - useToolCalls(), usePromptHistory(), useTokenCount()
*/
export { ChatStream } from './ChatStream.js';
export type { ChatStreamProps } from './ChatStream.js';
export { MessageBubble } from './MessageBubble.js';
export type { MessageBubbleProps } from './MessageBubble.js';
export { PromptComposer } from './PromptComposer.js';
export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer.js';
export { useChat } from './useChat.js';
export { streamText } from './useStreamingText.js';
export type {
ChatTransportOptions,
Message,
MessageRole,
StreamProtocol,
UseChatHelpers,
UseChatOptions,
} from './types.js';

View File

@ -0,0 +1,88 @@
/**
* Shared types for @bytelyst/ai-ui.
*
* Message shape is the **subset of the Vercel AI SDK `Message` type** that
* we commit to as stable. Per `docs/ROADMAP_2026_DECISIONS.md` §10 we adopt
* the AI SDK hook shape but abstract the transport additional fields
* Vercel emits (e.g. `toolInvocations`, `experimental_attachments`) may be
* exposed in later minor versions when the corresponding components
* (`<ToolCallCard>`, `<AttachmentPreview>`) land.
*/
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
export interface Message {
/** Stable id — used as React key and for deduplication. */
id: string;
role: MessageRole;
/** Plain text content. Markdown rendering is opt-in via component props. */
content: string;
/** Wall-clock time the message was authored on the client. */
createdAt?: Date;
/** When true, indicates content is still streaming in (assistant only). */
isStreaming?: boolean;
}
/**
* Wire protocol for streamed responses. The default `'text'` protocol
* expects a plain `text/plain` body with the assistant message streamed
* token-by-token (or chunk-by-chunk). Other protocols are reserved.
*/
export type StreamProtocol = 'text' | 'sse' | 'data';
export interface ChatTransportOptions {
/** Endpoint that accepts POST { messages } and returns a stream. */
endpoint?: string;
/**
* Wire format. Defaults to `'text'` which works with any backend
* returning a streaming text response including LysnrAI, JarvisJr,
* or any FastAPI/Express endpoint that pipes an LLM stream.
*/
streamProtocol?: StreamProtocol;
/**
* Optional fetch override. Useful for injecting auth headers, mock
* transports under test (MSW), or proxying via a service-worker.
* Defaults to the global `fetch`.
*/
fetcher?: typeof fetch;
/** Request headers merged on every send. */
headers?: Record<string, string>;
}
export interface UseChatOptions extends ChatTransportOptions {
/** Seed conversation. Re-evaluated only on mount. */
initialMessages?: Message[];
/** Seed the input text. */
initialInput?: string;
/** Called whenever a new assistant message completes (not on each token). */
onFinish?: (message: Message) => void;
/** Called on transport-level error. */
onError?: (error: Error) => void;
/**
* Optional id generator. Defaults to a monotonic counter so two
* concurrent hooks in the same React tree never collide. Provide one
* if you need cryptographic ids (e.g. for persistence by id).
*/
generateId?: () => string;
}
export interface UseChatHelpers {
messages: Message[];
input: string;
setInput: (value: string) => void;
handleInputChange: (e: { target: { value: string } }) => void;
/** Submit current `input` as a user message and start streaming a reply. */
handleSubmit: (e?: { preventDefault?: () => void }) => Promise<void>;
/** Programmatic equivalent of `handleSubmit`. */
append: (message: Omit<Message, 'id' | 'createdAt'> & Partial<Pick<Message, 'id'>>) => Promise<void>;
/** True while a request is in flight (from POST start to stream end). */
isLoading: boolean;
/** Latest transport-level error, if any. Cleared on next send. */
error: Error | undefined;
/** Abort the in-flight request (if any) and keep partial assistant text. */
stop: () => void;
/** Re-run the last user turn (drops the last assistant message first). */
reload: () => Promise<void>;
/** Replace the entire message list. */
setMessages: (messages: Message[]) => void;
}

View File

@ -0,0 +1,224 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { Message, UseChatHelpers, UseChatOptions } from './types.js';
import { streamText } from './useStreamingText.js';
let __id = 0;
const defaultGenerateId = (): string => `m-${Date.now().toString(36)}-${++__id}`;
/**
* `useChat` primary hook for chat UIs.
*
* Adopts the Vercel AI SDK `useChat` return shape (per
* `learning_ai_common_plat/docs/ROADMAP_2026_DECISIONS.md` §10) so any
* React engineer familiar with the AI SDK can drop straight in. The
* transport is **pluggable** products supply `endpoint`,
* `streamProtocol`, `fetcher`, or `headers` to talk to OpenAI directly,
* Anthropic, an LysnrAI service, or any backend that streams text.
*
* @example
* ```tsx
* const { messages, input, handleInputChange, handleSubmit, isLoading } =
* useChat({ endpoint: '/api/chat' });
*
* return (
* <>
* {messages.map(m => <MessageBubble key={m.id} message={m} />)}
* <PromptComposer value={input} onChange={handleInputChange}
* onSubmit={handleSubmit} disabled={isLoading} />
* </>
* );
* ```
*/
export function useChat(options: UseChatOptions = {}): UseChatHelpers {
const {
initialMessages = [],
initialInput = '',
endpoint = '/api/chat',
streamProtocol = 'text',
fetcher,
headers,
onFinish,
onError,
generateId = defaultGenerateId,
} = options;
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [input, setInput] = useState(initialInput);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null);
const handleInputChange = useCallback(
(e: { target: { value: string } }) => setInput(e.target.value),
[],
);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
// Drop the trailing isStreaming flag so the bubble settles into
// its final state even though we stopped mid-stream.
setMessages(prev =>
prev.map(m => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
setIsLoading(false);
}, []);
/**
* Core send loop appends `userMessage`, POSTs the conversation,
* streams the response into a new assistant message, and updates
* loading/error state along the way.
*/
const sendMessages = useCallback(
async (next: Message[]) => {
// Cancel any in-flight request before starting a new one.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const assistantId = generateId();
setMessages([
...next,
{
id: assistantId,
role: 'assistant',
content: '',
createdAt: new Date(),
isStreaming: true,
},
]);
setIsLoading(true);
setError(undefined);
const doFetch = fetcher ?? globalThis.fetch.bind(globalThis);
let finalContent = '';
try {
const response = await doFetch(endpoint, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ messages: next }),
});
if (!response.ok) {
throw new Error(`Chat endpoint returned ${response.status}`);
}
for await (const delta of streamText(
response,
streamProtocol,
controller.signal,
)) {
finalContent += delta;
// Functional update so concurrent setMessages calls don't lose work.
setMessages(prev =>
prev.map(m =>
m.id === assistantId ? { ...m, content: m.content + delta } : m,
),
);
}
// Settle isStreaming flag.
setMessages(prev =>
prev.map(m =>
m.id === assistantId ? { ...m, isStreaming: false } : m,
),
);
if (onFinish && !controller.signal.aborted) {
onFinish({
id: assistantId,
role: 'assistant',
content: finalContent,
isStreaming: false,
});
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
// User-initiated stop — already handled in `stop()`.
return;
}
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
// Mark the partial assistant message as no longer streaming so
// the bubble's "thinking" indicator clears.
setMessages(prev =>
prev.map(m =>
m.id === assistantId ? { ...m, isStreaming: false } : m,
),
);
onError?.(e);
} finally {
if (abortRef.current === controller) abortRef.current = null;
setIsLoading(false);
}
},
[endpoint, fetcher, generateId, headers, onError, onFinish, streamProtocol],
);
const append = useCallback<UseChatHelpers['append']>(
async incoming => {
const message: Message = {
id: incoming.id ?? generateId(),
role: incoming.role,
content: incoming.content,
createdAt: new Date(),
};
const next = [...messages, message];
setMessages(next);
await sendMessages(next);
},
[generateId, messages, sendMessages],
);
const handleSubmit = useCallback<UseChatHelpers['handleSubmit']>(
async e => {
e?.preventDefault?.();
const trimmed = input.trim();
if (!trimmed || isLoading) return;
setInput('');
await append({ role: 'user', content: trimmed });
},
[append, input, isLoading],
);
const reload = useCallback<UseChatHelpers['reload']>(async () => {
// Find the last user message; drop everything after it.
const lastUserIndex = [...messages]
.reverse()
.findIndex(m => m.role === 'user');
if (lastUserIndex === -1) return;
const cutoff = messages.length - lastUserIndex; // exclusive
const truncated = messages.slice(0, cutoff);
setMessages(truncated);
await sendMessages(truncated);
}, [messages, sendMessages]);
return useMemo(
() => ({
messages,
input,
setInput,
handleInputChange,
handleSubmit,
append,
isLoading,
error,
stop,
reload,
setMessages,
}),
[
messages,
input,
handleInputChange,
handleSubmit,
append,
isLoading,
error,
stop,
reload,
],
);
}

View File

@ -0,0 +1,130 @@
import type { StreamProtocol } from './types.js';
/**
* Consume a streaming HTTP response body and yield text deltas.
*
* - `'text'` body is plain text; each chunk yielded as-is.
* - `'sse'` body is Server-Sent Events; we parse `data: ...` lines.
* `[DONE]` and empty data frames are skipped. JSON frames
* that include a `content` or `text` field are unwrapped.
* - `'data'` Vercel AI SDK "data stream" prefix protocol. Lines look
* like `0:"hello"`, `2:[{...}]`, etc. We extract the `0:`
* (text) frames and ignore the rest until the matching
* components ship.
*
* The generator terminates when the body ends OR when the `AbortSignal`
* fires callers can early-exit cleanly.
*
* @example
* ```ts
* const ac = new AbortController();
* for await (const chunk of streamText(response, 'text', ac.signal)) {
* setDraft(prev => prev + chunk);
* }
* ```
*/
export async function* streamText(
response: Response,
protocol: StreamProtocol = 'text',
signal?: AbortSignal,
): AsyncGenerator<string, void, unknown> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (signal?.aborted) return;
const { done, value } = await reader.read();
if (done) {
// Flush any tail bytes through the decoder.
const tail = decoder.decode();
if (tail) yield* parseChunk(tail, protocol, buffer)[0];
return;
}
buffer += decoder.decode(value, { stream: true });
const [chunks, remaining] = parseChunk('', protocol, buffer);
buffer = remaining;
for (const chunk of chunks) {
if (signal?.aborted) return;
yield chunk;
}
}
} finally {
// Best-effort cancellation — ignore if the stream is already closed.
try {
reader.releaseLock();
} catch {
/* noop */
}
}
}
/**
* Parse buffered text into emit-ready chunks per protocol.
*
* Returns `[chunksToEmit, remainingBuffer]`. The `remainingBuffer`
* accumulates partial frames (e.g. an SSE `data:` line without its
* terminating `\n\n`) until the next read fills them in.
*/
function parseChunk(
flush: string,
protocol: StreamProtocol,
buffer: string,
): [string[], string] {
const combined = buffer + flush;
if (protocol === 'text') {
// The caller-supplied buffer concept doesn't apply — emit everything.
return combined ? [[combined], ''] : [[], ''];
}
if (protocol === 'sse') {
const out: string[] = [];
// SSE frames are separated by a blank line (\n\n).
const parts = combined.split('\n\n');
const remaining = parts.pop() ?? '';
for (const frame of parts) {
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
const raw = line.slice(5).trimStart();
if (raw === '[DONE]' || raw === '') continue;
// Best-effort JSON unwrap — falls back to the raw string.
try {
const obj = JSON.parse(raw);
if (typeof obj === 'string') out.push(obj);
else if (typeof obj?.content === 'string') out.push(obj.content);
else if (typeof obj?.text === 'string') out.push(obj.text);
else if (typeof obj?.delta === 'string') out.push(obj.delta);
} catch {
out.push(raw);
}
}
}
return [out, remaining];
}
// protocol === 'data' — Vercel AI SDK data-stream prefix protocol.
const out: string[] = [];
const lines = combined.split('\n');
const remaining = lines.pop() ?? '';
for (const line of lines) {
if (!line) continue;
const colon = line.indexOf(':');
if (colon < 1) continue;
const prefix = line.slice(0, colon);
if (prefix !== '0') continue; // Only text frames for the MVP.
const payload = line.slice(colon + 1);
try {
const value = JSON.parse(payload);
if (typeof value === 'string') out.push(value);
} catch {
/* ignore malformed frame */
}
}
return [out, remaining];
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
pool: 'forks',
},
});

11771
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff