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 ( +
+
+ Logo + {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 ( +
+
{children}
+
+ ); +}; + +export const UserMessageContainer = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + return ( +
+
{children}
+
+ ); +}; + +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 ( +
+
+