diff --git a/.cursor/rules/styling-rule.mdc b/.cursor/rules/styling-rule.mdc index f4e22a07b..6105e931b 100644 --- a/.cursor/rules/styling-rule.mdc +++ b/.cursor/rules/styling-rule.mdc @@ -88,128 +88,450 @@ Use the `.crayon-component-name` prefix with BEM-like modifiers: } ``` -### 3. Typography System +--- -#### Available Typography Mixins +## Color System + +### Background Colors + +| Token | Usage | +| ---------------------- | --------------------------------------- | +| `$bg-fill` | Main page/app background | +| `$bg-container` | Card/container backgrounds | +| `$bg-overlay` | Modal/overlay backgrounds | +| `$bg-sunk` | Input fields, recessed areas | +| `$bg-sunk-bg` | Alternative sunk background | +| `$bg-elevated` | Elevated surfaces (dropdowns, popovers) | +| `$bg-inverted` | Inverted/dark backgrounds | +| `$bg-danger` | Error/danger state backgrounds | +| `$bg-success` | Success state backgrounds | +| `$bg-info` | Informational backgrounds | +| `$bg-alert` | Warning/alert backgrounds | +| `$bg-highlight-subtle` | Subtle highlight (hover states) | +| `$bg-highlight-strong` | Strong highlight (selection) | ```scss -// Body text variants -@include cssUtils.typography(body, default); // Regular body text -@include cssUtils.typography(body, small); // Smaller body text -@include cssUtils.typography(body, large); // Larger body text -@include cssUtils.typography(body, heavy); // Bold body text - -// Label variants -@include cssUtils.typography(label, default); // Regular labels -@include cssUtils.typography(label, small); // Small labels -@include cssUtils.typography(label, large); // Large labels - -// Heading variants -@include cssUtils.typography(heading, large); // H1 equivalent -@include cssUtils.typography(heading, medium); // H2 equivalent -@include cssUtils.typography(heading, small); // H3 equivalent - -// Number variants (for data display) -@include cssUtils.typography(number, default); // Regular numbers -@include cssUtils.typography(number, large); // Large numbers -@include cssUtils.typography(number, title); // Title numbers +// Example usage +.crayon-card { + background-color: cssUtils.$bg-container; +} + +.crayon-modal-backdrop { + background-color: cssUtils.$bg-overlay; +} + +.crayon-input { + background-color: cssUtils.$bg-sunk; +} + +.crayon-error-banner { + background-color: cssUtils.$bg-danger; +} ``` -### 4. Color System +### Interactive Colors + +For buttons, clickable elements, and interactive states: + +| Token | Usage | +| ----------------------- | -------------------- | +| `$interactive-default` | Default state | +| `$interactive-hover` | Hover state | +| `$interactive-pressed` | Active/pressed state | +| `$interactive-disabled` | Disabled state | + +**Accent (Primary Actions):** -#### Background Colors +| Token | Usage | +| ------------------------------ | ----------------------- | +| `$interactive-accent` | Primary button default | +| `$interactive-accent-hover` | Primary button hover | +| `$interactive-accent-pressed` | Primary button pressed | +| `$interactive-accent-disabled` | Primary button disabled | + +**Destructive (Danger Actions):** + +| Token | Usage | +| ------------------------------------------ | --------------------------- | +| `$interactive-destructive` | Destructive button default | +| `$interactive-destructive-hover` | Destructive button hover | +| `$interactive-destructive-pressed` | Destructive button pressed | +| `$interactive-destructive-disabled` | Destructive button disabled | +| `$interactive-destructive-accent` | Filled destructive button | +| `$interactive-destructive-accent-hover` | Filled destructive hover | +| `$interactive-destructive-accent-pressed` | Filled destructive pressed | +| `$interactive-destructive-accent-disabled` | Filled destructive disabled | ```scss -cssUtils.$bg-fill // Main background -cssUtils.$bg-container // Card/container backgrounds -cssUtils.$bg-overlay // Modal/overlay backgrounds -cssUtils.$bg-sunk // Input field backgrounds -cssUtils.$bg-elevated // Elevated surfaces -cssUtils.$bg-danger // Error/danger backgrounds -cssUtils.$bg-success // Success backgrounds -cssUtils.$bg-info // Info backgrounds +// Primary button example +.crayon-button-primary { + background-color: cssUtils.$interactive-accent; + + &:not(:disabled):hover { + background-color: cssUtils.$interactive-accent-hover; + } + + &:not(:disabled):active { + background-color: cssUtils.$interactive-accent-pressed; + } + + &:disabled { + background-color: cssUtils.$interactive-accent-disabled; + } +} + +// Destructive button example +.crayon-button-destructive { + background-color: cssUtils.$interactive-destructive-accent; + + &:not(:disabled):hover { + background-color: cssUtils.$interactive-destructive-accent-hover; + } +} ``` -#### Interactive Colors +### Text Colors + +**Primary Text:** + +| Token | Usage | +| ----------------- | ---------------------------------- | +| `$primary-text` | Main body text | +| `$secondary-text` | Secondary/muted text, placeholders | +| `$disabled-text` | Disabled text | +| `$link-text` | Hyperlinks | + +**Accent Text:** + +| Token | Usage | +| ------------------------ | ------------------------------------ | +| `$accent-primary-text` | Text on accent backgrounds | +| `$accent-secondary-text` | Secondary text on accent backgrounds | +| `$accent-disabled-text` | Disabled text on accent backgrounds | + +**Status Text:** + +| Token | Usage | +| ------------------------ | --------------------------- | +| `$success-primary-text` | Success messages | +| `$success-inverted-text` | Text on success backgrounds | +| `$alert-primary-text` | Warning messages | +| `$alert-inverted-text` | Text on alert backgrounds | +| `$info-primary-text` | Info messages | +| `$info-inverted-text` | Text on info backgrounds | + +**Danger Text:** + +| Token | Usage | +| --------------------------------- | ------------------------------------ | +| `$danger-primary-text` | Error messages | +| `$danger-secondary-text` | Secondary error text | +| `$danger-disabled-text` | Disabled error text | +| `$danger-inverted-primary-text` | Primary text on danger backgrounds | +| `$danger-inverted-secondary-text` | Secondary text on danger backgrounds | +| `$danger-inverted-disabled-text` | Disabled text on danger backgrounds | ```scss -cssUtils.$interactive-default // Default button background -cssUtils.$interactive-hover // Hover state -cssUtils.$interactive-pressed // Pressed/active state -cssUtils.$interactive-disabled // Disabled state -cssUtils.$interactive-accent // Primary action color +// Example usage +.crayon-label { + color: cssUtils.$secondary-text; +} + +.crayon-error-message { + color: cssUtils.$danger-primary-text; +} + +.crayon-link { + color: cssUtils.$link-text; +} ``` -#### Text Colors +### Stroke/Border Colors + +| Token | Usage | +| --------------------------------- | ------------------------------------ | +| `$stroke-default` | Default borders | +| `$stroke-emphasis` | Emphasized borders | +| `$stroke-interactive-el` | Interactive element borders | +| `$stroke-interactive-el-selected` | Selected interactive element borders | + +**Semantic Strokes:** + +| Token | Usage | +| -------------------------- | -------------------------- | +| `$stroke-accent` | Accent/brand borders | +| `$stroke-accent-emphasis` | Emphasized accent borders | +| `$stroke-success` | Success state borders | +| `$stroke-success-emphasis` | Emphasized success borders | +| `$stroke-info` | Info state borders | +| `$stroke-info-emphasis` | Emphasized info borders | +| `$stroke-alert` | Alert/warning borders | +| `$stroke-alert-emphasis` | Emphasized alert borders | +| `$stroke-danger` | Error state borders | +| `$stroke-danger-emphasis` | Emphasized error borders | ```scss -cssUtils.$primary-text // Main text color -cssUtils.$secondary-text // Secondary/muted text -cssUtils.$disabled-text // Disabled text -cssUtils.$accent-primary-text // Primary accent text -cssUtils.$success-primary-text // Success text -cssUtils.$danger-primary-text // Error text +// Example usage +.crayon-input { + border: 1px solid cssUtils.$stroke-default; + + &:focus { + border-color: cssUtils.$stroke-emphasis; + } + + &-error { + border-color: cssUtils.$stroke-danger-emphasis; + } +} ``` -#### Stroke/Border Colors +### Chat Colors + +For chat/conversation UI components: + +| Token | Usage | +| ------------------------------- | ------------------------------- | +| `$chat-container-bg` | Chat container background | +| `$chat-assistant-response-bg` | AI/assistant message background | +| `$chat-assistant-response-text` | AI/assistant message text | +| `$chat-user-response-bg` | User message background | +| `$chat-user-response-text` | User message text | ```scss -cssUtils.$stroke-default // Default borders -cssUtils.$stroke-interactive-el // Interactive element borders -cssUtils.$stroke-emphasis // Emphasis borders -cssUtils.$stroke-accent // Accent borders -cssUtils.$stroke-danger // Error borders +// Example chat message styling +.crayon-chat-message { + &-assistant { + background-color: cssUtils.$chat-assistant-response-bg; + color: cssUtils.$chat-assistant-response-text; + } + + &-user { + background-color: cssUtils.$chat-user-response-bg; + color: cssUtils.$chat-user-response-text; + } +} ``` -### 5. Spacing System +--- + +## Spacing System -Use the predefined spacing scale: +Use the predefined spacing scale consistently: + +| Token | Value | Usage | +| -------------- | ----- | ---------------------- | +| `$spacing-0` | 0px | No spacing | +| `$spacing-3xs` | 2px | Minimal spacing | +| `$spacing-2xs` | 4px | Very tight spacing | +| `$spacing-xs` | 8px | Tight spacing | +| `$spacing-s` | 12px | Small spacing | +| `$spacing-m` | 16px | Medium/default spacing | +| `$spacing-l` | 20px | Large spacing | +| `$spacing-xl` | 24px | Extra large spacing | +| `$spacing-2xl` | 32px | Section spacing | +| `$spacing-3xl` | 40px | Large section spacing | ```scss -cssUtils.$spacing-0 // 0px -cssUtils.$spacing-3xs // 2px -cssUtils.$spacing-2xs // 4px -cssUtils.$spacing-xs // 8px -cssUtils.$spacing-s // 12px -cssUtils.$spacing-m // 16px -cssUtils.$spacing-l // 20px -cssUtils.$spacing-xl // 24px -cssUtils.$spacing-2xl // 32px -cssUtils.$spacing-3xl // 40px +// Example usage +.crayon-card { + padding: cssUtils.$spacing-m; + gap: cssUtils.$spacing-s; +} + +.crayon-section { + margin-bottom: cssUtils.$spacing-2xl; +} ``` -### 6. Border Radius System +--- + +## Border Radius System + +| Token | Value | Usage | +| --------------- | ------ | ------------------------------ | +| `$rounded-0` | 0px | Sharp corners | +| `$rounded-3xs` | 2px | Minimal rounding | +| `$rounded-2xs` | 4px | Subtle rounding | +| `$rounded-xs` | 6px | Small rounding | +| `$rounded-s` | 8px | Standard small | +| `$rounded-m` | 12px | Standard medium | +| `$rounded-l` | 16px | Large rounding | +| `$rounded-xl` | 20px | Extra large rounding | +| `$rounded-2xl` | 24px | Very large rounding | +| `$rounded-3xl` | 28px | Maximum rounding | +| `$rounded-full` | 9999px | Fully rounded (pills, circles) | ```scss -cssUtils.$rounded-0 // 0px (sharp corners) -cssUtils.$rounded-3xs // 2px -cssUtils.$rounded-2xs // 4px -cssUtils.$rounded-xs // 6px -cssUtils.$rounded-s // 8px -cssUtils.$rounded-m // 12px -cssUtils.$rounded-l // 16px -cssUtils.$rounded-xl // 20px -cssUtils.$rounded-full // 9999px (fully rounded) +// Example usage +.crayon-button { + border-radius: cssUtils.$rounded-m; +} + +.crayon-avatar { + border-radius: cssUtils.$rounded-full; +} + +.crayon-card { + border-radius: cssUtils.$rounded-l; +} ``` -### 7. Shadow System +--- + +## Shadow System + +| Token | Usage | +| ------------- | ------------------------------ | +| `$shadow-s` | Subtle elevation (buttons) | +| `$shadow-m` | Medium elevation (cards) | +| `$shadow-l` | Large elevation (dropdowns) | +| `$shadow-xl` | Extra large elevation (modals) | +| `$shadow-2xl` | High elevation (popovers) | +| `$shadow-3xl` | Maximum elevation | ```scss -cssUtils.$shadow-s // Small shadow -cssUtils.$shadow-m // Medium shadow -cssUtils.$shadow-l // Large shadow -cssUtils.$shadow-xl // Extra large shadow -cssUtils.$shadow-2xl // 2X large shadow -cssUtils.$shadow-3xl // 3X large shadow +// Example usage +.crayon-dropdown { + box-shadow: cssUtils.$shadow-l; +} + +.crayon-modal { + box-shadow: cssUtils.$shadow-xl; +} ``` -## Component Patterns +--- + +## Typography System + +Use the `typography` mixin for consistent text styling: + +```scss +@include cssUtils.typography($category, $variant); +``` + +### Body Text + +| Variant | Usage | +| ------------- | ------------------ | +| `default` | Standard body text | +| `small` | Smaller body text | +| `small-heavy` | Small bold text | +| `medium` | Medium weight body | +| `large` | Larger body text | +| `large-heavy` | Large bold text | +| `heavy` | Bold body text | +| `link` | Link text styling | -### Standard Component Variants +```scss +.crayon-paragraph { + @include cssUtils.typography(body, default); +} -Most components should support these variants: +.crayon-caption { + @include cssUtils.typography(body, small); +} -#### Size Variants +.crayon-emphasis { + @include cssUtils.typography(body, heavy); +} +``` + +### Labels + +| Variant | Usage | +| --------------------- | ----------------------- | +| `default` | Standard labels | +| `heavy` | Bold labels | +| `small` | Small labels | +| `small-heavy` | Small bold labels | +| `medium` | Medium labels | +| `medium-heavy` | Medium bold labels | +| `large` | Large labels | +| `large-heavy` | Large bold labels | +| `extra-small` | Extra small labels | +| `extra-small-heavy` | Extra small bold labels | +| `2-extra-small` | Tiny labels | +| `2-extra-small-heavy` | Tiny bold labels | + +```scss +.crayon-form-label { + @include cssUtils.typography(label, default); +} + +.crayon-badge { + @include cssUtils.typography(label, small-heavy); +} +``` + +### Headings + +| Variant | Usage | +| ------------- | ------------- | +| `large` | H1 equivalent | +| `medium` | H2 equivalent | +| `small` | H3 equivalent | +| `extra-small` | H4 equivalent | + +```scss +.crayon-page-title { + @include cssUtils.typography(heading, large); +} + +.crayon-section-title { + @include cssUtils.typography(heading, medium); +} +``` + +### Numbers + +For data display and metrics: + +| Variant | Usage | +| ------------------- | ------------------------------ | +| `default` | Standard numbers | +| `small` | Small numbers | +| `small-heavy` | Small bold numbers | +| `large` | Large numbers | +| `large-heavy` | Large bold numbers | +| `heavy` | Bold numbers | +| `extra-small` | Extra small numbers | +| `extra-small-heavy` | Extra small bold numbers | +| `title` | Display numbers (hero metrics) | +| `title-medium` | Medium display numbers | + +```scss +.crayon-metric { + @include cssUtils.typography(number, large-heavy); +} + +.crayon-stat-value { + @include cssUtils.typography(number, title); +} +``` + +--- + +## Utility Mixins + +### Button Reset + +Removes default button styling: + +```scss +.crayon-icon-button { + @include cssUtils.button-reset; + // Add your custom styles + padding: cssUtils.$spacing-xs; + border-radius: cssUtils.$rounded-m; +} +``` + +--- + +## Component Patterns + +### Standard Size Variants ```scss &-small { @@ -228,7 +550,7 @@ Most components should support these variants: } ``` -#### Button Variants +### Button Variants ```scss &-primary { @@ -240,6 +562,10 @@ Most components should support these variants: background-color: cssUtils.$interactive-accent-hover; } + &:not(:disabled):active { + background-color: cssUtils.$interactive-accent-pressed; + } + &:disabled { background-color: cssUtils.$interactive-accent-disabled; cursor: not-allowed; @@ -254,6 +580,29 @@ Most components should support these variants: &:not(:disabled):hover { background-color: cssUtils.$interactive-hover; } + + &:not(:disabled):active { + background-color: cssUtils.$interactive-pressed; + } +} + +&-destructive { + background-color: cssUtils.$interactive-destructive-accent; + color: cssUtils.$danger-inverted-primary-text; + border-color: cssUtils.$stroke-danger; + + &:not(:disabled):hover { + background-color: cssUtils.$interactive-destructive-accent-hover; + } + + &:not(:disabled):active { + background-color: cssUtils.$interactive-destructive-accent-pressed; + } + + &:disabled { + background-color: cssUtils.$interactive-destructive-accent-disabled; + cursor: not-allowed; + } } ``` @@ -266,6 +615,7 @@ Most components should support these variants: border-radius: cssUtils.$rounded-m; background-color: cssUtils.$bg-sunk; color: cssUtils.$primary-text; + padding: cssUtils.$spacing-xs cssUtils.$spacing-s; &::placeholder { color: cssUtils.$secondary-text; @@ -285,9 +635,45 @@ Most components should support these variants: &-error { border-color: cssUtils.$stroke-danger-emphasis; } + + &-success { + border-color: cssUtils.$stroke-success-emphasis; + } +} +``` + +### Status Badges Pattern + +```scss +.crayon-badge { + @include cssUtils.typography(label, small-heavy); + padding: cssUtils.$spacing-2xs cssUtils.$spacing-xs; + border-radius: cssUtils.$rounded-full; + + &-success { + background-color: cssUtils.$bg-success; + color: cssUtils.$success-primary-text; + } + + &-danger { + background-color: cssUtils.$bg-danger; + color: cssUtils.$danger-primary-text; + } + + &-info { + background-color: cssUtils.$bg-info; + color: cssUtils.$info-primary-text; + } + + &-alert { + background-color: cssUtils.$bg-alert; + color: cssUtils.$alert-primary-text; + } } ``` +--- + ## Best Practices ### 1. Avoid Magic Numbers @@ -318,20 +704,43 @@ Use the `:not(:disabled)` pattern for hover/active states: } ``` -### 3. Responsive Design +### 3. Focus States (Accessibility) -For responsive components, use CSS custom properties that can be controlled via JavaScript: +Always include visible focus states: + +```scss +&:focus-visible { + outline: 2px solid cssUtils.$stroke-accent; + outline-offset: 2px; +} +``` + +### 4. Responsive Design + +Use CSS custom properties for values that can be controlled via JavaScript: ```scss .my-responsive-component { - gap: var(--component-gap, cssUtils.$spacing-m); - padding: var(--component-padding, cssUtils.$spacing-m); + gap: var(--component-gap, #{cssUtils.$spacing-m}); + padding: var(--component-padding, #{cssUtils.$spacing-m}); } ``` -### 4. Component Composition +### 5. CSS Custom Properties for Theming -Prefer composition over complex variants. Create small, focused components that can be combined: +```scss +.crayon-themeable-component { + --component-bg: #{cssUtils.$bg-container}; + --component-text: #{cssUtils.$primary-text}; + + background-color: var(--component-bg); + color: var(--component-text); +} +``` + +### 6. Component Composition + +Prefer composition over complex variants: ```tsx // ✅ Good - Composable @@ -356,33 +765,7 @@ Prefer composition over complex variants. Create small, focused components that /> ``` -### 5. CSS Custom Properties for Theming - -Use CSS custom properties for values that might need to be overridden: - -```scss -.crayon-themeable-component { - --component-bg: #{cssUtils.$bg-container}; - --component-text: #{cssUtils.$primary-text}; - - background-color: var(--component-bg); - color: var(--component-text); -} -``` - -### 6. Accessibility Considerations - -- Always include focus states -- Use appropriate color contrasts (handled by design tokens) -- Support keyboard navigation -- Include proper ARIA attributes in component markup - -### 7. Performance - -- Avoid deeply nested selectors -- Use efficient CSS selectors -- Minimize CSS specificity conflicts -- Consider CSS-in-JS only when necessary (prefer SCSS for static styles) +--- ## When to Create New Components @@ -405,6 +788,8 @@ Use CSS custom properties for values that might need to be overridden: - Layout-only components (use CSS Grid/Flexbox) - Text styling (use typography mixins) +--- + ## Migration Guide When updating existing components: @@ -415,18 +800,12 @@ When updating existing components: 4. **Add missing states** - Include hover, focus, disabled, active states 5. **Test thoroughly** - Verify all variants work correctly +--- + ## Tooling - **SCSS Compilation**: `pnpm build:scss` -- **Linting**: ESLint with custom rules -- **Storybook**: For component development and testing -- **Design Tokens**: All available in `cssUtils.scss` - -## Questions? - -When in doubt: - -1. Check existing components for similar patterns -2. Look at `cssUtils.scss` for available tokens -3. Ask in #design-system Slack channel -4. Reference the design system documentation +- **Watch Mode**: `pnpm watch` +- **Storybook**: `pnpm storybook` +- **Linting**: `pnpm lint:check` +- **Format**: `pnpm format:check` diff --git a/js/packages/react-core/package.json b/js/packages/react-core/package.json index 9de3ce69a..491cb559d 100644 --- a/js/packages/react-core/package.json +++ b/js/packages/react-core/package.json @@ -1,6 +1,6 @@ { "name": "@crayonai/react-core", - "version": "0.7.6", + "version": "0.7.7", "description": "Generative UI SDK", "license": "MIT", "main": "dist/index.js", diff --git a/js/packages/react-core/src/hooks/useThreadState.ts b/js/packages/react-core/src/hooks/useThreadState.ts index 898228e8d..a3ba475d7 100644 --- a/js/packages/react-core/src/hooks/useThreadState.ts +++ b/js/packages/react-core/src/hooks/useThreadState.ts @@ -19,5 +19,6 @@ export const useThreadState = (): ThreadState => { messages: useStore(threadManager, (store) => store.messages), error: useStore(threadManager, (store) => store.error), responseTemplates: useStore(threadManager, (store) => store.responseTemplates), + isInitialized: useStore(threadManager, (store) => store.isInitialized), }; }; diff --git a/js/packages/react-core/src/internal/useThreadManagerStore.ts b/js/packages/react-core/src/internal/useThreadManagerStore.ts index 3f1693f30..68ce7c2b7 100644 --- a/js/packages/react-core/src/internal/useThreadManagerStore.ts +++ b/js/packages/react-core/src/internal/useThreadManagerStore.ts @@ -10,6 +10,7 @@ export const useThreadManagerStore = (inputThreadManager: ThreadManager) => { const [threadManagerStore] = useState(() => create(() => ({ + isInitialized: inputThreadManager.isInitialized, isLoadingMessages: inputThreadManager.isLoadingMessages, isRunning: inputThreadManager.isRunning, messages: inputThreadManager.messages, @@ -31,12 +32,14 @@ export const useThreadManagerStore = (inputThreadManager: ThreadManager) => { messages: inputThreadManager.messages, error: inputThreadManager.error, isLoadingMessages: inputThreadManager.isLoadingMessages, + isInitialized: inputThreadManager.isInitialized, }); }, [ inputThreadManager.isRunning, inputThreadManager.messages, inputThreadManager.error, inputThreadManager.isLoadingMessages, + inputThreadManager.isInitialized, ]); if (inputThreadManagerRef.current.responseTemplates !== inputThreadManager.responseTemplates) { diff --git a/js/packages/react-core/src/types/chatManager.ts b/js/packages/react-core/src/types/chatManager.ts index 30c217e8a..1ff286f51 100644 --- a/js/packages/react-core/src/types/chatManager.ts +++ b/js/packages/react-core/src/types/chatManager.ts @@ -57,6 +57,8 @@ export type ThreadState = { * `responseTemplates` property provided to the hook. */ responseTemplates: Record; + /** Indicates if the thread manager is initialized and the thread can show threadList or welcome screen */ + isInitialized: boolean; }; /** diff --git a/js/packages/react-core/src/useThreadManager.ts b/js/packages/react-core/src/useThreadManager.ts index fa33e3225..1fe31944e 100644 --- a/js/packages/react-core/src/useThreadManager.ts +++ b/js/packages/react-core/src/useThreadManager.ts @@ -43,7 +43,7 @@ export const useThreadManager = (params: UseThreadManagerParams): ThreadManager propsRef.current = params; const store = useMemo(() => { - return createStore< + const store = createStore< ThreadManager & { abortController: AbortController | null; } @@ -54,6 +54,7 @@ export const useThreadManager = (params: UseThreadManagerParams): ThreadManager abortController: null, isRunning: false, isLoadingMessages: false, + isInitialized: false, setMessages: (messages: Message[]) => { set({ messages }); }, @@ -117,6 +118,21 @@ export const useThreadManager = (params: UseThreadManagerParams): ThreadManager ), }; }); + + /** + * Delay initialization to ensure proper thread manager setup. + * + * Why this delay is necessary: + * - Multiple React effects must run to determine the initial `selectedThreadId` + * - Without a definitive thread ID state (null vs. valid ID), the UI cannot + * determine whether to render the thread list or welcome screen + * - This 200ms buffer allows all effects to complete, providing a stable state + * before marking initialization as complete + */ + setTimeout(() => { + store.setState({ isInitialized: true }); + }, 200); + return store; }, [propsRef]); useEffect(() => { diff --git a/js/packages/react-ui/package.json b/js/packages/react-ui/package.json index f5f80363c..a52b50d8f 100644 --- a/js/packages/react-ui/package.json +++ b/js/packages/react-ui/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@crayonai/react-ui", "license": "MIT", - "version": "0.9.11", + "version": "0.9.12", "description": "Component library for Generative UI SDK", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/js/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx b/js/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx new file mode 100644 index 000000000..267d7da18 --- /dev/null +++ b/js/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx @@ -0,0 +1,145 @@ +import { useThreadActions, useThreadState } from "@crayonai/react-core"; +import clsx from "clsx"; +import { ArrowUp, Lightbulb } from "lucide-react"; +import { Fragment, ReactNode } from "react"; +import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; +import { Separator } from "../Separator"; + +export type ConversationStarterVariant = "short" | "long"; + +interface ConversationStarterItemProps extends ConversationStarterProps { + onClick: (prompt: string) => void; + variant: ConversationStarterVariant; +} + +/** + * Renders the appropriate icon based on the icon prop value + * - undefined: Show default lightbulb icon + * - ReactNode: Show the provided icon (use <> or React.Fragment for no icon) + */ +const renderIcon = (icon: ConversationStarterIcon | undefined): ReactNode => { + if (icon === undefined) { + return ; + } + return icon; +}; + +const ConversationStarterItem = ({ + displayText, + prompt, + onClick, + variant, + icon, +}: ConversationStarterItemProps) => { + const renderedIcon = renderIcon(icon); + + if (variant === "short") { + return ( + + ); + } + + // Long variant (detailed list style) + return ( + + ); +}; + +export interface ConversationStarterContainerProps { + starters: ConversationStarterProps[]; + className?: string; + /** + * Variant of the conversation starter + * - "short": Pill-style horizontal buttons (default) + * - "long": List items with icons and hover arrow + */ + variant?: ConversationStarterVariant; +} + +export const ConversationStarter = ({ + starters, + className, + variant = "short", +}: ConversationStarterContainerProps) => { + const { processMessage } = useThreadActions(); + const { isRunning, messages } = useThreadState(); + + const handleClick = (prompt: string) => { + if (isRunning) return; + processMessage({ + type: "prompt", + role: "user", + message: prompt, + }); + }; + + // Only show when there are no messages + if (messages.length > 0) { + return null; + } + + if (starters.length === 0) { + return null; + } + + return ( +
+ {starters.map((item, index) => ( + + + {/* Add separator between items in long variant */} + {variant === "long" && index < starters.length - 1 && ( +
+ +
+ )} +
+ ))} +
+ ); +}; + +export default ConversationStarter; diff --git a/js/packages/react-ui/src/components/BottomTray/Thread.tsx b/js/packages/react-ui/src/components/BottomTray/Thread.tsx index d1114697c..7a7c875f3 100644 --- a/js/packages/react-ui/src/components/BottomTray/Thread.tsx +++ b/js/packages/react-ui/src/components/BottomTray/Thread.tsx @@ -6,7 +6,7 @@ import { useThreadState, } from "@crayonai/react-core"; import clsx from "clsx"; -import { ArrowRight, Square } from "lucide-react"; +import { ArrowUp, Square } from "lucide-react"; import React, { memo, useEffect, useLayoutEffect, useRef } from "react"; import { useComposerState } from "../../hooks/useComposerState"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; @@ -35,7 +35,20 @@ export const ThreadContainer = ({ setArtifactRenderer(renderArtifact); }, [isArtifactActive, renderArtifact, setIsArtifactActive, setArtifactRenderer]); - return
{children}
; + const { isInitialized } = useThreadManagerSelector((store) => ({ + isInitialized: store.isInitialized, + })); + + return ( +
+ {children} +
+ ); }; export const ScrollArea = ({ @@ -265,7 +278,7 @@ export const Composer = ({ className }: { className?: string }) => { /> : } + icon={isRunning ? : } /> diff --git a/js/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx b/js/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx new file mode 100644 index 000000000..8cdb3da24 --- /dev/null +++ b/js/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx @@ -0,0 +1,106 @@ +import { useThreadState } from "@crayonai/react-core"; +import clsx from "clsx"; +import { ReactNode } from "react"; + +interface WelcomeScreenBaseProps { + /** + * Additional CSS class name + */ + className?: string; +} + +interface WelcomeScreenWithContentProps extends WelcomeScreenBaseProps { + /** + * The greeting/title text to display + */ + title?: string; + /** + * Optional description text to add more context + */ + description?: string; + /** + * Image to display - can be a URL object or a ReactNode + * - { url: string }: Renders an tag with default styling (64x64, object-fit: cover, rounded) + * - ReactNode: Renders the provided element directly (for custom icons, styled images, etc.) + */ + image?: { url: string } | ReactNode; + /** + * Children are not allowed when using props-based content + */ + children?: never; +} + +interface WelcomeScreenWithChildrenProps extends WelcomeScreenBaseProps { + /** + * Custom content to render inside the welcome screen + * When children are provided, title, description, and image are ignored + */ + children: ReactNode; + title?: never; + description?: never; + image?: never; +} + +export type WelcomeScreenProps = WelcomeScreenWithContentProps | WelcomeScreenWithChildrenProps; + +/** + * Type guard to check if image is a URL object + */ +const isImageUrl = (image: { url: string } | ReactNode): image is { url: string } => { + return typeof image === "object" && image !== null && "url" in image; +}; + +export const WelcomeScreen = (props: WelcomeScreenProps) => { + const { className } = props; + + const { messages } = useThreadState(); + + // Only show when there are no messages + if (messages.length > 0) { + return null; + } + + // Check if children are provided + if ("children" in props && props.children) { + return ( +
{props.children}
+ ); + } + + // Props-based content + const { title, description, image } = props as WelcomeScreenWithContentProps; + + const renderImage = () => { + if (!image) return null; + + if (isImageUrl(image)) { + return ( + {title + ); + } + + return image; + }; + + return ( +
+ {image && ( +
{renderImage()}
+ )} + {(title || description) && ( +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+ )} +
+ ); +}; + +export default WelcomeScreen; diff --git a/js/packages/react-ui/src/components/BottomTray/bottomTray.scss b/js/packages/react-ui/src/components/BottomTray/bottomTray.scss index 9ccbdaf8c..164830724 100644 --- a/js/packages/react-ui/src/components/BottomTray/bottomTray.scss +++ b/js/packages/react-ui/src/components/BottomTray/bottomTray.scss @@ -6,3 +6,5 @@ @use "./header.scss"; @use "./threadList.scss"; @use "./thread.scss"; +@use "./conversationStarter.scss"; +@use "./welcomeScreen.scss"; diff --git a/js/packages/react-ui/src/components/BottomTray/container.scss b/js/packages/react-ui/src/components/BottomTray/container.scss index f5fa719d2..dbb667759 100644 --- a/js/packages/react-ui/src/components/BottomTray/container.scss +++ b/js/packages/react-ui/src/components/BottomTray/container.scss @@ -11,9 +11,13 @@ width: 448px; overflow: hidden; flex-direction: column; + + // Cone animation: expand from bottom-right (where trigger is) + transform-origin: bottom right; transition: - transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.3s ease; + transform 0.25s ease-in-out, + opacity 0.25s ease-in-out, + clip-path 0.25s ease-in-out; border: 1px solid cssUtils.$stroke-default; border-radius: cssUtils.$rounded-2xl cssUtils.$rounded-2xl cssUtils.$rounded-3xl @@ -26,17 +30,19 @@ box-sizing: border-box; } - // Open state + // Open state - fully expanded &--open { - transform: translateY(0); + transform: scale(1); opacity: 1; + clip-path: ellipse(150% 150% at 100% 100%); } - // Closed state + // Closed state - collapsed to trigger point with cone clip &--closed { - transform: translateY(calc(100% + cssUtils.$spacing-l)); + transform: scale(0.1); opacity: 0; pointer-events: none; + clip-path: ellipse(0% 0% at 100% 100%); } // Mobile fullscreen @@ -52,8 +58,10 @@ border-radius: 0; border: none; + // On mobile, expand from bottom-right corner where trigger was &--closed { - transform: translateY(100dvh); + transform: scale(0.1); + clip-path: ellipse(0% 0% at 100% 100%); } } } diff --git a/js/packages/react-ui/src/components/BottomTray/conversationStarter.scss b/js/packages/react-ui/src/components/BottomTray/conversationStarter.scss new file mode 100644 index 000000000..c52a69c84 --- /dev/null +++ b/js/packages/react-ui/src/components/BottomTray/conversationStarter.scss @@ -0,0 +1,140 @@ +@use "../../cssUtils" as cssUtils; + +// Container styles +.crayon-bottom-tray-conversation-starter { + display: flex; + padding: 0 cssUtils.$spacing-s; + margin-bottom: cssUtils.$spacing-s; + + // Short variant - horizontal pill buttons + &--short { + flex-direction: column; + gap: cssUtils.$spacing-s; + } + + // Long variant - vertical list items with separators + &--long { + flex-direction: column; + gap: cssUtils.$spacing-2xs; + } + + // Separator wrapper for long variant + &__separator { + padding: cssUtils.$spacing-3xs cssUtils.$spacing-xs; + } +} + +// Short variant item (pill-style buttons) +.crayon-bottom-tray-conversation-starter-item-short { + display: flex; + align-items: flex-start; + gap: cssUtils.$spacing-xs; + width: fit-content; + padding: cssUtils.$spacing-s cssUtils.$spacing-m; + background-color: cssUtils.$bg-container; + border: 1px solid cssUtils.$stroke-default; + border-radius: cssUtils.$rounded-m; + cursor: pointer; + transition: all 0.15s ease; + @include cssUtils.typography(body, small); + color: cssUtils.$primary-text; + text-align: left; + + // Icon container + &__icon { + display: flex; + align-items: flex-start; + justify-content: center; + flex-shrink: 0; + padding-top: cssUtils.$spacing-3xs; + color: cssUtils.$primary-text; + } + + // Text + &__text { + flex: 1; + } + + &:not(:disabled):hover { + background-color: cssUtils.$bg-sunk; + border-color: cssUtils.$stroke-emphasis; + } + + &:not(:disabled):active { + background-color: cssUtils.$bg-sunk; + } + + @media (max-width: 480px) { + padding: cssUtils.$spacing-xs cssUtils.$spacing-s; + } +} + +// Long variant item (list-style with icon and arrow) +.crayon-bottom-tray-conversation-starter-item-long { + display: flex; + align-items: center; + gap: cssUtils.$spacing-0; + width: 100%; + padding: cssUtils.$spacing-xs cssUtils.$spacing-xs; + background-color: transparent; + border: none; + border-radius: cssUtils.$rounded-m; + cursor: pointer; + transition: background-color 0.15s ease; + @include cssUtils.typography(body, default); + color: cssUtils.$primary-text; + text-align: left; + overflow: hidden; + + // Content wrapper (icon + text) + &__content { + display: flex; + align-items: flex-start; + gap: cssUtils.$spacing-xs; + flex: 1; + min-width: 0; + } + + // Icon container - aligned to top with padding to align with first line of text + &__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding-top: cssUtils.$spacing-2xs; + color: cssUtils.$primary-text; + } + + // Text - allows wrapping for long content + &__text { + flex: 1; + min-width: 0; + line-height: 1.5; + // Text wraps naturally, no truncation + } + + // Arrow icon (shown on hover) + &__arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; + color: cssUtils.$primary-text; + } + + // Hover state + &:not(:disabled):hover { + background-color: cssUtils.$bg-sunk; + + .crayon-bottom-tray-conversation-starter-item-long__arrow { + opacity: 1; + } + } + + // Active/pressed state + &:not(:disabled):active { + background-color: cssUtils.$interactive-pressed; + } +} diff --git a/js/packages/react-ui/src/components/BottomTray/index.ts b/js/packages/react-ui/src/components/BottomTray/index.ts index c7fc7b5aa..4306ca7ac 100644 --- a/js/packages/react-ui/src/components/BottomTray/index.ts +++ b/js/packages/react-ui/src/components/BottomTray/index.ts @@ -1,4 +1,6 @@ export * from "./Container"; +export * from "./ConversationStarter"; export * from "./Header"; export * from "./Thread"; export * from "./Trigger"; +export * from "./WelcomeScreen"; diff --git a/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx b/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx new file mode 100644 index 000000000..ea160a8d5 --- /dev/null +++ b/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx @@ -0,0 +1,361 @@ +import { Meta } from "@storybook/blocks"; +import * as BottomTrayStories from "./BottomTray.stories"; + + + +# BottomTray + +The BottomTray is a **composable chat interface** that appears as a floating panel in the bottom-right corner of your application. It uses a composition pattern, giving you full control over placement and styling. + +## Component Structure + +``` +ChatProvider +├── Trigger (floating button to open/close) +└── Container (the tray panel) + └── ThreadContainer + ├── Header (logo, agent name, actions) + ├── WelcomeScreen (optional, shown when empty) + ├── ScrollArea (scrollable message area) + │ └── Messages + ├── ConversationStarter (optional, shown when empty) + └── Composer (text input) +``` + +## Basic Usage + +```tsx +import { useState } from "react"; +import { ChatProvider, useThreadListManager, useThreadManager } from "@crayonai/react-core"; +import { + Composer, + Container, + ConversationStarter, + Header, + MessageLoading, + Messages, + ScrollArea, + ThreadContainer, + Trigger, + WelcomeScreen, +} from "@crayonai/react-ui"; + +function MyApp() { + const [isOpen, setIsOpen] = useState(false); + + // Set up thread list management + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => { + // Fetch threads from your backend + return []; + }, + deleteThread: async (id) => { + // Delete thread from your backend + }, + updateThread: async (thread) => thread, + onSwitchToNew: () => {}, + onSelectThread: (threadId) => {}, + }); + + // Set up thread management + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async (threadId) => { + // Return empty for new thread, otherwise load messages + if (!threadId) return []; + // Fetch messages from your backend + return []; + }, + onProcessMessage: async ({ message, threadManager }) => { + // Send message to your AI backend + // Return assistant response + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: "Response from AI" }], + }, + ]; + }, + responseTemplates: [], + }); + + const hasMessages = threadManager.messages.length > 0; + + return ( + + {/* Trigger - floating button */} + setIsOpen(!isOpen)} isOpen={isOpen} /> + + {/* Container - the tray panel */} + + +
setIsOpen(false)} /> + + {/* WelcomeScreen - shown when no messages */} + {!hasMessages && ( + + } + /> + )} + + + } /> + + + + + + + ); +} +``` + +## Components + +### Trigger + +The floating button that opens/closes the tray. Positioned fixed in the bottom-right corner. + +```tsx + setIsOpen(!isOpen)} + isOpen={isOpen} // Controls shrink animation when open + className="custom-class" // Optional custom styling +> + {/* Optional custom content, defaults to chevron icon */} + 💬 Need Help? + +``` + +### Container + +The main tray panel. Controls visibility with `isOpen` prop. + +```tsx + + {children} + +``` + +### ThreadContainer + +Wrapper for thread content. Manages layout and artifact panels. + +```tsx +
} // Artifact content +> + {children} + +``` + +### Header + +Header with logo, agent name, thread list, new chat button, and minimize. + +```tsx +
setIsOpen(false)} // Minimize callback + hideMinimizeButton={false} // Hide X button + hideNewChatButton={false} // Hide new chat button + hideThreadListContainer={false} // Hide thread list + rightChildren={} // Custom actions +/> +``` + +### ScrollArea + +Scrollable container for messages with auto-scroll behavior. + +```tsx + + } /> + +``` + +### ConversationStarter + +Clickable prompts shown when thread is empty. Automatically hides when messages exist. + +```tsx +, + }, + { displayText: "What can you do?", prompt: "What can you do?" }, // Default lightbulb icon + { displayText: "No icon example", prompt: "No icon", icon: <> }, // No icon (use empty fragment) + ]} +/> +``` + +**Props:** + +- `variant` - `"short"` (pill-style buttons) or `"long"` (list items with separators) +- `starters` - Array of objects with: + - `displayText` - Text shown on the button + - `prompt` - Message sent when clicked + - `icon` - Optional: `ReactNode` for custom icon, `undefined` for default lightbulb, `<>` (empty fragment) for no icon + +### WelcomeScreen + +A centered welcome message shown when the thread is empty. Supports either props-based content or custom children. + +**Props-based usage with URL:** + +```tsx +{ + !hasMessages && ( + + ); +} +``` + +**Props-based usage with styled image:** + +```tsx +{ + !hasMessages && ( + + } + /> + ); +} +``` + +**Props-based usage with custom icon:** + +```tsx +{ + !hasMessages && ( + } + /> + ); +} +``` + +**Custom children usage:** + +```tsx +{ + !hasMessages && ( + +
+ +

Welcome!

+

Custom welcome content

+
+
+ ); +} +``` + +**Props:** + +- `title` - Greeting/title text (optional) +- `description` - Description text (optional) +- `image` - Image to display: `{ url: string }` for simple URL (renders with default 64x64 size, rounded corners), or `ReactNode` for custom styled elements (optional) +- `children` - Custom content (mutually exclusive with title/description/image) +- `className` - Additional CSS class (optional) + +**Note:** You must conditionally render WelcomeScreen based on `hasMessages` state. + +### Composer + +Text input with submit button. Handles enter key and running state. + +```tsx + +``` + +## Customization Examples + +### Custom Trigger Content + +```tsx + setIsOpen(!isOpen)} isOpen={isOpen}> + Chat + +``` + +### Without ConversationStarter + +Simply omit the component: + +```tsx + +
setIsOpen(false)} /> + + } /> + + + +``` + +### Minimal Header + +```tsx +
setIsOpen(false)} hideNewChatButton hideThreadListContainer /> +``` + +## Animation + +The tray uses a **cone animation** that expands from the trigger button: + +- Opens with scale + clip-path animation from bottom-right +- Trigger shrinks when tray is open +- Smooth spring easing for natural feel + +## Mobile Behavior + +- Tray goes fullscreen on mobile (less than 768px) +- Trigger hides when tray is open on mobile +- Touch-friendly interactions diff --git a/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx b/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx index f2a6b0d41..231ac936e 100644 --- a/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx +++ b/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx @@ -4,16 +4,19 @@ import { useThreadListManager, useThreadManager, } from "@crayonai/react-core"; +import { MessageSquare, Sparkles, Zap } from "lucide-react"; import { useState } from "react"; import { Composer, Container, + ConversationStarter, Header, MessageLoading, Messages, ScrollArea, ThreadContainer, Trigger, + WelcomeScreen, } from "../../BottomTray"; // @ts-ignore import styles from "./style.module.scss"; @@ -21,16 +24,27 @@ import logoUrl from "./thesysdev_logo.jpeg"; export default { title: "Components/BottomTray", - tags: ["dev", "!autodocs"], + tags: ["dev"], argTypes: { defaultOpen: { control: "boolean", description: "Whether the tray starts open", }, + variant: { + control: "select", + options: ["short", "long"], + description: "Conversation starter variant", + }, }, }; -const BottomTrayStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { +const BottomTrayStory = ({ + defaultOpen = false, + variant = "short", +}: { + defaultOpen?: boolean; + variant?: "short" | "long"; +}) => { const [isOpen, setIsOpen] = useState(defaultOpen); const threadListManager = useThreadListManager({ @@ -73,7 +87,9 @@ const BottomTrayStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => const threadManager = useThreadManager({ threadId: threadListManager.selectedThreadId, - loadThread: async () => { + loadThread: async (threadId) => { + // Return empty for new thread (null), otherwise return existing messages + if (!threadId) return []; return [ { id: crypto.randomUUID(), @@ -135,6 +151,28 @@ const BottomTrayStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => } /> + , + }, + { + displayText: + "Who is the president of Venezuela and where is he currently located (icon was not passed)", + prompt: "Who is the president of Venezuela and where is he currently located?", + // icon undefined = shows default lightbulb + }, + { + displayText: "Tell me about major stock (no icon with empty fragment)", + prompt: "Tell me about major stock", + icon: <>, // Empty fragment = no icon + }, + ]} + /> @@ -146,6 +184,7 @@ const BottomTrayStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => export const Default = { args: { defaultOpen: false, + variant: "short", }, render: (args: any) => , }; @@ -153,12 +192,27 @@ export const Default = { export const OpenByDefault = { args: { defaultOpen: true, + variant: "short", + }, + render: (args: any) => , +}; + +export const LongVariant = { + args: { + defaultOpen: true, + variant: "long", }, render: (args: any) => , }; // Example with custom trigger -const CustomTriggerStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { +const CustomTriggerStory = ({ + defaultOpen = false, + variant = "short", +}: { + defaultOpen?: boolean; + variant?: "short" | "long"; +}) => { const [isOpen, setIsOpen] = useState(defaultOpen); const threadListManager = useThreadListManager({ @@ -183,20 +237,24 @@ const CustomTriggerStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) const threadManager = useThreadManager({ threadId: threadListManager.selectedThreadId, - loadThread: async () => [ - { - id: crypto.randomUUID(), - role: "user", - type: "prompt", - message: "Hello", - }, - { - id: crypto.randomUUID(), - role: "assistant", - type: "response", - message: [{ type: "text", text: "Hello! How can I help you today?" }], - }, - ], + loadThread: async (threadId) => { + // Return empty for new thread (null), otherwise return existing messages + if (!threadId) return []; + return [ + { + id: crypto.randomUUID(), + role: "user", + type: "prompt", + message: "Hello", + }, + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: "Hello! How can I help you today?" }], + }, + ]; + }, onProcessMessage: async ({ message, threadManager }) => { const newMessage = Object.assign({}, message, { id: crypto.randomUUID() }) as Message; threadManager.appendMessages(newMessage); @@ -236,6 +294,29 @@ const CustomTriggerStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) } /> + , + }, + { + displayText: + "What can you help me with today? I need assistance with multiple tasks", + prompt: "What can you help me with today? I need assistance with multiple tasks", + icon: , + }, + { + displayText: "No icon example - this is a shorter prompt", + prompt: "No icon example - this is a shorter prompt", + icon: <>, // Empty fragment = no icon + }, + ]} + /> @@ -247,6 +328,243 @@ const CustomTriggerStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) export const CustomTrigger = { args: { defaultOpen: false, + variant: "short", + }, + render: (args: any) => , +}; + +export const CustomTriggerLongVariant = { + args: { + defaultOpen: true, + variant: "long", }, render: (args: any) => , }; + +// Example with WelcomeScreen +const WelcomeScreenStory = ({ + defaultOpen = true, + variant = "short", +}: { + defaultOpen?: boolean; + variant?: "short" | "long"; +}) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async () => [], // Start with empty thread to show welcome screen + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { id: crypto.randomUUID() }) as Message; + threadManager.appendMessages(newMessage); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: `You asked: "${message.message}"` }], + }, + ]; + }, + responseTemplates: [], + }); + + // this can be used instead of thread state + const hasMessages = threadManager.messages.length > 0; + + return ( +
+
+

Welcome Screen Example

+

This example shows the WelcomeScreen component with title, description, and logo.

+
+ + + setIsOpen(!isOpen)} isOpen={isOpen} /> + + + +
setIsOpen(false)} /> + + {hasMessages ? null : ( + + )} + + + } /> + + , + }, + { + displayText: "What can you do?", + prompt: "What can you do?", + }, + ]} + /> + + + + +
+ ); +}; + +export const WithWelcomeScreen = { + args: { + defaultOpen: true, + variant: "short", + }, + render: (args: any) => , +}; + +export const WithWelcomeScreenLongVariant = { + args: { + defaultOpen: true, + variant: "long", + }, + render: (args: any) => , +}; + +// Example with custom children in WelcomeScreen +const CustomWelcomeScreenStory = ({ + defaultOpen = true, + variant = "short", +}: { + defaultOpen?: boolean; + variant?: "short" | "long"; +}) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async () => [], + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { id: crypto.randomUUID() }) as Message; + threadManager.appendMessages(newMessage); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: `You asked: "${message.message}"` }], + }, + ]; + }, + responseTemplates: [], + }); + + const hasMessages = threadManager.messages.length > 0; + + return ( +
+
+

Custom Welcome Screen Example

+

This example shows WelcomeScreen with custom children instead of props.

+
+ + + setIsOpen(!isOpen)} isOpen={isOpen} /> + + + +
setIsOpen(false)} /> + + {hasMessages ? null : ( + +
+
+ +
+

+ Welcome to AI Assistant +

+

+ Your personal AI helper for all your questions +

+
+
+ )} + + + } /> + + , + }, + { + displayText: "What can you do?", + prompt: "What can you do?", + }, + ]} + /> + + + + +
+ ); +}; + +export const WithCustomWelcomeScreen = { + args: { + defaultOpen: true, + variant: "short", + }, + render: (args: any) => , +}; diff --git a/js/packages/react-ui/src/components/BottomTray/trigger.scss b/js/packages/react-ui/src/components/BottomTray/trigger.scss index 43b493c4b..5201bca17 100644 --- a/js/packages/react-ui/src/components/BottomTray/trigger.scss +++ b/js/packages/react-ui/src/components/BottomTray/trigger.scss @@ -14,7 +14,7 @@ color: cssUtils.$accent-primary-text; box-shadow: cssUtils.$shadow-xl; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.1s ease; z-index: 1000; overflow: hidden; @@ -26,6 +26,12 @@ &:active { transform: translateY(0); + scale: 0.95; + } + + // Desktop open state - shrink the trigger + &--open { + transform: scale(0.85); } // Mobile diff --git a/js/packages/react-ui/src/components/BottomTray/welcomeScreen.scss b/js/packages/react-ui/src/components/BottomTray/welcomeScreen.scss new file mode 100644 index 000000000..ef12c10c2 --- /dev/null +++ b/js/packages/react-ui/src/components/BottomTray/welcomeScreen.scss @@ -0,0 +1,50 @@ +@use "../../cssUtils" as cssUtils; + +.crayon-bottom-tray-welcome-screen { + display: flex; + flex-direction: column; + gap: cssUtils.$spacing-l; + height: 100%; + justify-content: center; + align-items: center; + + // Image container - minimal wrapper, styling handled by consumer + &__image-container { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + // Default image styles when using { url: string } + &__image { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: cssUtils.$rounded-xl; + } + + // Content container (title + description) + &__content { + display: flex; + flex-direction: column; + align-items: center; + gap: cssUtils.$spacing-xs; + text-align: center; + } + + // Title/greeting text + &__title { + @include cssUtils.typography(heading, small); + color: cssUtils.$primary-text; + margin: 0; + } + + // Description text + &__description { + @include cssUtils.typography(body, small); + color: cssUtils.$secondary-text; + margin: 0; + max-width: 280px; + } +} diff --git a/js/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx b/js/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx new file mode 100644 index 000000000..5679253af --- /dev/null +++ b/js/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx @@ -0,0 +1,145 @@ +import { useThreadActions, useThreadState } from "@crayonai/react-core"; +import clsx from "clsx"; +import { ArrowUp, Lightbulb } from "lucide-react"; +import { Fragment, ReactNode } from "react"; +import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; +import { Separator } from "../Separator"; + +export type ConversationStarterVariant = "short" | "long"; + +interface ConversationStarterItemProps extends ConversationStarterProps { + onClick: (prompt: string) => void; + variant: ConversationStarterVariant; +} + +/** + * Renders the appropriate icon based on the icon prop value + * - undefined: Show default lightbulb icon + * - ReactNode: Show the provided icon (use <> or React.Fragment for no icon) + */ +const renderIcon = (icon: ConversationStarterIcon | undefined): ReactNode => { + if (icon === undefined) { + return ; + } + return icon; +}; + +const ConversationStarterItem = ({ + displayText, + prompt, + onClick, + variant, + icon, +}: ConversationStarterItemProps) => { + const renderedIcon = renderIcon(icon); + + if (variant === "short") { + return ( + + ); + } + + // Long variant (detailed list style) + return ( + + ); +}; + +export interface ConversationStarterContainerProps { + starters: ConversationStarterProps[]; + className?: string; + /** + * Variant of the conversation starter + * - "short": Pill-style horizontal buttons (default) + * - "long": List items with icons and hover arrow + */ + variant?: ConversationStarterVariant; +} + +export const ConversationStarter = ({ + starters, + className, + variant = "short", +}: ConversationStarterContainerProps) => { + const { processMessage } = useThreadActions(); + const { isRunning, messages } = useThreadState(); + + const handleClick = (prompt: string) => { + if (isRunning) return; + processMessage({ + type: "prompt", + role: "user", + message: prompt, + }); + }; + + // Only show when there are no messages + if (messages.length > 0) { + return null; + } + + if (starters.length === 0) { + return null; + } + + return ( +
+ {starters.map((item, index) => ( + + + {/* Add separator between items in long variant */} + {variant === "long" && index < starters.length - 1 && ( +
+ +
+ )} +
+ ))} +
+ ); +}; + +export default ConversationStarter; diff --git a/js/packages/react-ui/src/components/CopilotShell/Thread.tsx b/js/packages/react-ui/src/components/CopilotShell/Thread.tsx index f53a403bb..f9d3c45e4 100644 --- a/js/packages/react-ui/src/components/CopilotShell/Thread.tsx +++ b/js/packages/react-ui/src/components/CopilotShell/Thread.tsx @@ -6,7 +6,7 @@ import { useThreadState, } from "@crayonai/react-core"; import clsx from "clsx"; -import { ArrowRight, Square } from "lucide-react"; +import { ArrowUp, Square } from "lucide-react"; import React, { memo, useEffect, useLayoutEffect, useRef } from "react"; import { useComposerState } from "../../hooks/useComposerState"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; @@ -35,7 +35,20 @@ export const ThreadContainer = ({ setArtifactRenderer(renderArtifact); }, [isArtifactActive, setIsArtifactActive]); - return
{children}
; + const { isInitialized } = useThreadManagerSelector((store) => ({ + isInitialized: store.isInitialized, + })); + + return ( +
+ {children} +
+ ); }; export const ScrollArea = ({ @@ -268,7 +281,7 @@ export const Composer = ({ className }: { className?: string }) => { /> : } + icon={isRunning ? : } />
diff --git a/js/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx b/js/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx new file mode 100644 index 000000000..b59125eef --- /dev/null +++ b/js/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx @@ -0,0 +1,106 @@ +import { useThreadState } from "@crayonai/react-core"; +import clsx from "clsx"; +import { ReactNode } from "react"; + +interface WelcomeScreenBaseProps { + /** + * Additional CSS class name + */ + className?: string; +} + +interface WelcomeScreenWithContentProps extends WelcomeScreenBaseProps { + /** + * The greeting/title text to display + */ + title?: string; + /** + * Optional description text to add more context + */ + description?: string; + /** + * Image to display - can be a URL object or a ReactNode + * - { url: string }: Renders an tag with default styling (64x64, object-fit: cover, rounded) + * - ReactNode: Renders the provided element directly (for custom icons, styled images, etc.) + */ + image?: { url: string } | ReactNode; + /** + * Children are not allowed when using props-based content + */ + children?: never; +} + +interface WelcomeScreenWithChildrenProps extends WelcomeScreenBaseProps { + /** + * Custom content to render inside the welcome screen + * When children are provided, title, description, and image are ignored + */ + children: ReactNode; + title?: never; + description?: never; + image?: never; +} + +export type WelcomeScreenProps = WelcomeScreenWithContentProps | WelcomeScreenWithChildrenProps; + +/** + * Type guard to check if image is a URL object + */ +const isImageUrl = (image: { url: string } | ReactNode): image is { url: string } => { + return typeof image === "object" && image !== null && "url" in image; +}; + +export const WelcomeScreen = (props: WelcomeScreenProps) => { + const { className } = props; + + const { messages } = useThreadState(); + + // Only show when there are no messages + if (messages.length > 0) { + return null; + } + + // Check if children are provided + if ("children" in props && props.children) { + return ( +
{props.children}
+ ); + } + + // Props-based content + const { title, description, image } = props as WelcomeScreenWithContentProps; + + const renderImage = () => { + if (!image) return null; + + if (isImageUrl(image)) { + return ( + {title + ); + } + + return image; + }; + + return ( +
+ {image && ( +
{renderImage()}
+ )} + {(title || description) && ( +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+ )} +
+ ); +}; + +export default WelcomeScreen; diff --git a/js/packages/react-ui/src/components/CopilotShell/conversationStarter.scss b/js/packages/react-ui/src/components/CopilotShell/conversationStarter.scss new file mode 100644 index 000000000..71ceb2806 --- /dev/null +++ b/js/packages/react-ui/src/components/CopilotShell/conversationStarter.scss @@ -0,0 +1,140 @@ +@use "../../cssUtils" as cssUtils; + +// Container styles +.crayon-copilot-shell-conversation-starter { + display: flex; + padding: 0 cssUtils.$spacing-l; + margin-bottom: cssUtils.$spacing-s; + + // Short variant - horizontal pill buttons + &--short { + flex-direction: column; + gap: cssUtils.$spacing-s; + } + + // Long variant - vertical list items with separators + &--long { + flex-direction: column; + gap: cssUtils.$spacing-2xs; + } + + // Separator wrapper for long variant + &__separator { + padding: cssUtils.$spacing-3xs cssUtils.$spacing-xs; + } +} + +// Short variant item (pill-style buttons) +.crayon-copilot-shell-conversation-starter-item-short { + display: flex; + align-items: flex-start; + gap: cssUtils.$spacing-xs; + width: fit-content; + padding: cssUtils.$spacing-s cssUtils.$spacing-m; + background-color: cssUtils.$bg-container; + border: 1px solid cssUtils.$stroke-default; + border-radius: cssUtils.$rounded-m; + cursor: pointer; + transition: all 0.15s ease; + @include cssUtils.typography(body, small); + color: cssUtils.$primary-text; + text-align: left; + + // Icon container + &__icon { + display: flex; + align-items: flex-start; + justify-content: center; + flex-shrink: 0; + padding-top: cssUtils.$spacing-3xs; + color: cssUtils.$primary-text; + } + + // Text + &__text { + flex: 1; + } + + &:not(:disabled):hover { + background-color: cssUtils.$bg-sunk; + border-color: cssUtils.$stroke-emphasis; + } + + &:not(:disabled):active { + background-color: cssUtils.$bg-sunk; + } + + @media (max-width: 480px) { + padding: cssUtils.$spacing-xs cssUtils.$spacing-s; + } +} + +// Long variant item (list-style with icon and arrow) +.crayon-copilot-shell-conversation-starter-item-long { + display: flex; + align-items: center; + gap: cssUtils.$spacing-0; + width: 100%; + padding: cssUtils.$spacing-xs cssUtils.$spacing-s; + background-color: transparent; + border: none; + border-radius: cssUtils.$rounded-m; + cursor: pointer; + transition: background-color 0.15s ease; + @include cssUtils.typography(body, default); + color: cssUtils.$primary-text; + text-align: left; + overflow: hidden; + + // Content wrapper (icon + text) + &__content { + display: flex; + align-items: flex-start; + gap: cssUtils.$spacing-s; + flex: 1; + min-width: 0; + } + + // Icon container - aligned to top with padding to align with first line of text + &__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding-top: cssUtils.$spacing-2xs; + color: cssUtils.$primary-text; + } + + // Text - allows wrapping for long content + &__text { + flex: 1; + min-width: 0; + line-height: 1.5; + // Text wraps naturally, no truncation + } + + // Arrow icon (shown on hover) + &__arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; + color: cssUtils.$primary-text; + } + + // Hover state + &:not(:disabled):hover { + background-color: cssUtils.$bg-sunk; + + .crayon-copilot-shell-conversation-starter-item-long__arrow { + opacity: 1; + } + } + + // Active/pressed state + &:not(:disabled):active { + background-color: cssUtils.$interactive-pressed; + } +} diff --git a/js/packages/react-ui/src/components/CopilotShell/copilotShell.scss b/js/packages/react-ui/src/components/CopilotShell/copilotShell.scss index 16c7f0c47..32987f56b 100644 --- a/js/packages/react-ui/src/components/CopilotShell/copilotShell.scss +++ b/js/packages/react-ui/src/components/CopilotShell/copilotShell.scss @@ -1,6 +1,8 @@ @use "../../cssUtils" as cssUtils; @use "./thread.scss"; @use "./header.scss"; +@use "./conversationStarter.scss"; +@use "./welcomeScreen.scss"; .crayon-copilot-shell-container { display: flex; diff --git a/js/packages/react-ui/src/components/CopilotShell/index.ts b/js/packages/react-ui/src/components/CopilotShell/index.ts index f19ab917b..7c3629b7c 100644 --- a/js/packages/react-ui/src/components/CopilotShell/index.ts +++ b/js/packages/react-ui/src/components/CopilotShell/index.ts @@ -1,3 +1,5 @@ export * from "./Container"; +export * from "./ConversationStarter"; export * from "./Header"; export * from "./Thread"; +export * from "./WelcomeScreen"; diff --git a/js/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx b/js/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx new file mode 100644 index 000000000..9a5a5e101 --- /dev/null +++ b/js/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx @@ -0,0 +1,363 @@ +import { Meta } from "@storybook/blocks"; +import * as ShellStories from "./Shell.stories"; + + + +# CopilotShell + +The CopilotShell is a **composable sidebar chat interface** designed to be embedded as a side panel in your application. It uses a composition pattern, giving you full control over placement and styling. + +## Component Structure + +``` +ChatProvider +└── Container (sidebar panel) + └── ThreadContainer + ├── Header (logo, agent name) + ├── WelcomeScreen (optional, shown when empty) + ├── ScrollArea (scrollable message area) + │ └── Messages + ├── ConversationStarter (optional, shown when empty) + └── Composer (text input) +``` + +## Basic Usage + +```tsx +import { useState } from "react"; +import { ChatProvider, useThreadListManager, useThreadManager } from "@crayonai/react-core"; +import { + Composer, + Container, + ConversationStarter, + Header, + MessageLoading, + Messages, + ScrollArea, + ThreadContainer, + WelcomeScreen, +} from "@crayonai/react-ui/CopilotShell"; + +function MyCopilot() { + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async (threadId) => { + if (!threadId) return []; + // Load messages for thread + return []; + }, + onProcessMessage: async ({ message, threadManager }) => { + threadManager.appendMessages({ ...message, id: crypto.randomUUID() }); + // Send message to your AI backend + // Return assistant response + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: "Response from AI" }], + }, + ]; + }, + responseTemplates: [], + }); + + const hasMessages = threadManager.messages.length > 0; + + return ( + + + +
+ + {/* WelcomeScreen - shown when no messages */} + {!hasMessages && ( + + } + /> + )} + + + } /> + + + + + + + ); +} +``` + +## Individual Components + +### Container + +The main wrapper for the CopilotShell sidebar. + +```tsx + + {children} + +``` + +### ThreadContainer + +Wrapper for the thread content with optional artifact panel support. + +```tsx +
} // Artifact content +> + {children} + +``` + +### Header + +The header with logo and agent name. + +```tsx +
+``` + +### ScrollArea + +Scrollable container for messages with smart scroll behavior. + +```tsx + + } /> + +``` + +### ConversationStarter + +Clickable prompts shown when the thread is empty. Automatically submits the prompt as a message when clicked. + +```tsx +, + }, + { displayText: "What can you do?", prompt: "What can you do?" }, // Default lightbulb icon + { displayText: "No icon example", prompt: "No icon", icon: <> }, // No icon (use empty fragment) + ]} + className="custom-class" // Optional custom styling +/> +``` + +**Props:** + +- `variant` - `"short"` (pill-style buttons) or `"long"` (list items with separators) +- `starters` - Array of objects with: + - `displayText` - Text shown on the button + - `prompt` - Message sent when clicked + - `icon` - Optional: `ReactNode` for custom icon, `undefined` for default lightbulb, `<>` (empty fragment) for no icon +- `className` - Optional additional CSS class + +**Behavior:** + +- Only visible when there are no messages in the thread +- Clicking a button submits the `prompt` as a user message +- Clicks are ignored when a response is being generated + +### WelcomeScreen + +A centered welcome message shown when the thread is empty. Supports either props-based content or custom children. + +**Props-based usage with URL:** + +```tsx +{ + !hasMessages && ( + + ); +} +``` + +**Props-based usage with styled image:** + +```tsx +{ + !hasMessages && ( + + } + /> + ); +} +``` + +**Props-based usage with custom icon:** + +```tsx +{ + !hasMessages && ( + } + /> + ); +} +``` + +**Custom children usage:** + +```tsx +{ + !hasMessages && ( + +
+ +

Welcome!

+

Custom welcome content

+
+
+ ); +} +``` + +**Props:** + +- `title` - Greeting/title text (optional) +- `description` - Description text (optional) +- `image` - Image to display: `{ url: string }` for simple URL (renders with default 64x64 size, rounded corners), or `ReactNode` for custom styled elements (optional) +- `children` - Custom content (mutually exclusive with title/description/image) +- `className` - Additional CSS class (optional) + +**Note:** You must conditionally render WelcomeScreen based on `hasMessages` state. + +### Composer + +Text input for sending messages. + +```tsx + +``` + +## Layout Integration + +CopilotShell is designed to be placed as a sidebar: + +```tsx +function AppLayout() { + const hasMessages = threadManager.messages.length > 0; + + return ( +
+ {/* Main content area */} +
{/* Your app content */}
+ + {/* Copilot sidebar */} + +
+ ); +} +``` + +## Comparison with BottomTray + +| Feature | CopilotShell | BottomTray | +| -------- | ------------------- | ---------------------- | +| Layout | Sidebar panel | Floating panel | +| Position | Fixed sidebar | Bottom-right corner | +| Trigger | Always visible | Floating button | +| Mobile | Responsive | Fullscreen | +| Use case | Main chat interface | Secondary/support chat | + +## CSS Classes + +All CopilotShell components use the `crayon-copilot-shell-` prefix: + +- `.crayon-copilot-shell-thread-container` +- `.crayon-copilot-shell-thread-scroll-area` +- `.crayon-copilot-shell-thread-messages` +- `.crayon-copilot-shell-thread-message-user` +- `.crayon-copilot-shell-thread-message-assistant` +- `.crayon-copilot-shell-thread-composer` +- `.crayon-copilot-shell-conversation-starter` +- `.crayon-copilot-shell-conversation-starter-item-short` +- `.crayon-copilot-shell-conversation-starter-item-long` +- `.crayon-copilot-shell-welcome-screen` +- `.crayon-copilot-shell-welcome-screen__image-container` +- `.crayon-copilot-shell-welcome-screen__content` +- `.crayon-copilot-shell-welcome-screen__title` +- `.crayon-copilot-shell-welcome-screen__description` diff --git a/js/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx b/js/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx index fecfb6aee..0041821da 100644 --- a/js/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx +++ b/js/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx @@ -4,14 +4,17 @@ import { useThreadListManager, useThreadManager, } from "@crayonai/react-core"; +import { Sparkles } from "lucide-react"; import { Composer, Container, + ConversationStarter, Header, MessageLoading, Messages, ScrollArea, ThreadContainer, + WelcomeScreen, } from "../../CopilotShell"; // @ts-ignore import styles from "./style.module.scss"; @@ -19,11 +22,39 @@ import logoUrl from "./thesysdev_logo.jpeg"; export default { title: "Components/CopilotShell", - tags: ["dev", "!autodocs"], + tags: ["dev"], + argTypes: { + variant: { + control: "select", + options: ["short", "long"], + description: "Conversation starter variant", + }, + }, }; +const SAMPLE_STARTERS = [ + { + displayText: "Tell me about my portfolio", + prompt: "Tell me about the latest stock market trends and how they affect my portfolio", + icon: , + }, + { + displayText: "Who is the president of Venezuela and where is he currently located?", + prompt: "Who is the president of Venezuela and where is he currently located?", + // icon undefined = shows default lightbulb + }, + { + displayText: "Tell me about major stock (no icon)", + prompt: "Tell me about major stock", + icon: <>, // Empty fragment = no icon + }, +]; + export const Default = { - render: (args: any) => { + args: { + variant: "short", + }, + render: ({ variant }: { variant: "short" | "long" }) => { const threadListManager = useThreadListManager({ createThread: async () => { return { @@ -64,7 +95,8 @@ export const Default = { const threadManager = useThreadManager({ threadId: threadListManager.selectedThreadId, - loadThread: async () => { + loadThread: async (threadId) => { + if (!threadId) return []; return [ { id: crypto.randomUUID(), @@ -76,11 +108,11 @@ export const Default = { id: crypto.randomUUID(), role: "assistant", type: "response", - message: [{ type: "text", text: "Hello" }], + message: [{ type: "text", text: "Hello! How can I help you today?" }], }, ]; }, - onProcessMessage: async ({ message, threadManager, abortController }) => { + onProcessMessage: async ({ message, threadManager }) => { const newMessage = Object.assign({}, message, { id: crypto.randomUUID(), }) as Message; @@ -91,7 +123,7 @@ export const Default = { id: crypto.randomUUID(), role: "assistant", type: "response", - message: [{ type: "text", text: "sadfasdf" }], + message: [{ type: "text", text: "This is a response from the AI assistant." }], }, ]; }, @@ -108,6 +140,230 @@ export const Default = { } /> + + + + + +
+ ); + }, +}; + +export const LongVariant = { + args: { + variant: "long", + }, + render: ({ variant }: { variant: "short" | "long" }) => { + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async () => [], // Start with empty thread to show starters + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { + id: crypto.randomUUID(), + }) as Message; + threadManager.appendMessages(newMessage); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: `You asked: "${message.message}"` }], + }, + ]; + }, + responseTemplates: [], + }); + + return ( +
+
+ + + +
+ + } /> + + + + + + +
+ ); + }, +}; + +// Example with WelcomeScreen +export const WithWelcomeScreen = { + args: { + variant: "short", + }, + render: ({ variant }: { variant: "short" | "long" }) => { + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async () => [], // Start with empty thread to show welcome screen + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { + id: crypto.randomUUID(), + }) as Message; + threadManager.appendMessages(newMessage); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: `You asked: "${message.message}"` }], + }, + ]; + }, + responseTemplates: [], + }); + + const hasMessages = threadManager.messages.length > 0; + + return ( +
+
+ + + +
+ + {hasMessages ? null : ( + + )} + + + } /> + + + + + + +
+ ); + }, +}; + +// Example with custom children in WelcomeScreen +export const WithCustomWelcomeScreen = { + args: { + variant: "short", + }, + render: ({ variant }: { variant: "short" | "long" }) => { + const threadListManager = useThreadListManager({ + createThread: async () => ({ + threadId: crypto.randomUUID(), + title: "New Chat", + createdAt: new Date(), + isRunning: false, + }), + fetchThreadList: async () => [], + deleteThread: async () => {}, + updateThread: async (t) => t, + onSwitchToNew: () => {}, + onSelectThread: () => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async () => [], + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { + id: crypto.randomUUID(), + }) as Message; + threadManager.appendMessages(newMessage); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return [ + { + id: crypto.randomUUID(), + role: "assistant", + type: "response", + message: [{ type: "text", text: `You asked: "${message.message}"` }], + }, + ]; + }, + responseTemplates: [], + }); + + const hasMessages = threadManager.messages.length > 0; + + return ( +
+
+ + + +
+ + {hasMessages ? null : ( + +
+
+ +
+

+ Welcome to AI Assistant +

+

+ Your personal AI helper for all your questions +

+
+
+ )} + + + } /> + + diff --git a/js/packages/react-ui/src/components/CopilotShell/welcomeScreen.scss b/js/packages/react-ui/src/components/CopilotShell/welcomeScreen.scss new file mode 100644 index 000000000..df13edfbe --- /dev/null +++ b/js/packages/react-ui/src/components/CopilotShell/welcomeScreen.scss @@ -0,0 +1,50 @@ +@use "../../cssUtils" as cssUtils; + +.crayon-copilot-shell-welcome-screen { + display: flex; + flex-direction: column; + gap: cssUtils.$spacing-l; + height: 100%; + justify-content: center; + align-items: center; + + // Image container - minimal wrapper, styling handled by consumer + &__image-container { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + // Default image styles when using { url: string } + &__image { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: cssUtils.$rounded-xl; + } + + // Content container (title + description) + &__content { + display: flex; + flex-direction: column; + align-items: center; + gap: cssUtils.$spacing-xs; + text-align: center; + } + + // Title/greeting text + &__title { + @include cssUtils.typography(heading, small); + color: cssUtils.$primary-text; + margin: 0; + } + + // Description text + &__description { + @include cssUtils.typography(body, small); + color: cssUtils.$secondary-text; + margin: 0; + max-width: 280px; + } +} diff --git a/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx b/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx index ee9124b76..a6f06a2e3 100644 --- a/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx +++ b/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx @@ -1,15 +1,20 @@ +import { useThreadState } from "@crayonai/react-core"; import { useState } from "react"; import { ScrollVariant } from "../../hooks/useScrollToBottom"; import { Composer, Container, + ConversationStarter, Header, MessageLoading, Messages, ScrollArea, ThreadContainer, Trigger, + WelcomeScreen, } from "../BottomTray"; +import { ConversationStartersConfig, WelcomeMessageConfig } from "./types"; +import { isChatEmpty, isWelcomeComponent } from "./utils"; interface ComposedBottomTrayProps { logoUrl?: string; @@ -24,8 +29,59 @@ interface ComposedBottomTrayProps { onOpenChange?: (isOpen: boolean) => void; /** Default open state (uncontrolled) */ defaultOpen?: boolean; + /** Welcome message shown when thread is empty */ + welcomeMessage?: WelcomeMessageConfig; + /** Conversation starters shown when thread is empty */ + conversationStarters?: ConversationStartersConfig; } +/** + * Internal component to render welcome message based on thread state + */ +const WelcomeMessageRenderer = ({ welcomeMessage }: { welcomeMessage?: WelcomeMessageConfig }) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!welcomeMessage || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + if (isWelcomeComponent(welcomeMessage)) { + const CustomWelcome = welcomeMessage; + // Wrap custom component with WelcomeScreen for proper container styling + return ( + + + + ); + } + + return ( + + ); +}; + +const ConversationStartersRenderer = ({ + conversationStarters, +}: { + conversationStarters?: ConversationStartersConfig; +}) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!conversationStarters || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + return ( + + ); +}; export const ComposedBottomTray = ({ logoUrl = "https://crayonai.org/img/logo.png", agentName = "My Agent", @@ -36,6 +92,8 @@ export const ComposedBottomTray = ({ isOpen: controlledIsOpen, onOpenChange, defaultOpen = false, + welcomeMessage, + conversationStarters, }: ComposedBottomTrayProps) => { const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(defaultOpen); @@ -53,16 +111,20 @@ export const ComposedBottomTray = ({ <> {/* Trigger is always visible - toggles the tray (hidden on mobile when open) */} handleOpenChange(!isOpen)} isOpen={isOpen}> - Logo + {logoUrl ? ( + Logo + ) : null} {/* Controlled container */}
handleOpenChange(false)} /> + } /> + diff --git a/js/packages/react-ui/src/components/CrayonChat/ComposedCopilot.tsx b/js/packages/react-ui/src/components/CrayonChat/ComposedCopilot.tsx index 2957757e8..30910f151 100644 --- a/js/packages/react-ui/src/components/CrayonChat/ComposedCopilot.tsx +++ b/js/packages/react-ui/src/components/CrayonChat/ComposedCopilot.tsx @@ -1,13 +1,19 @@ +import { useThreadState } from "@crayonai/react-core"; import { ScrollVariant } from "../../hooks/useScrollToBottom"; import { Composer, Container, + ConversationStarter, Header, MessageLoading, Messages, ScrollArea, ThreadContainer, + WelcomeScreen, } from "../CopilotShell"; +import { ConversationStartersConfig, WelcomeMessageConfig } from "./types"; +import { isChatEmpty, isWelcomeComponent } from "./utils"; + interface ComposedCopilotProps { logoUrl?: string; agentName?: string; @@ -15,8 +21,59 @@ interface ComposedCopilotProps { scrollVariant: ScrollVariant; isArtifactActive?: boolean; renderArtifact?: () => React.ReactNode; + /** Welcome message shown when thread is empty */ + welcomeMessage?: WelcomeMessageConfig; + /** Conversation starters shown when thread is empty */ + conversationStarters?: ConversationStartersConfig; } +/** + * Internal component to render welcome message based on thread state + */ +const WelcomeMessageRenderer = ({ welcomeMessage }: { welcomeMessage?: WelcomeMessageConfig }) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!welcomeMessage || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + if (isWelcomeComponent(welcomeMessage)) { + const CustomWelcome = welcomeMessage; + // Wrap custom component with WelcomeScreen for proper container styling + return ( + + + + ); + } + + return ( + + ); +}; + +const ConversationStartersRenderer = ({ + conversationStarters, +}: { + conversationStarters?: ConversationStartersConfig; +}) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!conversationStarters || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + return ( + + ); +}; export const ComposedCopilot = ({ logoUrl = "https://crayonai.org/img/logo.png", agentName = "My Agent", @@ -24,14 +81,18 @@ export const ComposedCopilot = ({ scrollVariant, isArtifactActive, renderArtifact, + welcomeMessage, + conversationStarters, }: ComposedCopilotProps) => { return (
+ } /> + diff --git a/js/packages/react-ui/src/components/CrayonChat/ComposedStandalone.tsx b/js/packages/react-ui/src/components/CrayonChat/ComposedStandalone.tsx index 83293a30c..61e41b58a 100644 --- a/js/packages/react-ui/src/components/CrayonChat/ComposedStandalone.tsx +++ b/js/packages/react-ui/src/components/CrayonChat/ComposedStandalone.tsx @@ -1,7 +1,9 @@ +import { useThreadState } from "@crayonai/react-core"; import { ScrollVariant } from "../../hooks/useScrollToBottom"; import { Composer, Container, + ConversationStarter, MessageLoading, Messages, MobileHeader, @@ -13,7 +15,11 @@ import { SidebarSeparator, ThreadContainer, ThreadList, + WelcomeScreen, } from "../Shell"; +import { ConversationStartersConfig, WelcomeMessageConfig } from "./types"; +import { isChatEmpty, isWelcomeComponent } from "./utils"; + interface ComposedStandaloneProps { logoUrl?: string; agentName?: string; @@ -21,8 +27,70 @@ interface ComposedStandaloneProps { scrollVariant: ScrollVariant; isArtifactActive?: boolean; renderArtifact?: () => React.ReactNode; + /** Welcome message shown when thread is empty */ + welcomeMessage?: WelcomeMessageConfig; + /** Conversation starters shown when thread is empty */ + conversationStarters?: ConversationStartersConfig; } +/** + * Internal component to render welcome message based on thread state + * For Shell, WelcomeScreen includes built-in starters on desktop + */ +const WelcomeMessageRenderer = ({ + welcomeMessage, + conversationStarters, +}: { + welcomeMessage?: WelcomeMessageConfig; + conversationStarters?: ConversationStartersConfig; +}) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!welcomeMessage || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + if (isWelcomeComponent(welcomeMessage)) { + const CustomWelcome = welcomeMessage; + // Wrap custom component with WelcomeScreen for proper container styling + // Note: starters are rendered separately via ConversationStarter component + return ( + + + + ); + } + + return ( + + ); +}; + +const ConversationStartersRenderer = ({ + conversationStarters, +}: { + conversationStarters?: ConversationStartersConfig; +}) => { + const { messages, isLoadingMessages } = useThreadState(); + + if (!conversationStarters || isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + return ( + + ); +}; + export const ComposedStandalone = ({ logoUrl = "https://crayonai.org/img/logo.png", agentName = "My Agent", @@ -30,6 +98,8 @@ export const ComposedStandalone = ({ scrollVariant, isArtifactActive, renderArtifact, + welcomeMessage, + conversationStarters, }: ComposedStandaloneProps) => { return ( @@ -43,9 +113,14 @@ export const ComposedStandalone = ({ + } /> + diff --git a/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx b/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx index 11be2be58..fd27dc0ce 100644 --- a/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx +++ b/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx @@ -18,6 +18,7 @@ import { ThemeProps, ThemeProvider } from "../ThemeProvider"; import { ComposedBottomTray } from "./ComposedBottomTray"; import { ComposedCopilot } from "./ComposedCopilot"; import { ComposedStandalone } from "./ComposedStandalone"; +import { ConversationStartersConfig, WelcomeMessageConfig } from "./types"; type BaseCrayonChatProps = { // options used when threadManager not provided @@ -47,6 +48,11 @@ type BaseCrayonChatProps = { isArtifactActive?: boolean; renderArtifact?: () => React.ReactNode; + + /** Welcome message shown when thread is empty */ + welcomeMessage?: WelcomeMessageConfig; + /** Conversation starters shown when thread is empty */ + conversationStarters?: ConversationStartersConfig; }; type BottomTrayProps = { @@ -87,6 +93,8 @@ export const CrayonChat = (props: CrayonChatProps) => { disableThemeProvider, isArtifactActive, renderArtifact, + welcomeMessage, + conversationStarters, } = props; // Extract bottom-tray specific props if type is bottom-tray @@ -179,6 +187,8 @@ export const CrayonChat = (props: CrayonChatProps) => { scrollVariant={scrollVariant} isArtifactActive={isArtifactActive} renderArtifact={renderArtifact} + welcomeMessage={welcomeMessage} + conversationStarters={conversationStarters} /> ) : type === "bottom-tray" ? ( { isOpen={isOpen} onOpenChange={onOpenChange} defaultOpen={defaultOpen} + welcomeMessage={welcomeMessage} + conversationStarters={conversationStarters} /> ) : ( { scrollVariant={scrollVariant} isArtifactActive={isArtifactActive} renderArtifact={renderArtifact} + welcomeMessage={welcomeMessage} + conversationStarters={conversationStarters} /> )} diff --git a/js/packages/react-ui/src/components/CrayonChat/composedBottomTray.scss b/js/packages/react-ui/src/components/CrayonChat/composedBottomTray.scss new file mode 100644 index 000000000..4f2370cb5 --- /dev/null +++ b/js/packages/react-ui/src/components/CrayonChat/composedBottomTray.scss @@ -0,0 +1,8 @@ +@use "../../cssUtils" as cssUtils; + +.crayon-bottom-tray-trigger-logo { + width: "100%"; + height: "100%"; + object-fit: "cover"; + border-radius: cssUtils.$rounded-m; +} diff --git a/js/packages/react-ui/src/components/CrayonChat/crayonChat.scss b/js/packages/react-ui/src/components/CrayonChat/crayonChat.scss index e69de29bb..adb45f588 100644 --- a/js/packages/react-ui/src/components/CrayonChat/crayonChat.scss +++ b/js/packages/react-ui/src/components/CrayonChat/crayonChat.scss @@ -0,0 +1 @@ +@forward "./composedBottomTray.scss"; diff --git a/js/packages/react-ui/src/components/CrayonChat/index.ts b/js/packages/react-ui/src/components/CrayonChat/index.ts index 2d05fedb6..710f82a99 100644 --- a/js/packages/react-ui/src/components/CrayonChat/index.ts +++ b/js/packages/react-ui/src/components/CrayonChat/index.ts @@ -2,3 +2,5 @@ export * from "./ComposedBottomTray"; export * from "./ComposedCopilot"; export * from "./ComposedStandalone"; export { CrayonChat } from "./CrayonChat"; +export type { ConversationStartersConfig, WelcomeMessageConfig } from "./types"; +export { isChatEmpty, isWelcomeComponent } from "./utils"; diff --git a/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.mdx b/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.mdx new file mode 100644 index 000000000..051e5e74e --- /dev/null +++ b/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.mdx @@ -0,0 +1,457 @@ +import { Meta } from "@storybook/blocks"; +import * as CrayonChatStories from "./CrayonChat.stories"; + + + +# CrayonChat + +CrayonChat is a **high-level, batteries-included chat component** that provides a complete chat experience with minimal configuration. It wraps all the complexity of the composable shell components (Shell, CopilotShell, BottomTray) into a single, easy-to-use component. + +## When to Use CrayonChat + +Use `CrayonChat` when you want: + +- A quick, ready-to-use chat interface +- Minimal boilerplate code +- Automatic thread management +- Built-in welcome screens and conversation starters + +Use the individual shell components (Shell, CopilotShell, BottomTray) when you need: + +- Full control over the UI composition +- Custom layouts +- Advanced customization + +## Types + +CrayonChat supports three different layout types: + +| Type | Description | +| ------------- | ----------------------------------------------------- | +| `standalone` | Full-page chat with sidebar for thread list (default) | +| `copilot` | Side panel chat without sidebar | +| `bottom-tray` | Floating chat panel in the bottom-right corner | + +## Basic Usage + +```tsx +import { CrayonChat } from "@crayonai/react-ui"; + +function App() { + return ( + { + // Send messages to your AI backend + const response = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages, threadId }), + signal: abortController.signal, + }); + return response; + }} + type="standalone" + logoUrl="/path/to/logo.png" + agentName="AI Assistant" + /> + ); +} +``` + +## With Welcome Message and Conversation Starters + +```tsx +import { CrayonChat } from "@crayonai/react-ui"; +import { Sparkles, Zap } from "lucide-react"; + +function App() { + return ( + , + }, + { + displayText: "What can you do?", + prompt: "What can you do?", + // No icon = default lightbulb + }, + { + displayText: "Tell me about features", + prompt: "Tell me about your features", + icon: , + }, + ], + }} + /> + ); +} +``` + +## Bottom Tray Type + +The bottom-tray type provides a floating chat interface with a built-in trigger button (bottom-right corner): + +```tsx +import { CrayonChat } from "@crayonai/react-ui"; + +function App() { + return ( + + ); +} +``` + +**Note:** The bottom-tray includes a built-in floating trigger button that opens/closes the tray. You don't need to add your own toggle button. + +### Controlled Mode (Optional) + +If you need programmatic control over the tray's open state: + +```tsx +import { useState } from "react"; +import { CrayonChat } from "@crayonai/react-ui"; + +function App() { + const [isOpen, setIsOpen] = useState(false); + + // Example: Open tray programmatically after some action + const handleSomeAction = () => { + setIsOpen(true); + }; + + return ( + + ); +} +``` + +## Copilot Type + +The copilot type is ideal for side panel integrations: + +```tsx +import { CrayonChat } from "@crayonai/react-ui"; + +function App() { + return ( +
+
+ {/* Your app content */} +
+ +
+ ); +} +``` + +## Props + +### Core Props + +| Prop | Type | Description | +| ---------------- | -------------------------------------------- | ------------------------------------- | +| `type` | `"standalone" \| "copilot" \| "bottom-tray"` | Layout type (default: `"standalone"`) | +| `processMessage` | `(params) => Promise` | Handler for processing messages | +| `logoUrl` | `string` | URL for the logo image | +| `agentName` | `string` | Name displayed in the header | + +### Welcome Message + +| Prop | Type | Description | +| ---------------- | ---------------------- | ----------------------------- | +| `welcomeMessage` | `WelcomeMessageConfig` | Welcome message configuration | + +`WelcomeMessageConfig` can be either: + +**Props-based configuration:** + +```typescript +{ + title?: string; + description?: string; + image?: { url: string } | ReactNode; + // { url: string } - Renders with default 64x64 size, rounded corners + // ReactNode - Full control over styling (e.g., , ) +} +``` + +**Custom component:** + +```typescript +React.ComponentType; +// Note: Custom components are wrapped with WelcomeScreen for proper container styling +``` + +### Conversation Starters + +| Prop | Type | Description | +| ---------------------- | ---------------------------- | ----------------------------------- | +| `conversationStarters` | `ConversationStartersConfig` | Conversation starters configuration | + +```typescript +interface ConversationStartersConfig { + variant?: "short" | "long"; + options: Array<{ + displayText: string; + prompt: string; + icon?: ReactNode; // undefined = default lightbulb, <> = no icon + }>; +} +``` + +**Icon options:** + +- `undefined` (not provided) - Shows default lightbulb icon +- `` - Custom icon +- `<>` (empty fragment) - No icon + +### Bottom Tray Props + +These props only apply when `type="bottom-tray"`. The tray includes a built-in trigger button, so these are optional for controlled mode: + +| Prop | Type | Description | +| -------------- | --------------------------- | ----------------------------------------------- | +| `isOpen` | `boolean` | Controlled open state (optional) | +| `onOpenChange` | `(isOpen: boolean) => void` | Callback when open state changes (optional) | +| `defaultOpen` | `boolean` | Initial open state, default: `false` (optional) | + +### Advanced Props + +| Prop | Type | Description | +| ------------------------- | ------------------------------ | ------------------------------- | +| `threadManager` | `ThreadManager` | Custom thread manager | +| `threadListManager` | `ThreadListManager` | Custom thread list manager | +| `responseTemplates` | `ResponseTemplate[]` | Response templates | +| `createThread` | `(message) => Promise` | Custom thread creation | +| `onUpdateMessage` | `(props) => void` | Message update callback | +| `processStreamedMessage` | `function` | Custom stream processor | +| `theme` | `ThemeProps` | Theme configuration | +| `scrollVariant` | `ScrollVariant` | Scroll behavior variant | +| `disableThemeProvider` | `boolean` | Disable built-in theme provider | +| `messageLoadingComponent` | `() => ReactNode` | Custom loading component | +| `isArtifactActive` | `boolean` | Show artifact panel | +| `renderArtifact` | `() => ReactNode` | Artifact content renderer | + +## Custom Welcome Component + +You can provide a custom welcome component instead of the props-based configuration. The custom component will be wrapped with `WelcomeScreen` for proper container styling: + +```tsx +import { CrayonChat } from "@crayonai/react-ui"; +import { Sparkles } from "lucide-react"; + +const MyCustomWelcome = () => ( +
+
+ +
+

+ Welcome to My App +

+

+ This is a custom welcome screen +

+
+); + + +``` + +**Note:** When using a custom component, conversation starters are rendered separately below the welcome screen. + +## Conversation Starter Variants + +### Short Variant (default) + +Pill-style buttons that wrap horizontally: + +```tsx +import { Sparkles } from "lucide-react"; + +// In your component: +conversationStarters={{ + variant: "short", + options: [ + { displayText: "With custom icon", prompt: "Prompt 1", icon: }, + { displayText: "Default lightbulb", prompt: "Prompt 2" }, // icon undefined = lightbulb + { displayText: "No icon", prompt: "Prompt 3", icon: <> }, // empty fragment = no icon + ], +}} +``` + +### Long Variant + +Vertical list with separators and hover arrows: + +```tsx +import { Sparkles } from "lucide-react"; + +// In your component: +conversationStarters={{ + variant: "long", + options: [ + { + displayText: "This is a longer prompt that explains more context", + prompt: "Full prompt text", + icon: , + }, + ], +}} +``` + +## Using Custom Thread Managers + +For advanced use cases, you can provide your own thread managers: + +```tsx +import { useThreadListManager, useThreadManager } from "@crayonai/react-core"; + +function App() { + const threadListManager = useThreadListManager({ + createThread: async () => ({ ... }), + fetchThreadList: async () => [...], + deleteThread: async (id) => { ... }, + updateThread: async (thread) => thread, + onSwitchToNew: () => {}, + onSelectThread: (threadId) => {}, + }); + + const threadManager = useThreadManager({ + threadId: threadListManager.selectedThreadId, + loadThread: async (threadId) => [...], + onProcessMessage: async ({ message, threadManager }) => [...], + responseTemplates: [], + }); + + return ( + + ); +} +``` + +## Theming + +CrayonChat uses CSS custom properties for theming. You can customize colors, spacing, and typography: + +```tsx + +``` + +Or disable the built-in theme provider to use your own: + +```tsx + +``` + +## Migration from Composed Components + +If you're currently using the individual shell components and want to migrate to CrayonChat: + +**Before (using Shell components):** + +```tsx + + + ... + + + + + } /> + + + + + + +``` + +**After (using CrayonChat):** + +```tsx + +``` diff --git a/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.stories.tsx b/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.stories.tsx index bb729e15b..885436cf9 100644 --- a/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.stories.tsx +++ b/js/packages/react-ui/src/components/CrayonChat/stories/CrayonChat.stories.tsx @@ -1,10 +1,13 @@ import { Message, useThreadListManager, useThreadManager } from "@crayonai/react-core"; +import { Sparkles, Zap } from "lucide-react"; import { useState } from "react"; +import logoUrl from "../../BottomTray/stories/thesysdev_logo.jpeg"; import { CrayonChat } from "../CrayonChat"; +import { ConversationStartersConfig, WelcomeMessageConfig } from "../types"; export default { title: "Components/CrayonChat", - tags: ["dev", "!autodocs"], + tags: ["dev"], argTypes: { type: { control: "select", @@ -18,13 +21,62 @@ export default { }, }; +// Sample welcome message config +const SAMPLE_WELCOME_MESSAGE: WelcomeMessageConfig = { + title: "Hi, I'm Crayon Assistant", + description: "I can help you with questions about your account, products, and more.", + image: { url: logoUrl }, +}; + +// Sample conversation starters +const SAMPLE_STARTERS: ConversationStartersConfig = { + variant: "short", + options: [ + { + displayText: "Help me get started", + prompt: "Help me get started with Crayon", + icon: , + }, + { + displayText: "What can you do?", + prompt: "What can you do?", + }, + { + displayText: "Tell me about features", + prompt: "Tell me about your features", + icon: , + }, + ], +}; + +// Sample long variant starters +const LONG_STARTERS: ConversationStartersConfig = { + variant: "long", + options: [ + { + displayText: "Help me get started with this application and guide me through the features", + prompt: "Help me get started", + icon: , + }, + { + displayText: "What can you do? I'd like to know all your capabilities", + prompt: "What can you do?", + }, + { + displayText: "Tell me about your advanced features and how I can use them effectively", + prompt: "Tell me about your features", + icon: , + }, + ], +}; + const CrayonChatStory = (args: any) => { const [isOpen, setIsOpen] = useState(args.defaultOpen ?? false); const threadListManager = useThreadListManager({ createThread: async () => { return { threadId: crypto.randomUUID(), - title: "test", + title: "New Chat", createdAt: new Date(), isRunning: false, }; @@ -32,24 +84,9 @@ const CrayonChatStory = (args: any) => { fetchThreadList: async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); return [ - { - threadId: "1", - title: "test", - createdAt: new Date(), - isRunning: false, - }, - { - threadId: "2", - title: "test 2", - createdAt: new Date(), - isRunning: false, - }, - { - threadId: "3", - title: "test 3", - createdAt: new Date(), - isRunning: false, - }, + { threadId: "1", title: "Previous Chat 1", createdAt: new Date(), isRunning: false }, + { threadId: "2", title: "Previous Chat 2", createdAt: new Date(), isRunning: false }, + { threadId: "3", title: "Previous Chat 3", createdAt: new Date(), isRunning: false }, ]; }, deleteThread: async () => {}, @@ -60,26 +97,23 @@ const CrayonChatStory = (args: any) => { const threadManager = useThreadManager({ threadId: threadListManager.selectedThreadId, - loadThread: async () => { + shouldResetThreadState: threadListManager.shouldResetThreadState, + loadThread: async (threadId) => { + // Return empty to show welcome screen and conversation starters + if (!threadId) return []; return [ - { - id: crypto.randomUUID(), - role: "user", - type: "prompt", - message: "Hello", - }, + { id: crypto.randomUUID(), role: "user", type: "prompt", message: "Hello" }, { id: crypto.randomUUID(), role: "assistant", type: "response", - message: [{ type: "text", text: "Hello" }], + message: [{ type: "text", text: "Hello! How can I help you today?" }], }, ]; + // return []; }, - onProcessMessage: async ({ message, threadManager, abortController }) => { - const newMessage = Object.assign({}, message, { - id: crypto.randomUUID(), - }) as Message; + onProcessMessage: async ({ message, threadManager }) => { + const newMessage = Object.assign({}, message, { id: crypto.randomUUID() }) as Message; threadManager.appendMessages(newMessage); await new Promise((resolve) => setTimeout(resolve, 1000)); return [ @@ -87,7 +121,12 @@ const CrayonChatStory = (args: any) => { id: crypto.randomUUID(), role: "assistant", type: "response", - message: [{ type: "text", text: "sadfasdf" }], + message: [ + { + type: "text", + text: `You said: "${message.message}". This is a response from the AI assistant.`, + }, + ], }, ]; }, @@ -123,11 +162,16 @@ const CrayonChatStory = (args: any) => { isOpen={isOpen} onOpenChange={setIsOpen} defaultOpen={args.defaultOpen} + welcomeMessage={args.welcomeMessage} + conversationStarters={args.conversationStarters} + logoUrl={logoUrl} + agentName="Crayon Assistant" />
); }; +// Default standalone without welcome/starters export const Default = { args: { type: "standalone", @@ -135,6 +179,45 @@ export const Default = { render: CrayonChatStory, }; +// Standalone with welcome message and conversation starters +export const StandaloneWithWelcome = { + args: { + type: "standalone", + welcomeMessage: SAMPLE_WELCOME_MESSAGE, + conversationStarters: SAMPLE_STARTERS, + }, + render: CrayonChatStory, +}; + +// Standalone with long variant starters +export const StandaloneLongStarters = { + args: { + type: "standalone", + welcomeMessage: SAMPLE_WELCOME_MESSAGE, + conversationStarters: LONG_STARTERS, + }, + render: CrayonChatStory, +}; + +// Copilot type +export const Copilot = { + args: { + type: "copilot", + }, + render: CrayonChatStory, +}; + +// Copilot with welcome message and starters +export const CopilotWithWelcome = { + args: { + type: "copilot", + welcomeMessage: SAMPLE_WELCOME_MESSAGE, + conversationStarters: SAMPLE_STARTERS, + }, + render: CrayonChatStory, +}; + +// Bottom Tray type export const BottomTray = { args: { type: "bottom-tray", @@ -143,6 +226,7 @@ export const BottomTray = { render: CrayonChatStory, }; +// Bottom Tray open by default export const BottomTrayOpen = { args: { type: "bottom-tray", @@ -150,3 +234,25 @@ export const BottomTrayOpen = { }, render: CrayonChatStory, }; + +// Bottom Tray with welcome message and starters +export const BottomTrayWithWelcome = { + args: { + type: "bottom-tray", + defaultOpen: true, + welcomeMessage: SAMPLE_WELCOME_MESSAGE, + conversationStarters: SAMPLE_STARTERS, + }, + render: CrayonChatStory, +}; + +// Bottom Tray with long variant starters +export const BottomTrayLongStarters = { + args: { + type: "bottom-tray", + defaultOpen: true, + welcomeMessage: SAMPLE_WELCOME_MESSAGE, + conversationStarters: LONG_STARTERS, + }, + render: CrayonChatStory, +}; diff --git a/js/packages/react-ui/src/components/CrayonChat/types.ts b/js/packages/react-ui/src/components/CrayonChat/types.ts new file mode 100644 index 000000000..0cdc8822a --- /dev/null +++ b/js/packages/react-ui/src/components/CrayonChat/types.ts @@ -0,0 +1,66 @@ +import { ReactNode } from "react"; +import { ConversationStarterProps } from "../../types/ConversationStarter"; + +/** + * Welcome message configuration for CrayonChat. + * + * Can be either: + * - A custom React component that will be wrapped with WelcomeScreen for styling + * - An object with title, description, and optional image + * + * @example + * // Props-based configuration + * const welcomeMessage: WelcomeMessageConfig = { + * title: "Hi, I'm AI Assistant", + * description: "I can help you with your questions.", + * image: { url: "/logo.png" }, // or a ReactNode for custom styling + * }; + * + * @example + * // Custom component + * const MyCustomWelcome = () =>
Custom welcome content
; + * const welcomeMessage: WelcomeMessageConfig = MyCustomWelcome; + */ +export type WelcomeMessageConfig = + | React.ComponentType + | { + /** Title text displayed in the welcome screen */ + title?: string; + /** Description text displayed below the title */ + description?: string; + /** + * Image to display in the welcome screen. + * - `{ url: string }` - Renders with default 64x64 size, rounded corners + * - `ReactNode` - Full control over styling (e.g., ``, ``) + */ + image?: { url: string } | ReactNode; + }; + +/** + * Configuration for conversation starters in CrayonChat. + * + * Conversation starters are clickable prompts shown when the thread is empty, + * helping users begin a conversation with predefined options. + * + * @example + * const starters: ConversationStartersConfig = { + * variant: "short", // "short" for pill buttons, "long" for list items + * options: [ + * { displayText: "Help me get started", prompt: "Help me get started" }, + * { displayText: "What can you do?", prompt: "What can you do?", icon: }, + * ], + * }; + */ +export interface ConversationStartersConfig { + /** + * Visual variant for the conversation starters. + * - `"short"` - Pill-style buttons that wrap horizontally (default) + * - `"long"` - Vertical list with separators and hover arrows + */ + variant?: "short" | "long"; + /** + * Array of conversation starter options. + * Each option has displayText, prompt, and optional icon. + */ + options: ConversationStarterProps[]; +} diff --git a/js/packages/react-ui/src/components/CrayonChat/utils/index.ts b/js/packages/react-ui/src/components/CrayonChat/utils/index.ts new file mode 100644 index 000000000..c0bae27e1 --- /dev/null +++ b/js/packages/react-ui/src/components/CrayonChat/utils/index.ts @@ -0,0 +1,37 @@ +import { Message } from "@crayonai/react-core"; +import { WelcomeMessageConfig } from "../types"; + +/** + * Type guard to check if a WelcomeMessageConfig is a custom React component. + * + * Use this to differentiate between a custom component and a props-based + * configuration when rendering the welcome message. + * + * @param config - The welcome message configuration to check + * @returns `true` if config is a React component, `false` if it's a props object + * + * @example + * if (isWelcomeComponent(welcomeMessage)) { + * // welcomeMessage is a React.ComponentType + * const CustomWelcome = welcomeMessage; + * return ; + * } else { + * // welcomeMessage is { title?, description?, image? } + * return ; + * } + */ +export const isWelcomeComponent = ( + config: WelcomeMessageConfig, +): config is React.ComponentType => { + return typeof config === "function"; +}; + +export const isChatEmpty = ({ + isLoadingMessages, + messages, +}: { + isLoadingMessages: boolean | undefined; + messages: Message[]; +}) => { + return isLoadingMessages || messages.length > 0; +}; diff --git a/js/packages/react-ui/src/components/Shell/ConversationStarter.tsx b/js/packages/react-ui/src/components/Shell/ConversationStarter.tsx new file mode 100644 index 000000000..90b9baace --- /dev/null +++ b/js/packages/react-ui/src/components/Shell/ConversationStarter.tsx @@ -0,0 +1,137 @@ +import { useThreadActions, useThreadState } from "@crayonai/react-core"; +import clsx from "clsx"; +import { ArrowUp, Lightbulb } from "lucide-react"; +import { Fragment, ReactNode } from "react"; +import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; +import { Separator } from "../Separator"; + +export type ConversationStarterVariant = "short" | "long"; + +interface ConversationStarterItemProps extends ConversationStarterProps { + onClick: (prompt: string) => void; + variant: ConversationStarterVariant; +} + +/** + * Renders the appropriate icon based on the icon prop value + * - undefined: Show default lightbulb icon + * - ReactNode: Show the provided icon (use <> or React.Fragment for no icon) + */ +const renderIcon = (icon: ConversationStarterIcon | undefined): ReactNode => { + if (icon === undefined) { + return ; + } + return icon; +}; + +const ConversationStarterItem = ({ + displayText, + prompt, + onClick, + variant, + icon, +}: ConversationStarterItemProps) => { + const renderedIcon = renderIcon(icon); + + if (variant === "short") { + return ( + + ); + } + + // Long variant (detailed list style) + return ( + + ); +}; + +export interface ConversationStarterContainerProps { + starters: ConversationStarterProps[]; + className?: string; + /** + * Variant of the conversation starter + * - "short": Pill-style buttons that wrap (default) + * - "long": Vertical list items with icons and hover arrow + */ + variant?: ConversationStarterVariant; +} + +export const ConversationStarter = ({ + starters, + className, + variant = "short", +}: ConversationStarterContainerProps) => { + const { processMessage } = useThreadActions(); + const { isRunning, messages } = useThreadState(); + + const handleClick = (prompt: string) => { + if (isRunning) return; + processMessage({ + type: "prompt", + role: "user", + message: prompt, + }); + }; + + // Only show when there are no messages + if (messages.length > 0) { + return null; + } + + if (starters.length === 0) { + return null; + } + + return ( +
+ {starters.map((item, index) => ( + + + {/* Add separator between items in long variant */} + {variant === "long" && index < starters.length - 1 && ( +
+ +
+ )} +
+ ))} +
+ ); +}; + +export default ConversationStarter; diff --git a/js/packages/react-ui/src/components/Shell/Thread.tsx b/js/packages/react-ui/src/components/Shell/Thread.tsx index 486d73f09..a2eaa464a 100644 --- a/js/packages/react-ui/src/components/Shell/Thread.tsx +++ b/js/packages/react-ui/src/components/Shell/Thread.tsx @@ -1,17 +1,13 @@ import { Message, MessageProvider, - useThreadActions, useThreadManagerSelector, useThreadState, } from "@crayonai/react-core"; import clsx from "clsx"; -import { ArrowRight, Square } from "lucide-react"; -import React, { memo, useEffect, useLayoutEffect, useRef } from "react"; +import React, { memo, useEffect, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; -import { useComposerState } from "../../hooks/useComposerState"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; -import { IconButton } from "../IconButton"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; import { ResizableSeparator } from "./ResizableSeparator"; import { useShellStore } from "./store"; @@ -43,6 +39,10 @@ export const ThreadContainer = ({ setArtifactRenderer(renderArtifact); }, [isArtifactActive, renderArtifact, setIsArtifactActive, setArtifactRenderer]); + const { isInitialized } = useThreadManagerSelector((store) => ({ + isInitialized: store.isInitialized, + })); + // Desktop-only: Handle resize logic for artifact panel const { containerRef, @@ -63,6 +63,9 @@ export const ThreadContainer = ({ className={clsx("crayon-shell-thread-container", className, { "crayon-shell-thread-container--artifact-active": isArtifactActive, })} + style={{ + visibility: isInitialized ? undefined : "hidden", + }} >
{/* Chat panel - always visible */} @@ -282,57 +285,5 @@ export const Messages = ({ ); }; -export const Composer = ({ className }: { className?: string }) => { - const { textContent, setTextContent } = useComposerState(); - const { processMessage, onCancel } = useThreadActions(); - const { isRunning, isLoadingMessages } = useThreadState(); - const inputRef = useRef(null); - - const handleSubmit = () => { - if (!textContent.trim() || isRunning || isLoadingMessages) { - return; - } - - processMessage({ - type: "prompt", - role: "user", - message: textContent, - }); - - setTextContent(""); - }; - - useLayoutEffect(() => { - const input = inputRef.current; - if (!input) { - return; - } - - input.style.height = "0px"; - input.style.height = `${input.scrollHeight}px`; - }, [textContent]); - - return ( -
-
-