From c9a7f905afbf3de72b9e8d864caad996d7fa466c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Wed, 27 May 2026 12:07:23 -0700 Subject: [PATCH] feat(ai-ui): Wave 2 MVP @bytelyst/ai-ui@0.1.0 --- .gitea/workflows/size-limit.yml | 1 + .size-limit.cjs | 7 + packages/ai-ui/README.md | 107 + packages/ai-ui/package.json | 36 + packages/ai-ui/src/ChatStream.tsx | 136 + packages/ai-ui/src/MessageBubble.tsx | 163 + packages/ai-ui/src/PromptComposer.tsx | 209 + packages/ai-ui/src/__tests__/ai-ui.test.tsx | 379 + packages/ai-ui/src/index.ts | 36 + packages/ai-ui/src/types.ts | 88 + packages/ai-ui/src/useChat.ts | 224 + packages/ai-ui/src/useStreamingText.ts | 130 + packages/ai-ui/tsconfig.json | 11 + packages/ai-ui/vitest.config.ts | 8 + pnpm-lock.yaml | 11771 +++++------------- 15 files changed, 4575 insertions(+), 8731 deletions(-) create mode 100644 packages/ai-ui/README.md create mode 100644 packages/ai-ui/package.json create mode 100644 packages/ai-ui/src/ChatStream.tsx create mode 100644 packages/ai-ui/src/MessageBubble.tsx create mode 100644 packages/ai-ui/src/PromptComposer.tsx create mode 100644 packages/ai-ui/src/__tests__/ai-ui.test.tsx create mode 100644 packages/ai-ui/src/index.ts create mode 100644 packages/ai-ui/src/types.ts create mode 100644 packages/ai-ui/src/useChat.ts create mode 100644 packages/ai-ui/src/useStreamingText.ts create mode 100644 packages/ai-ui/tsconfig.json create mode 100644 packages/ai-ui/vitest.config.ts diff --git a/.gitea/workflows/size-limit.yml b/.gitea/workflows/size-limit.yml index 08e44e74..f692d0a6 100644 --- a/.gitea/workflows/size-limit.yml +++ b/.gitea/workflows/size-limit.yml @@ -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 diff --git a/.size-limit.cjs b/.size-limit.cjs index 13140424..bd3705c0 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -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, + }, ]; diff --git a/packages/ai-ui/README.md b/packages/ai-ui/README.md new file mode 100644 index 00000000..c1e32297 --- /dev/null +++ b/packages/ai-ui/README.md @@ -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 ``, ``, +> ``, ``, ``, and the +> `useToolCalls` / `usePromptHistory` / `useTokenCount` hooks. + +## Quick start + +```tsx +import { ChatStream } from '@bytelyst/ai-ui'; + +export function ChatPage() { + return ( +
+ } + /> +
+ ); +} +``` + +## 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 + +### `` + +Opinionated composition of `useChat` + `` + +``. Auto-scrolls (sticky-bottom), shows Stop while +streaming, surfaces transport errors inline. + +### `` + +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. + +### `` + +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` — ``, ``, `useToolCalls()` +- `0.3.0` — ``, `` +- `0.4.0` — `` with MCP discovery +- `0.5.0` — `` + `` (Wave 2 stretch) +- `1.0.0` — API freeze when 3+ products consume in production diff --git a/packages/ai-ui/package.json b/packages/ai-ui/package.json new file mode 100644 index 00000000..60371c8b --- /dev/null +++ b/packages/ai-ui/package.json @@ -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" + } +} diff --git a/packages/ai-ui/src/ChatStream.tsx b/packages/ai-ui/src/ChatStream.tsx new file mode 100644 index 00000000..cde1cbca --- /dev/null +++ b/packages/ai-ui/src/ChatStream.tsx @@ -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 . */ + renderMessage?: (message: Message) => ReactNode; + /** Forwarded to every when default renderer is used. */ + messageProps?: Omit; + /** Placeholder shown inside . */ + placeholder?: string; + /** Tailwind escape hatch. */ + className?: string; + /** Inline style escape hatch. */ + style?: CSSProperties; +} + +/** + * `` — opinionated composition of `useChat` + + * `` + ``. 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(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 ( +
+ {header} + +
+ {showEmpty + ? emptyState + : messages.map(message => + renderMessage ? ( + {renderMessage(message)} + ) : ( + + ), + )} + + {chat.error && ( +
+ {chat.error.message} +
+ )} +
+ + + + {footer} +
+ ); +} diff --git a/packages/ai-ui/src/MessageBubble.tsx b/packages/ai-ui/src/MessageBubble.tsx new file mode 100644 index 00000000..51ac1020 --- /dev/null +++ b/packages/ai-ui/src/MessageBubble.tsx @@ -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 ``. + * + * 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 ( +
+
+ {roleLabel ?? capitalize(message.role)} + {message.createdAt && ( + + )} +
+ + {thinking ? :
{message.content}
} + + {actions && ( +
+ {actions(message)} +
+ )} +
+ ); +} + +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 ( + + + + + + + ); +} diff --git a/packages/ai-ui/src/PromptComposer.tsx b/packages/ai-ui/src/PromptComposer.tsx new file mode 100644 index 00000000..a2c6748a --- /dev/null +++ b/packages/ai-ui/src/PromptComposer.tsx @@ -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; + /** 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( + 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(null); + + useImperativeHandle( + ref, + () => ({ + focus: () => textareaRef.current?.focus(), + clear: () => { + onChange({ target: { value: '' } }); + textareaRef.current?.focus(); + }, + }), + [onChange], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + 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) => { + 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 ( +
+ {leadingActions && ( +
+ {leadingActions} +
+ )} + +