diff --git a/js/packages/react-core/src/index.ts b/js/packages/react-core/src/index.ts
index 99a2d33b9..b184d0a92 100644
--- a/js/packages/react-core/src/index.ts
+++ b/js/packages/react-core/src/index.ts
@@ -7,7 +7,7 @@ export { useThreadListState } from "./hooks/useThreadListState";
export { useThreadManagerSelector } from "./hooks/useThreadManagerSelector";
export { useThreadState } from "./hooks/useThreadState";
export { processStreamedMessage } from "./stream/processStreamedMessage";
-export { UseThreadListManagerParams, useThreadListManager } from "./useThreadListManager";
-export { UseThreadManagerParams, useThreadManager } from "./useThreadManager";
+export { useThreadListManager, type UseThreadListManagerParams } from "./useThreadListManager";
+export { useThreadManager, type UseThreadManagerParams } from "./useThreadManager";
export * from "./types";
diff --git a/js/packages/react-ui/package.json b/js/packages/react-ui/package.json
index 6b98b0522..00e569d8f 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.8",
+ "version": "0.9.9",
"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/Container.tsx b/js/packages/react-ui/src/components/BottomTray/Container.tsx
new file mode 100644
index 000000000..853aff4a4
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/Container.tsx
@@ -0,0 +1,39 @@
+import clsx from "clsx";
+import { LayoutContextProvider } from "../../context/LayoutContext";
+import { ShellStoreProvider } from "../Shell/store";
+
+interface ContainerProps {
+ children?: React.ReactNode;
+ logoUrl: string;
+ agentName: string;
+ className?: string;
+ /** Control the open state of the tray */
+ isOpen?: boolean;
+}
+
+export const Container = ({
+ children,
+ logoUrl,
+ agentName,
+ className,
+ isOpen = false,
+}: ContainerProps) => {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/js/packages/react-ui/src/components/BottomTray/Header.tsx b/js/packages/react-ui/src/components/BottomTray/Header.tsx
new file mode 100644
index 000000000..603f098a2
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/Header.tsx
@@ -0,0 +1,72 @@
+import { useThreadListActions } from "@crayonai/react-core";
+import clsx from "clsx";
+import { SquarePen, X } from "lucide-react";
+import { ReactNode } from "react";
+import { IconButton } from "../IconButton";
+import { useShellStore } from "../Shell/store";
+import { ThreadListContainer } from "./ThreadListContainer";
+
+export const BottomTrayNewChatButton = () => {
+ const { switchToNewThread } = useThreadListActions();
+
+ return (
+ }
+ onClick={switchToNewThread}
+ variant="tertiary"
+ aria-label="New chat"
+ className="crayon-bottom-tray-header-new-chat-button"
+ />
+ );
+};
+
+interface HeaderProps {
+ className?: string;
+ /** Custom content to render on the rightmost side of the logo container */
+ rightChildren?: ReactNode;
+ /** Callback when minimize button is clicked */
+ onMinimize?: () => void;
+ /** Hide the minimize button */
+ hideMinimizeButton?: boolean;
+ /** Custom new chat button */
+ hideNewChatButton?: boolean;
+ /** Hide the thread list container */
+ hideThreadListContainer?: boolean;
+}
+
+export const Header = ({
+ className,
+ rightChildren,
+ onMinimize,
+ hideMinimizeButton = false,
+ hideNewChatButton = false,
+ hideThreadListContainer = false,
+}: HeaderProps) => {
+ const { logoUrl, agentName } = useShellStore((state) => ({
+ logoUrl: state.logoUrl,
+ agentName: state.agentName,
+ }));
+
+ return (
+
+
+
+
{agentName}
+
+
+ {rightChildren}
+ {!hideThreadListContainer && }
+ {!hideNewChatButton && }
+ {!hideMinimizeButton && onMinimize && (
+ }
+ onClick={onMinimize}
+ variant="tertiary"
+ aria-label="Minimize chat"
+ className="crayon-bottom-tray-header-minimize"
+ />
+ )}
+
+
+ );
+};
diff --git a/js/packages/react-ui/src/components/BottomTray/Thread.tsx b/js/packages/react-ui/src/components/BottomTray/Thread.tsx
new file mode 100644
index 000000000..d1114697c
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/Thread.tsx
@@ -0,0 +1,273 @@
+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 { useComposerState } from "../../hooks/useComposerState";
+import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom";
+import { IconButton } from "../IconButton";
+import { MessageLoading as MessageLoadingComponent } from "../MessageLoading";
+import { useShellStore } from "../Shell/store";
+
+export const ThreadContainer = ({
+ children,
+ className,
+ isArtifactActive = false,
+ renderArtifact = () => null,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+ isArtifactActive?: boolean;
+ renderArtifact?: () => React.ReactNode;
+}) => {
+ const { setIsArtifactActive, setArtifactRenderer } = useShellStore((state) => ({
+ setIsArtifactActive: state.setIsArtifactActive,
+ setArtifactRenderer: state.setArtifactRenderer,
+ }));
+
+ useEffect(() => {
+ setIsArtifactActive(isArtifactActive);
+ setArtifactRenderer(renderArtifact);
+ }, [isArtifactActive, renderArtifact, setIsArtifactActive, setArtifactRenderer]);
+
+ return {children}
;
+};
+
+export const ScrollArea = ({
+ children,
+ className,
+ scrollVariant = "user-message-anchor",
+ userMessageSelector = ".crayon-bottom-tray-thread-message-user",
+}: {
+ children?: React.ReactNode;
+ className?: string;
+ /**
+ * Scroll to bottom once the last message is added
+ */
+ scrollVariant?: ScrollVariant;
+ /**
+ * Selector for the user message
+ */
+ userMessageSelector?: string;
+}) => {
+ const ref = useRef(null);
+
+ const { messages, isRunning, isLoadingMessages } = useThreadState();
+ const { isArtifactActive, artifactRenderer } = useShellStore((store) => ({
+ isArtifactActive: store.isArtifactActive,
+ artifactRenderer: store.artifactRenderer,
+ }));
+
+ useScrollToBottom({
+ ref,
+ lastMessage: messages[messages.length - 1] || { id: "" },
+ scrollVariant,
+ userMessageSelector,
+ isRunning,
+ isLoadingMessages,
+ });
+
+ return (
+
+
+ {children}
+
+ {/* Gradient to hide the bottom of the scroll area */}
+
+ {isArtifactActive && (
+
{artifactRenderer()}
+ )}
+
+ );
+};
+
+const FallbackTemplate = ({ name, templateProps }: { name: string; templateProps: any }) => {
+ return (
+
+ Unable to render template: {name} with props:
+ {JSON.stringify(templateProps)}
+
+ );
+};
+
+const DefaultTextRenderer = ({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) => {
+ return {children}
;
+};
+
+export const AssistantMessageContainer = ({
+ children,
+ className,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+}) => {
+ return (
+
+ );
+};
+
+export const UserMessageContainer = ({
+ children,
+ className,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+}) => {
+ return (
+
+ );
+};
+
+export const RenderMessage = memo(
+ ({ message, className }: { message: Message; className?: string }) => {
+ const responseTemplates = useThreadManagerSelector((store) => store.responseTemplates);
+ const MessageContainer =
+ message.role === "user" ? UserMessageContainer : AssistantMessageContainer;
+
+ if (message.role === "assistant") {
+ return (
+
+ {message.message?.map((stringOrTemplate, i) => {
+ if (stringOrTemplate.type === "text") {
+ const TextRenderer = responseTemplates["text"]?.Component || DefaultTextRenderer;
+
+ return (
+
+ {stringOrTemplate.text}
+
+ );
+ }
+
+ const Template = responseTemplates[stringOrTemplate.name];
+ const Fallback = responseTemplates["fallback"]?.Component || FallbackTemplate;
+ return Template ? (
+
+ ) : (
+
+ );
+ })}
+
+ );
+ }
+
+ return {message.message} ;
+ },
+);
+
+export const MessageLoading = () => {
+ return (
+
+
+
+ );
+};
+
+export const Messages = ({
+ className,
+ loader,
+}: {
+ className?: string;
+ loader?: React.ReactNode;
+}) => {
+ const { messages, isRunning } = useThreadState();
+
+ return (
+
+ {messages.map((message) => {
+ if (message.isVisuallyHidden) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ })}
+ {isRunning &&
{loader}
}
+
+ );
+};
+
+export const Composer = ({ className }: { className?: string }) => {
+ const { textContent, setTextContent } = useComposerState();
+ const { processMessage, onCancel } = useThreadActions();
+ const { isRunning } = useThreadState();
+ const inputRef = useRef(null);
+
+ const handleSubmit = () => {
+ if (!textContent.trim() || isRunning) {
+ 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 (
+
+ );
+};
diff --git a/js/packages/react-ui/src/components/BottomTray/ThreadListContainer.tsx b/js/packages/react-ui/src/components/BottomTray/ThreadListContainer.tsx
new file mode 100644
index 000000000..9e8c311ef
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/ThreadListContainer.tsx
@@ -0,0 +1,102 @@
+import { useThreadListActions, useThreadListState } from "@crayonai/react-core";
+import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
+import clsx from "clsx";
+import { EllipsisVerticalIcon, MenuIcon, Trash2Icon } from "lucide-react";
+import { useEffect } from "react";
+import { IconButton } from "../IconButton";
+
+const ThreadItem = ({
+ title,
+ isSelected,
+ onSelect,
+ onDelete,
+}: {
+ title: string;
+ isSelected: boolean;
+ onSelect: () => void;
+ onDelete: () => void;
+}) => {
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onDelete();
+ }}
+ >
+
+ Delete
+
+
+
+
+
+ );
+};
+
+export const ThreadListContainer = () => {
+ const { threads, selectedThreadId } = useThreadListState();
+ const { load, selectThread, deleteThread } = useThreadListActions();
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ return (
+
+
+ }
+ variant="tertiary"
+ aria-label="Thread list"
+ className="crayon-bottom-tray-thread-list-trigger"
+ />
+
+
+
+ All threads
+
+ {threads.map((thread) => (
+
selectThread(thread.threadId)}
+ onDelete={() => deleteThread(thread.threadId)}
+ />
+ ))}
+ {threads.length === 0 && (
+ No threads yet
+ )}
+
+
+
+
+ );
+};
diff --git a/js/packages/react-ui/src/components/BottomTray/Trigger.tsx b/js/packages/react-ui/src/components/BottomTray/Trigger.tsx
new file mode 100644
index 000000000..43ea95b36
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/Trigger.tsx
@@ -0,0 +1,30 @@
+import clsx from "clsx";
+import { ChevronDown } from "lucide-react";
+import React, { forwardRef } from "react";
+
+interface TriggerProps extends React.ButtonHTMLAttributes {
+ /** Whether the tray is currently open (used for mobile styling) */
+ isOpen?: boolean;
+}
+
+export const Trigger = forwardRef(
+ (
+ { children, className, isOpen = false, "aria-label": ariaLabel = "Open chat", ...rest },
+ ref,
+ ) => {
+ return (
+
+ {children || }
+
+ );
+ },
+);
diff --git a/js/packages/react-ui/src/components/BottomTray/bottomTray.scss b/js/packages/react-ui/src/components/BottomTray/bottomTray.scss
new file mode 100644
index 000000000..9ccbdaf8c
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/bottomTray.scss
@@ -0,0 +1,8 @@
+// BottomTray component styles
+// This file imports all individual style modules for the BottomTray component
+
+@use "./container.scss";
+@use "./trigger.scss";
+@use "./header.scss";
+@use "./threadList.scss";
+@use "./thread.scss";
diff --git a/js/packages/react-ui/src/components/BottomTray/container.scss b/js/packages/react-ui/src/components/BottomTray/container.scss
new file mode 100644
index 000000000..f5fa719d2
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/container.scss
@@ -0,0 +1,59 @@
+@use "../../cssUtils" as cssUtils;
+
+.crayon-bottom-tray-container {
+ display: flex;
+ position: fixed;
+ // Leave space for the 56px trigger + spacing (16px bottom + 56px height + 12px gap = 84px)
+ bottom: 84px;
+ right: cssUtils.$spacing-l;
+ height: calc(90% - 84px);
+ max-height: 768px;
+ width: 448px;
+ overflow: hidden;
+ flex-direction: column;
+ transition:
+ transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s ease;
+
+ border: 1px solid cssUtils.$stroke-default;
+ border-radius: cssUtils.$rounded-2xl cssUtils.$rounded-2xl cssUtils.$rounded-3xl
+ cssUtils.$rounded-3xl;
+ box-shadow: cssUtils.$shadow-2xl;
+
+ background: cssUtils.$chat-container-bg;
+ box-sizing: border-box;
+ & * {
+ box-sizing: border-box;
+ }
+
+ // Open state
+ &--open {
+ transform: translateY(0);
+ opacity: 1;
+ }
+
+ // Closed state
+ &--closed {
+ transform: translateY(calc(100% + cssUtils.$spacing-l));
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ // Mobile fullscreen
+ @media (max-width: 768px) {
+ right: 0;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 100dvw;
+ height: 100dvh;
+ max-height: none;
+ max-width: none;
+ border-radius: 0;
+ border: none;
+
+ &--closed {
+ transform: translateY(100dvh);
+ }
+ }
+}
diff --git a/js/packages/react-ui/src/components/BottomTray/dependencies.ts b/js/packages/react-ui/src/components/BottomTray/dependencies.ts
new file mode 100644
index 000000000..3028df8d3
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/dependencies.ts
@@ -0,0 +1,5 @@
+import buttonDeps from "../Button/dependencies";
+import iconButtonDeps from "../IconButton/dependencies";
+
+const dependencies = ["Shell", ...iconButtonDeps, ...buttonDeps];
+export default dependencies;
diff --git a/js/packages/react-ui/src/components/BottomTray/header.scss b/js/packages/react-ui/src/components/BottomTray/header.scss
new file mode 100644
index 000000000..c4c8a5405
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/header.scss
@@ -0,0 +1,38 @@
+@use "../../cssUtils" as cssUtils;
+
+.crayon-bottom-tray-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: cssUtils.$spacing-m;
+ border-radius: cssUtils.$rounded-l cssUtils.$rounded-l 0 0;
+
+ // Mobile
+ @media (max-width: 768px) {
+ border-radius: 0;
+ }
+}
+
+.crayon-bottom-tray-header-logo-container {
+ display: flex;
+ align-items: center;
+ gap: cssUtils.$spacing-s;
+ @include cssUtils.typography(title, medium);
+}
+
+.crayon-bottom-tray-header-logo {
+ width: 32px;
+ height: 32px;
+ border-radius: cssUtils.$rounded-m;
+}
+
+.crayon-bottom-tray-header-agent-name {
+ @include cssUtils.typography(title, medium);
+ color: cssUtils.$primary-text;
+}
+
+.crayon-bottom-tray-header-actions {
+ display: flex;
+ align-items: center;
+ gap: cssUtils.$spacing-s;
+}
diff --git a/js/packages/react-ui/src/components/BottomTray/index.ts b/js/packages/react-ui/src/components/BottomTray/index.ts
new file mode 100644
index 000000000..c7fc7b5aa
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/index.ts
@@ -0,0 +1,4 @@
+export * from "./Container";
+export * from "./Header";
+export * from "./Thread";
+export * from "./Trigger";
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
new file mode 100644
index 000000000..f2a6b0d41
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx
@@ -0,0 +1,252 @@
+import {
+ ChatProvider,
+ Message,
+ useThreadListManager,
+ useThreadManager,
+} from "@crayonai/react-core";
+import { useState } from "react";
+import {
+ Composer,
+ Container,
+ Header,
+ MessageLoading,
+ Messages,
+ ScrollArea,
+ ThreadContainer,
+ Trigger,
+} from "../../BottomTray";
+// @ts-ignore
+import styles from "./style.module.scss";
+import logoUrl from "./thesysdev_logo.jpeg";
+
+export default {
+ title: "Components/BottomTray",
+ tags: ["dev", "!autodocs"],
+ argTypes: {
+ defaultOpen: {
+ control: "boolean",
+ description: "Whether the tray starts open",
+ },
+ },
+};
+
+const BottomTrayStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+
+ const threadListManager = useThreadListManager({
+ createThread: async () => {
+ return {
+ threadId: crypto.randomUUID(),
+ title: "test",
+ createdAt: new Date(),
+ isRunning: false,
+ };
+ },
+ 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,
+ },
+ ];
+ },
+ deleteThread: async () => {},
+ updateThread: async (t) => t,
+ onSwitchToNew: () => {},
+ onSelectThread: () => {},
+ });
+
+ const threadManager = useThreadManager({
+ threadId: threadListManager.selectedThreadId,
+ loadThread: async () => {
+ 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, abortController }) => {
+ 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: "This is a response from the bottom tray assistant!" }],
+ },
+ ];
+ },
+ responseTemplates: [],
+ });
+
+ return (
+
+
+
Bottom Tray Example (Composable)
+
+ The chat interface uses a composition pattern . The trigger button is
+ separate from the container, giving you full control over placement and styling.
+
+
+ Try it: Click the pill button (bottom-right) or the custom button below
+ to toggle the tray.
+
+
setIsOpen(!isOpen)} className={styles.toggleButton}>
+ {isOpen ? "Close" : "Open"} Tray
+
+
+
+
+ {/* Trigger is always visible - toggles the tray (hidden on mobile when open) */}
+ setIsOpen(!isOpen)} isOpen={isOpen} />
+
+ {/* Container is controlled externally */}
+
+
+ setIsOpen(false)} />
+
+ } />
+
+
+
+
+
+
+ );
+};
+
+export const Default = {
+ args: {
+ defaultOpen: false,
+ },
+ render: (args: any) => ,
+};
+
+export const OpenByDefault = {
+ args: {
+ defaultOpen: true,
+ },
+ render: (args: any) => ,
+};
+
+// Example with custom trigger
+const CustomTriggerStory = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+
+ const threadListManager = useThreadListManager({
+ createThread: async () => ({
+ threadId: crypto.randomUUID(),
+ title: "test",
+ createdAt: new Date(),
+ isRunning: false,
+ }),
+ 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 },
+ ];
+ },
+ deleteThread: async () => {},
+ updateThread: async (t) => t,
+ onSwitchToNew: () => {},
+ onSelectThread: () => {},
+ });
+
+ 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?" }],
+ },
+ ],
+ 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: "This is a response from the assistant!" }],
+ },
+ ];
+ },
+ responseTemplates: [],
+ });
+
+ return (
+
+
+
Custom Trigger Example
+
Use a fully custom trigger with your own styling and content.
+
+
+
+ {/* Custom trigger - always visible, toggles tray (hidden on mobile when open) */}
+ setIsOpen(!isOpen)}
+ isOpen={isOpen}
+ className={styles.customTrigger}
+ >
+ 💬 Need Help?
+
+
+
+
+ setIsOpen(false)} />
+
+ } />
+
+
+
+
+
+
+ );
+};
+
+export const CustomTrigger = {
+ args: {
+ defaultOpen: false,
+ },
+ render: (args: any) => ,
+};
diff --git a/js/packages/react-ui/src/components/BottomTray/stories/style.module.scss b/js/packages/react-ui/src/components/BottomTray/stories/style.module.scss
new file mode 100644
index 000000000..75555201d
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/stories/style.module.scss
@@ -0,0 +1,59 @@
+@use "../../../cssUtils" as cssUtils;
+
+.container {
+ display: flex;
+ min-height: 100vh;
+ background: cssUtils.$bg-fill;
+ position: relative;
+}
+
+.content {
+ flex-grow: 1;
+ padding: cssUtils.$spacing-2xl;
+
+ h1 {
+ @include cssUtils.typography(heading, large);
+ color: cssUtils.$primary-text;
+ margin-bottom: cssUtils.$spacing-l;
+ }
+
+ p {
+ @include cssUtils.typography(body, default);
+ color: cssUtils.$secondary-text;
+ margin-bottom: cssUtils.$spacing-m;
+ }
+}
+
+.toggleButton {
+ @include cssUtils.typography(label, default);
+ padding: cssUtils.$spacing-s cssUtils.$spacing-l;
+ background: cssUtils.$interactive-default;
+ color: cssUtils.$primary-text;
+ border: 1px solid cssUtils.$stroke-interactive-el;
+ border-radius: cssUtils.$rounded-m;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: cssUtils.$interactive-hover;
+ }
+
+ &:active {
+ background: cssUtils.$interactive-pressed;
+ }
+}
+
+.customTrigger {
+ // Override default trigger styles
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ padding: cssUtils.$spacing-m cssUtils.$spacing-xl;
+ font-size: 1.1rem;
+ font-weight: 700;
+ border: none;
+
+ &:hover {
+ background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
+ transform: translateY(-4px);
+ box-shadow: cssUtils.$shadow-3xl;
+ }
+}
diff --git a/js/packages/react-ui/src/components/BottomTray/stories/thesysdev_logo.jpeg b/js/packages/react-ui/src/components/BottomTray/stories/thesysdev_logo.jpeg
new file mode 100644
index 000000000..755421486
Binary files /dev/null and b/js/packages/react-ui/src/components/BottomTray/stories/thesysdev_logo.jpeg differ
diff --git a/js/packages/react-ui/src/components/BottomTray/thread.scss b/js/packages/react-ui/src/components/BottomTray/thread.scss
new file mode 100644
index 000000000..6dfe79985
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/thread.scss
@@ -0,0 +1,141 @@
+@use "../../cssUtils" as cssUtils;
+
+.crayon-bottom-tray-thread-container {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+ flex-direction: column;
+}
+
+.crayon-bottom-tray-thread-scroll-container {
+ width: 100%;
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+}
+
+.crayon-bottom-tray-thread-scroll-gradient {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 40px;
+ z-index: 1;
+ pointer-events: none;
+ background: linear-gradient(to top, cssUtils.$bg-fill 0%, transparent);
+}
+
+.crayon-bottom-tray-thread-scroll-area {
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ padding: cssUtils.$spacing-m;
+ &--user-message-anchor {
+ & .crayon-bottom-tray-thread-messages > *:last-child {
+ min-height: calc(-200px + 100dvh);
+ }
+ }
+}
+
+// Artifact panel (overlay style)
+.crayon-bottom-tray-thread-artifact-panel--mobile {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10;
+ background-color: cssUtils.$bg-container;
+ animation: crayon-bottom-tray-slide-in-from-bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes crayon-bottom-tray-slide-in-from-bottom {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.crayon-bottom-tray-thread-messages {
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: cssUtils.$spacing-xl;
+}
+
+.crayon-bottom-tray-thread-message-assistant {
+ width: 100%;
+ overflow: hidden;
+
+ // override theme variables so that other crayon components don't depend on chat colors
+ // this solves the case where the crayon-card component is used in the thread but without card component
+ --crayon-primary-text: #{cssUtils.$chat-assistant-response-text};
+ --crayon-container-fills: #{cssUtils.$chat-assistant-response-bg};
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: cssUtils.$spacing-s;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ flex-grow: 1;
+ }
+
+ &__text {
+ @include cssUtils.typography(primary, default);
+ color: cssUtils.$chat-assistant-response-text;
+ }
+}
+
+.crayon-bottom-tray-thread-message-user {
+ display: flex;
+ justify-content: flex-end;
+
+ &__content {
+ @include cssUtils.typography(primary, default);
+ padding: cssUtils.$spacing-m cssUtils.$spacing-l;
+ background-color: cssUtils.$chat-user-response-bg;
+ color: cssUtils.$chat-user-response-text;
+ border-radius: cssUtils.$rounded-2xl;
+ overflow-wrap: break-word;
+ max-width: 100%;
+ height: fit-content;
+ }
+}
+
+.crayon-bottom-tray-thread-composer {
+ width: 100%;
+ padding: 0 cssUtils.$spacing-s cssUtils.$spacing-s;
+
+ &__input-wrapper {
+ background-color: cssUtils.$bg-container;
+ border: 1.256px solid cssUtils.$stroke-default;
+ display: flex;
+ align-items: flex-end;
+ gap: cssUtils.$spacing-s;
+ padding: cssUtils.$spacing-m;
+ border-radius: cssUtils.$rounded-xl;
+ }
+
+ &__input {
+ flex-grow: 1;
+ padding: 0;
+ resize: none;
+ margin: auto 0px;
+ max-height: 154px;
+ outline: none;
+ border: none;
+ background: transparent;
+ overflow: hidden;
+
+ @include cssUtils.typography(primary, default);
+ color: cssUtils.$primary-text;
+ &::placeholder {
+ color: cssUtils.$disabled-text;
+ }
+ }
+}
diff --git a/js/packages/react-ui/src/components/BottomTray/threadList.scss b/js/packages/react-ui/src/components/BottomTray/threadList.scss
new file mode 100644
index 000000000..44a59959b
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/threadList.scss
@@ -0,0 +1,126 @@
+@use "../../cssUtils" as cssUtils;
+
+// Thread List Dropdown
+.crayon-bottom-tray-thread-list-dropdown {
+ display: flex;
+ flex-direction: column;
+ min-width: 240px;
+ max-width: 320px;
+ max-height: 296px;
+ padding: cssUtils.$spacing-s;
+ border: 1px solid cssUtils.$stroke-default;
+ border-radius: cssUtils.$rounded-l;
+ background-color: cssUtils.$bg-container;
+ box-shadow: cssUtils.$shadow-l;
+ z-index: 9999;
+ overflow: hidden;
+}
+
+.crayon-bottom-tray-thread-list-header {
+ @include cssUtils.typography(label, small);
+ color: cssUtils.$secondary-text;
+ padding: cssUtils.$spacing-xs cssUtils.$spacing-s;
+ padding-bottom: cssUtils.$spacing-s;
+}
+
+.crayon-bottom-tray-thread-list-items {
+ display: flex;
+ flex-direction: column;
+ gap: cssUtils.$spacing-2xs;
+ overflow-y: auto;
+}
+
+.crayon-bottom-tray-thread-list-empty {
+ @include cssUtils.typography(body, small);
+ color: cssUtils.$secondary-text;
+ padding: cssUtils.$spacing-m;
+ text-align: center;
+}
+
+// Thread Item
+.crayon-bottom-tray-thread-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ border-radius: cssUtils.$rounded-s;
+ border: 1px solid transparent;
+ padding-right: cssUtils.$spacing-xs;
+
+ &--selected {
+ background-color: cssUtils.$bg-sunk;
+ border-color: cssUtils.$stroke-default;
+ }
+
+ &:hover {
+ background-color: cssUtils.$bg-sunk;
+
+ .crayon-bottom-tray-thread-item-menu-trigger {
+ opacity: 1;
+ }
+ }
+}
+
+.crayon-bottom-tray-thread-item-title {
+ @include cssUtils.button-reset;
+ @include cssUtils.typography(body, small);
+ color: cssUtils.$primary-text;
+ padding: cssUtils.$spacing-xs cssUtils.$spacing-s;
+ flex: 1;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.crayon-bottom-tray-thread-item-menu-trigger {
+ @include cssUtils.button-reset;
+ outline: none;
+ color: cssUtils.$secondary-text;
+ padding: cssUtils.$spacing-xs;
+ flex-shrink: 0;
+ cursor: pointer;
+ opacity: 0;
+ border-radius: cssUtils.$rounded-xs;
+
+ &:hover {
+ background-color: cssUtils.$interactive-hover;
+ }
+
+ &[data-state="open"] {
+ opacity: 1;
+ }
+}
+
+.crayon-bottom-tray-thread-item-menu {
+ display: flex;
+ flex-direction: column;
+ padding: cssUtils.$spacing-xs;
+ border: 1px solid cssUtils.$stroke-default;
+ border-radius: cssUtils.$rounded-m;
+ background-color: cssUtils.$bg-container;
+ box-shadow: cssUtils.$shadow-m;
+ z-index: 10000;
+}
+
+.crayon-bottom-tray-thread-item-menu-action {
+ @include cssUtils.button-reset;
+ @include cssUtils.typography(body, small);
+ outline: none;
+ color: cssUtils.$primary-text;
+ padding: cssUtils.$spacing-xs cssUtils.$spacing-s;
+ display: flex;
+ align-items: center;
+ gap: cssUtils.$spacing-xs;
+ cursor: pointer;
+ border-radius: cssUtils.$rounded-xs;
+
+ &:hover {
+ background-color: cssUtils.$bg-sunk;
+ }
+}
+
+.crayon-bottom-tray-thread-item-menu-icon {
+ color: cssUtils.$secondary-text;
+}
diff --git a/js/packages/react-ui/src/components/BottomTray/trigger.scss b/js/packages/react-ui/src/components/BottomTray/trigger.scss
new file mode 100644
index 000000000..43b493c4b
--- /dev/null
+++ b/js/packages/react-ui/src/components/BottomTray/trigger.scss
@@ -0,0 +1,41 @@
+@use "../../cssUtils" as cssUtils;
+
+.crayon-bottom-tray-trigger {
+ position: fixed;
+ bottom: cssUtils.$spacing-l;
+ right: cssUtils.$spacing-l;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 56px;
+ border-radius: cssUtils.$rounded-full;
+ background: cssUtils.$bg-inverted;
+ color: cssUtils.$accent-primary-text;
+ box-shadow: cssUtils.$shadow-xl;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ z-index: 1000;
+ overflow: hidden;
+
+ &:hover {
+ background: cssUtils.$interactive-accent-hover;
+ box-shadow: cssUtils.$shadow-2xl;
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ // Mobile
+ @media (max-width: 768px) {
+ bottom: cssUtils.$spacing-m;
+ right: cssUtils.$spacing-m;
+
+ // Hide trigger on mobile when tray is open (tray goes fullscreen)
+ &--open {
+ display: none;
+ }
+ }
+}
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 5fab3c30f..fecfb6aee 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
@@ -18,8 +18,8 @@ import styles from "./style.module.scss";
import logoUrl from "./thesysdev_logo.jpeg";
export default {
- title: "Copilot Shell",
- tags: ["!dev", "!autodocs"],
+ title: "Components/CopilotShell",
+ tags: ["dev", "!autodocs"],
};
export const Default = {
diff --git a/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx b/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx
new file mode 100644
index 000000000..ee9124b76
--- /dev/null
+++ b/js/packages/react-ui/src/components/CrayonChat/ComposedBottomTray.tsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+import { ScrollVariant } from "../../hooks/useScrollToBottom";
+import {
+ Composer,
+ Container,
+ Header,
+ MessageLoading,
+ Messages,
+ ScrollArea,
+ ThreadContainer,
+ Trigger,
+} from "../BottomTray";
+
+interface ComposedBottomTrayProps {
+ logoUrl?: string;
+ agentName?: string;
+ messageLoadingComponent?: () => React.ReactNode;
+ scrollVariant: ScrollVariant;
+ isArtifactActive?: boolean;
+ renderArtifact?: () => React.ReactNode;
+ /** Control the open state of the tray */
+ isOpen?: boolean;
+ /** Callback when open state changes */
+ onOpenChange?: (isOpen: boolean) => void;
+ /** Default open state (uncontrolled) */
+ defaultOpen?: boolean;
+}
+
+export const ComposedBottomTray = ({
+ logoUrl = "https://crayonai.org/img/logo.png",
+ agentName = "My Agent",
+ messageLoadingComponent: MessageLoadingComponent = MessageLoading,
+ scrollVariant,
+ isArtifactActive,
+ renderArtifact,
+ isOpen: controlledIsOpen,
+ onOpenChange,
+ defaultOpen = false,
+}: ComposedBottomTrayProps) => {
+ const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(defaultOpen);
+
+ // Use controlled state if provided, otherwise use internal state
+ const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : uncontrolledIsOpen;
+
+ const handleOpenChange = (newIsOpen: boolean) => {
+ if (controlledIsOpen === undefined) {
+ setUncontrolledIsOpen(newIsOpen);
+ }
+ onOpenChange?.(newIsOpen);
+ };
+
+ return (
+ <>
+ {/* Trigger is always visible - toggles the tray (hidden on mobile when open) */}
+ handleOpenChange(!isOpen)} isOpen={isOpen}>
+
+
+
+ {/* Controlled container */}
+
+
+ handleOpenChange(false)} />
+
+ } />
+
+
+
+
+ >
+ );
+};
diff --git a/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx b/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx
index bfbbe2d16..11be2be58 100644
--- a/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx
+++ b/js/packages/react-ui/src/components/CrayonChat/CrayonChat.tsx
@@ -15,10 +15,11 @@ import { useEffect, useRef } from "react";
import invariant from "tiny-invariant";
import { ScrollVariant } from "../../hooks/useScrollToBottom";
import { ThemeProps, ThemeProvider } from "../ThemeProvider";
+import { ComposedBottomTray } from "./ComposedBottomTray";
import { ComposedCopilot } from "./ComposedCopilot";
import { ComposedStandalone } from "./ComposedStandalone";
-type CrayonChatProps = {
+type BaseCrayonChatProps = {
// options used when threadManager not provided
processMessage?: (params: {
threadId: string;
@@ -39,7 +40,6 @@ type CrayonChatProps = {
logoUrl?: string;
agentName?: string;
- type?: "copilot" | "standalone";
scrollVariant?: ScrollVariant;
messageLoadingComponent?: () => React.ReactNode;
@@ -49,28 +49,52 @@ type CrayonChatProps = {
renderArtifact?: () => React.ReactNode;
};
+type BottomTrayProps = {
+ type: "bottom-tray";
+ /** Control the open state of the bottom tray */
+ isOpen?: boolean;
+ /** Callback when bottom tray open state changes */
+ onOpenChange?: (isOpen: boolean) => void;
+ /** Default open state for bottom tray (uncontrolled) */
+ defaultOpen?: boolean;
+};
+
+type OtherTypeProps = {
+ type?: "copilot" | "standalone";
+};
+
+type CrayonChatProps = BaseCrayonChatProps & (BottomTrayProps | OtherTypeProps);
+
const DummyThemeProvider = ({ children }: { children: React.ReactNode }) => {
return children;
};
-export const CrayonChat = ({
- processMessage,
- threadManager: userThreadManager,
- threadListManager: userThreadListManager,
- logoUrl = "https://crayonai.org/img/logo.png",
- agentName = "My Agent",
- responseTemplates,
- createThread,
- onUpdateMessage,
- processStreamedMessage: userProcessStreamedMessage,
- messageLoadingComponent,
- type = "standalone",
- theme,
- scrollVariant = "user-message-anchor",
- disableThemeProvider,
- isArtifactActive,
- renderArtifact,
-}: CrayonChatProps) => {
+export const CrayonChat = (props: CrayonChatProps) => {
+ const {
+ processMessage,
+ threadManager: userThreadManager,
+ threadListManager: userThreadListManager,
+ logoUrl = "https://crayonai.org/img/logo.png",
+ agentName = "My Agent",
+ responseTemplates,
+ createThread,
+ onUpdateMessage,
+ processStreamedMessage: userProcessStreamedMessage,
+ messageLoadingComponent,
+ type = "standalone",
+ theme,
+ scrollVariant = "user-message-anchor",
+ disableThemeProvider,
+ isArtifactActive,
+ renderArtifact,
+ } = props;
+
+ // Extract bottom-tray specific props if type is bottom-tray
+ const { isOpen, onOpenChange, defaultOpen } =
+ type === "bottom-tray"
+ ? (props as BottomTrayProps)
+ : { isOpen: undefined, onOpenChange: undefined, defaultOpen: undefined };
+
invariant(processMessage || userThreadManager, "processMessage or threadManager is required");
const ThemeProviderComponent = disableThemeProvider ? DummyThemeProvider : ThemeProvider;
@@ -156,6 +180,18 @@ export const CrayonChat = ({
isArtifactActive={isArtifactActive}
renderArtifact={renderArtifact}
/>
+ ) : type === "bottom-tray" ? (
+
) : (
{
- const threadListManager = useThreadListManager({
- createThread: async () => {
- return {
- threadId: crypto.randomUUID(),
+const CrayonChatStory = (args: any) => {
+ const [isOpen, setIsOpen] = useState(args.defaultOpen ?? false);
+ const threadListManager = useThreadListManager({
+ createThread: async () => {
+ return {
+ threadId: crypto.randomUUID(),
+ title: "test",
+ createdAt: new Date(),
+ isRunning: false,
+ };
+ },
+ fetchThreadList: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ return [
+ {
+ threadId: "1",
title: "test",
createdAt: new Date(),
isRunning: false,
- };
- },
- 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,
- },
- ];
- },
- deleteThread: async () => {},
- updateThread: async (t) => t,
- onSwitchToNew: () => {},
- onSelectThread: () => {},
- });
+ },
+ {
+ threadId: "2",
+ title: "test 2",
+ createdAt: new Date(),
+ isRunning: false,
+ },
+ {
+ threadId: "3",
+ title: "test 3",
+ createdAt: new Date(),
+ isRunning: false,
+ },
+ ];
+ },
+ deleteThread: async () => {},
+ updateThread: async (t) => t,
+ onSwitchToNew: () => {},
+ onSelectThread: () => {},
+ });
- const threadManager = useThreadManager({
- threadId: threadListManager.selectedThreadId,
- loadThread: async () => {
- return [
- {
- id: crypto.randomUUID(),
- role: "user",
- type: "prompt",
- message: "Hello",
- },
- {
- id: crypto.randomUUID(),
- role: "assistant",
- type: "response",
- message: [{ type: "text", text: "Hello" }],
- },
- ];
- },
- onProcessMessage: async ({ message, threadManager, abortController }) => {
- const newMessage = Object.assign({}, message, {
+ const threadManager = useThreadManager({
+ threadId: threadListManager.selectedThreadId,
+ loadThread: async () => {
+ return [
+ {
+ id: crypto.randomUUID(),
+ role: "user",
+ type: "prompt",
+ message: "Hello",
+ },
+ {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ type: "response",
+ message: [{ type: "text", text: "Hello" }],
+ },
+ ];
+ },
+ onProcessMessage: async ({ message, threadManager, abortController }) => {
+ const newMessage = Object.assign({}, message, {
+ id: crypto.randomUUID(),
+ }) as Message;
+ threadManager.appendMessages(newMessage);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ return [
+ {
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: "sadfasdf" }],
- },
- ];
- },
- responseTemplates: [],
- });
+ role: "assistant",
+ type: "response",
+ message: [{ type: "text", text: "sadfasdf" }],
+ },
+ ];
+ },
+ responseTemplates: [],
+ });
- return (
+ return (
+
+ {args.type === "bottom-tray" && (
+
+
Bottom Tray Example
+
+ The chat appears as a bottom tray. Click the pill button to open/close.
+
+
setIsOpen(!isOpen)}
+ style={{
+ padding: "0.5rem 1rem",
+ background: "var(--crayon-interactive-default)",
+ border: "1px solid var(--crayon-stroke-interactive-el)",
+ borderRadius: "8px",
+ cursor: "pointer",
+ }}
+ >
+ {isOpen ? "Close" : "Open"} Chat
+
+
+ )}
- );
+
+ );
+};
+
+export const Default = {
+ args: {
+ type: "standalone",
+ },
+ render: CrayonChatStory,
+};
+
+export const BottomTray = {
+ args: {
+ type: "bottom-tray",
+ defaultOpen: false,
+ },
+ render: CrayonChatStory,
+};
+
+export const BottomTrayOpen = {
+ args: {
+ type: "bottom-tray",
+ defaultOpen: true,
},
+ render: CrayonChatStory,
};
diff --git a/js/packages/react-ui/src/components/index.scss b/js/packages/react-ui/src/components/index.scss
index cc8230299..deba20ad1 100644
--- a/js/packages/react-ui/src/components/index.scss
+++ b/js/packages/react-ui/src/components/index.scss
@@ -1,4 +1,5 @@
@forward "./Accordion/accordion.scss";
+@forward "./BottomTray/bottomTray.scss";
@forward "./Button/button.scss";
@forward "./Buttons/buttons.scss";
@forward "./Calendar/calendar.scss";