From f6a54146a35ec3132f81db0c52f941e7dc3cf848 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 20 Jan 2026 00:26:58 +0800 Subject: [PATCH] feat(webui): add ChatViewer component with stories and styles Co-authored-by: Qwen-Coder --- packages/vscode-ide-companion/NOTICES.txt | 6 + .../src/components/ChatViewer/ChatViewer.css | 152 +++++++ .../ChatViewer/ChatViewer.stories.tsx | 428 ++++++++++++++++++ .../src/components/ChatViewer/ChatViewer.tsx | 274 +++++++++++ .../webui/src/components/ChatViewer/index.ts | 14 + packages/webui/src/index.ts | 12 + 6 files changed, 886 insertions(+) create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.css create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.tsx create mode 100644 packages/webui/src/components/ChatViewer/index.ts diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 9e312534d..2b4bdbbce 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,11 @@ This file contains third-party software notices and license terms. +============================================================ +@qwen-code/webui@undefined +(No repository found) + +License text not found. + ============================================================ semver@7.7.2 (git+https://github.com/npm/node-semver.git) diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.css b/packages/webui/src/components/ChatViewer/ChatViewer.css new file mode 100644 index 000000000..5cc71011a --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.css @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * ChatViewer component styles - matching vscode-ide-companion visual appearance + * Note: Timeline styles are inherited from shared styles/timeline.css + */ + +/* =========================== + Main Chat Viewer Container + =========================== */ +.chat-viewer-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background-color: var(--app-background, var(--app-primary-background, #1e1e1e)); + color: var(--app-primary-foreground, #cccccc); + font-family: var(--vscode-chat-font-family, var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif)); + font-size: var(--vscode-chat-font-size, 13px); + overflow: hidden; +} + +/* =========================== + Messages Container (scrollable) + =========================== */ +.chat-viewer-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 20px; + display: flex; + flex-direction: column; + position: relative; + min-width: 0; + /* Enable smooth scrolling for auto-scroll */ + scroll-behavior: smooth; +} + +/* Dark theme scrollbar styling */ +.chat-viewer-messages::-webkit-scrollbar { + width: 8px; +} + +.chat-viewer-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Light theme scrollbar styling */ +@media (prefers-color-scheme: light) { + .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + } + + .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); + } +} + +/* Force light theme scrollbar */ +.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); +} + +.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Message item base styles */ +.chat-viewer-messages > * { + display: flex; + gap: 0; + align-items: flex-start; + text-align: left; + padding: 8px 0; + flex-direction: column; + position: relative; + animation: chatViewerFadeIn 0.2s ease-in; +} + +.chat-viewer-messages > *:not(:last-child) { + padding-bottom: 8px; +} + +/* Disable overflow anchoring on individual items for manual scroll control */ +.chat-viewer-messages > * { + overflow-anchor: none; +} + +/* User message container spacing */ +.chat-viewer-messages .user-message-container:first-child { + margin-top: 0; +} + +/* =========================== + Animations + =========================== */ +@keyframes chatViewerFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* =========================== + Empty State + =========================== */ +.chat-viewer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + color: var(--app-secondary-foreground, rgba(255, 255, 255, 0.6)); + font-size: 14px; + text-align: center; + padding: 20px; +} + +.chat-viewer-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.chat-viewer-empty-text { + max-width: 300px; + line-height: 1.5; +} + +/* =========================== + Scroll Anchor (for auto-scroll) + =========================== */ +.chat-viewer-scroll-anchor { + height: 1px; + overflow-anchor: auto; +} diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx new file mode 100644 index 000000000..136efa855 --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx @@ -0,0 +1,428 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + ChatViewer, + type ChatMessageData, + type ChatViewerHandle, +} from './ChatViewer.js'; + +/** + * ChatViewer component displays a read-only conversation flow. + * It accepts JSONL-formatted chat messages and renders them using + * UserMessage and AssistantMessage components with timeline styling. + * + * Features: + * - Auto-scroll to bottom when new messages arrive + * - Programmatic scroll control via ref + * - Light/dark/auto theme support + * - Empty state with customizable message + */ +const meta: Meta = { + title: 'Chat/ChatViewer', + component: ChatViewer, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + messages: { + control: 'object', + description: 'Array of chat messages in JSONL format', + }, + className: { + control: 'text', + description: 'Additional CSS class name', + }, + onFileClick: { action: 'fileClicked' }, + emptyMessage: { + control: 'text', + description: 'Message to show when there are no messages', + }, + autoScroll: { + control: 'boolean', + description: 'Whether to auto-scroll to bottom when new messages arrive', + }, + theme: { + control: 'select', + options: ['dark', 'light', 'auto'], + description: 'Theme variant for the viewer', + }, + showEmptyIcon: { + control: 'boolean', + description: 'Whether to show the icon in empty state', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Helper function to create message data +const createMessage = ( + uuid: string, + type: 'user' | 'assistant', + text: string, + timestamp: string, + model?: string, +): ChatMessageData => ({ + uuid, + parentUuid: null, + sessionId: 'story-session', + timestamp, + type, + message: { + role: type === 'user' ? 'user' : 'model', + parts: [{ text }], + }, + model, +}); + +export const Default: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'How do I create a React component?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + "To create a React component, you can use either a function or a class. Here's a simple example of a functional component:\n\n```tsx\nimport React from 'react';\n\nconst MyComponent: React.FC = () => {\n return
Hello, World!
;\n};\n\nexport default MyComponent;\n```\n\nThis creates a basic component that renders \"Hello, World!\". You can then use it in other components like ``.", + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + }, +}; + +export const MultiTurn: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'What is TypeScript?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'TypeScript is a strongly typed programming language that builds on JavaScript. It adds optional static typing and class-based object-oriented programming to the language.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + createMessage( + '3', + 'user', + 'How do I define an interface?', + '2026-01-19T10:00:30.000Z', + ), + createMessage( + '4', + 'assistant', + 'You can define an interface in TypeScript like this:\n\n```typescript\ninterface User {\n id: number;\n name: string;\n email?: string; // optional property\n}\n\nconst user: User = {\n id: 1,\n name: "John Doe"\n};\n```\n\nInterfaces help you define the shape of objects and enable better type checking.', + '2026-01-19T10:00:35.000Z', + 'coder-model', + ), + createMessage( + '5', + 'user', + 'Can interfaces extend other interfaces?', + '2026-01-19T10:01:00.000Z', + ), + createMessage( + '6', + 'assistant', + 'Yes! Interfaces can extend one or more interfaces. Here\'s an example:\n\n```typescript\ninterface Person {\n name: string;\n age: number;\n}\n\ninterface Employee extends Person {\n employeeId: string;\n department: string;\n}\n\n// Employee now has: name, age, employeeId, and department\nconst employee: Employee = {\n name: "Jane",\n age: 30,\n employeeId: "E001",\n department: "Engineering"\n};\n```\n\nYou can also extend multiple interfaces: `interface Manager extends Person, Employee { ... }`', + '2026-01-19T10:01:05.000Z', + 'coder-model', + ), + ], + }, +}; + +export const WithCodeBlocks: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'Show me how to implement a debounce function', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + `Here's a debounce function implementation in TypeScript: + +\`\`\`typescript +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return function (...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func.apply(this, args); + timeoutId = null; + }, wait); + }; +} +\`\`\` + +**Usage example:** + +\`\`\`typescript +const handleSearch = debounce((query: string) => { + console.log('Searching for:', query); + // Make API call here +}, 300); + +// This will only execute once, 300ms after the last call +handleSearch('h'); +handleSearch('he'); +handleSearch('hel'); +handleSearch('hello'); +\`\`\` + +The debounce function delays the execution until there's a pause in calls for the specified wait time (300ms in this case).`, + '2026-01-19T10:00:10.000Z', + 'coder-model', + ), + ], + }, +}; + +export const LongConversation: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'I need to build a REST API. What framework should I use?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'For building a REST API, there are several excellent options depending on your needs:\n\n1. **Express.js** - Minimal and flexible, great for small to medium projects\n2. **Fastify** - High performance, excellent for high-throughput APIs\n3. **NestJS** - Full-featured framework with TypeScript, great for enterprise applications\n4. **Hono** - Ultra-fast and lightweight, perfect for edge computing\n\nWhat language/runtime are you planning to use?', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + createMessage( + '3', + 'user', + 'I want to use TypeScript with Node.js. The API will handle about 10k requests per second.', + '2026-01-19T10:00:30.000Z', + ), + createMessage( + '4', + 'assistant', + "For high throughput (10k req/s) with TypeScript, I recommend **Fastify**. It's designed for performance and has excellent TypeScript support out of the box.\n\nHere's a quick setup:\n\n```bash\nnpm init -y\nnpm install fastify\nnpm install -D typescript @types/node\n```\n\n```typescript\nimport Fastify from 'fastify';\n\nconst app = Fastify({ logger: true });\n\napp.get('/health', async () => {\n return { status: 'ok' };\n});\n\napp.listen({ port: 3000 }, (err) => {\n if (err) throw err;\n});\n```\n\nFastify benchmarks show it can handle 30k+ req/s on modest hardware.", + '2026-01-19T10:00:35.000Z', + 'coder-model', + ), + createMessage( + '5', + 'user', + 'How do I add request validation?', + '2026-01-19T10:01:00.000Z', + ), + createMessage( + '6', + 'assistant', + "Fastify has built-in JSON Schema validation. Here's how to add it:\n\n```typescript\nconst createUserSchema = {\n body: {\n type: 'object',\n required: ['email', 'name'],\n properties: {\n email: { type: 'string', format: 'email' },\n name: { type: 'string', minLength: 2 },\n age: { type: 'integer', minimum: 0 }\n }\n },\n response: {\n 201: {\n type: 'object',\n properties: {\n id: { type: 'string' },\n email: { type: 'string' },\n name: { type: 'string' }\n }\n }\n }\n};\n\napp.post('/users', { schema: createUserSchema }, async (req, reply) => {\n const { email, name, age } = req.body;\n // Create user...\n reply.code(201).send({ id: '123', email, name });\n});\n```\n\nInvalid requests automatically return 400 with detailed error messages.", + '2026-01-19T10:01:10.000Z', + 'coder-model', + ), + ], + }, +}; + +export const Empty: Story = { + args: { + messages: [], + emptyMessage: 'Start a conversation to see messages here', + }, +}; + +export const CustomEmptyMessage: Story = { + args: { + messages: [], + emptyMessage: 'No chat history available', + }, +}; + +export const SingleUserMessage: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'This is a single user message without any response yet.', + '2026-01-19T10:00:00.000Z', + ), + ], + }, +}; + +export const SingleAssistantMessage: Story = { + args: { + messages: [ + createMessage( + '1', + 'assistant', + 'This is a standalone assistant message, perhaps from a system prompt or welcome message.', + '2026-01-19T10:00:00.000Z', + 'coder-model', + ), + ], + }, +}; + +export const LightTheme: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'Show me how to use the light theme.', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'The ChatViewer supports light, dark, and auto themes. Set `theme="light"` for light mode styling.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + theme: 'light', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const AutoScrollDisabled: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'This story has auto-scroll disabled.', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'When `autoScroll={false}`, the viewer will not automatically scroll to the bottom when new messages arrive. This is useful when you want users to manually control the scroll position.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + autoScroll: false, + }, +}; + +export const EmptyWithoutIcon: Story = { + args: { + messages: [], + emptyMessage: 'No messages yet', + showEmptyIcon: false, + }, +}; + +// Interactive story demonstrating ref functionality +const WithRefControlTemplate = () => { + const chatRef = useRef(null); + + const messages: ChatMessageData[] = Array.from({ length: 20 }, (_, i) => + createMessage( + String(i + 1), + i % 2 === 0 ? 'user' : 'assistant', + i % 2 === 0 + ? `Question ${Math.floor(i / 2) + 1}: How does feature ${Math.floor(i / 2) + 1} work?` + : `This is the answer to question ${Math.floor(i / 2) + 1}. The feature works by processing data through multiple stages and returning the result to the caller.`, + new Date(2026, 0, 19, 10, i).toISOString(), + i % 2 === 1 ? 'coder-model' : undefined, + ), + ); + + return ( +
+
+ + +
+
+ +
+
+ ); +}; + +export const WithRefControl: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Demonstrates programmatic scroll control using the `ref` prop. The `ChatViewerHandle` provides `scrollToTop()`, `scrollToBottom()`, and `getScrollContainer()` methods.', + }, + }, + }, +}; diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx new file mode 100644 index 000000000..17cff910d --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; +import { UserMessage } from '../messages/UserMessage.js'; +import { AssistantMessage } from '../messages/Assistant/AssistantMessage.js'; +import { ThinkingMessage } from '../messages/ThinkingMessage.js'; +import './ChatViewer.css'; + +/** + * Message part containing text content + */ +export interface MessagePart { + text: string; +} + +/** + * Single chat message from JSONL format + */ +export interface ChatMessageData { + uuid: string; + parentUuid: string | null; + sessionId: string; + timestamp: string; // ISO timestamp string + type: 'user' | 'assistant' | 'system'; + message: { + role: string; + parts: MessagePart[]; + }; + model?: string; // for assistant messages +} + +/** + * ChatViewer ref handle for programmatic control + */ +export interface ChatViewerHandle { + /** Scroll to the bottom of the messages */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Scroll to the top of the messages */ + scrollToTop: (behavior?: ScrollBehavior) => void; + /** Get the scroll container element */ + getScrollContainer: () => HTMLDivElement | null; +} + +/** + * ChatViewer component props + */ +export interface ChatViewerProps { + /** Array of chat messages in JSONL format */ + messages: ChatMessageData[]; + /** Optional additional CSS class name */ + className?: string; + /** Optional callback when a file path is clicked */ + onFileClick?: (path: string) => void; + /** Optional empty state message */ + emptyMessage?: string; + /** Whether to auto-scroll to bottom when new messages arrive (default: true) */ + autoScroll?: boolean; + /** Theme variant: 'dark' | 'light' | 'auto' (default: 'auto') */ + theme?: 'dark' | 'light' | 'auto'; + /** Show empty state icon (default: true) */ + showEmptyIcon?: boolean; +} + +/** + * Extract text content from message parts + */ +function extractContent(parts: MessagePart[]): string { + return parts.map((part) => part.text).join(''); +} + +/** + * Convert ISO timestamp string to numeric timestamp + */ +function parseTimestamp(isoString: string): number { + const date = new Date(isoString); + return isNaN(date.getTime()) ? Date.now() : date.getTime(); +} + +/** + * ChatViewer - A standalone component for displaying chat conversations + * + * Renders a conversation flow from JSONL-formatted data using existing + * message components (UserMessage, AssistantMessage, ThinkingMessage). + * This is a pure UI component without VSCode or external dependencies. + * + * @example + * ```tsx + * const messages = [ + * { uuid: '1', type: 'user', message: { role: 'user', parts: [{ text: 'Hello!' }] }, ... }, + * { uuid: '2', type: 'assistant', message: { role: 'model', parts: [{ text: 'Hi there!' }] }, ... }, + * ]; + * + * console.log(path)} /> + * ``` + * + * @example With ref for programmatic control + * ```tsx + * const chatRef = useRef(null); + * + * // Scroll to bottom programmatically + * chatRef.current?.scrollToBottom('smooth'); + * + * + * ``` + */ +export const ChatViewer = forwardRef( + ( + { + messages, + className = '', + onFileClick, + emptyMessage = 'No messages to display', + autoScroll = true, + theme = 'auto', + showEmptyIcon = true, + }, + ref, + ) => { + const scrollContainerRef = useRef(null); + const scrollAnchorRef = useRef(null); + const prevMessageCountRef = useRef(0); + + // Sort messages by timestamp and filter out system messages + const sortedMessages = useMemo( + () => + messages + .filter((msg) => msg.type !== 'system') + .sort( + (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp), + ), + [messages], + ); + + // Expose imperative handle for programmatic control + useImperativeHandle( + ref, + () => ({ + scrollToBottom: (behavior: ScrollBehavior = 'smooth') => { + const container = scrollContainerRef.current; + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior, + }); + } + }, + scrollToTop: (behavior: ScrollBehavior = 'smooth') => { + const container = scrollContainerRef.current; + if (container) { + container.scrollTo({ + top: 0, + behavior, + }); + } + }, + getScrollContainer: () => scrollContainerRef.current, + }), + [], + ); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (!autoScroll) return; + + const currentCount = sortedMessages.length; + const prevCount = prevMessageCountRef.current; + + // Only auto-scroll when new messages are added + if (currentCount > prevCount && scrollAnchorRef.current) { + scrollAnchorRef.current.scrollIntoView({ behavior: 'smooth' }); + } + + prevMessageCountRef.current = currentCount; + }, [sortedMessages.length, autoScroll]); + + // Render individual message based on type + const renderMessage = (msg: ChatMessageData, index: number) => { + const content = extractContent(msg.message.parts); + const timestamp = parseTimestamp(msg.timestamp); + const key = msg.uuid || `msg-${index}`; + + // Skip empty messages + if (!content.trim()) { + return null; + } + + switch (msg.type) { + case 'user': + return ( + + ); + + case 'assistant': + // Check if this is a thinking message based on role + if (msg.message.role === 'thinking') { + return ( + + ); + } + return ( + + ); + + default: + return null; + } + }; + + // Build container class names + const containerClasses = [ + 'chat-viewer-container', + theme === 'light' ? 'light-theme' : '', + theme === 'auto' ? 'auto-theme' : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
+
+ {sortedMessages.length === 0 ? ( +
+ {showEmptyIcon && ( + + )} +
{emptyMessage}
+
+ ) : ( + <> + {sortedMessages.map((msg, index) => renderMessage(msg, index))} + {/* Scroll anchor for auto-scroll functionality */} +
+ + )} +
+
+ ); + }, +); + +ChatViewer.displayName = 'ChatViewer'; + +export default ChatViewer; diff --git a/packages/webui/src/components/ChatViewer/index.ts b/packages/webui/src/components/ChatViewer/index.ts new file mode 100644 index 000000000..1b56e8562 --- /dev/null +++ b/packages/webui/src/components/ChatViewer/index.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + ChatViewer, + default, + type ChatMessageData, + type ChatViewerHandle, + type ChatViewerProps, + type MessagePart, +} from './ChatViewer.js'; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 0ffa52c8e..315858270 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -79,6 +79,18 @@ export type { AssistantMessageStatus, } from './components/messages/Assistant/AssistantMessage'; +// ChatViewer - standalone chat display component +export { + ChatViewer, + default as ChatViewerDefault, +} from './components/ChatViewer'; +export type { + ChatViewerProps, + ChatViewerHandle, + ChatMessageData, + MessagePart, +} from './components/ChatViewer'; + // UI Elements export { default as Button } from './components/ui/Button'; export { default as Input } from './components/ui/Input';