diff --git a/README.md b/README.md index 7fdd6a0..5d54217 100644 --- a/README.md +++ b/README.md @@ -26,39 +26,57 @@ --- -## Features -- Markdown -- Code block -- CSV Table -- JSON Block -- File Upload Support +reachat is a UI library for building chat experiences. No more manually coding +all the components required to build LLM uis manually. Customize each component and +theme via Tailwind. + +## 🚀 Quick Links +- Checkout the [docs and demos](https://reachat.dev) +- Checkout the [storybook demos](https://storybook.reachat.dev) +- Learn about updates from the [changelog](CHANGELOG.md) + +## 💎 Other Projects + +- [Reaflow](https://reaflow.dev?utm=reagraph) - Open-source library for workflow and diagram graphs. +- [Reablocks](https://reablocks.dev?utm=reagraph) - Open-source component library for React based on Tailwind. +- [Reaviz](https://reaviz.dev?utm=reagraph) - Open-source library for data visualizations for React. +- [Reagraph](https://reagraph.dev?utm=reaviz) - Open-source library for large webgl based network graphs. + +## 🪄 Features +- Markdown Rendering + - GFM Styling + - Code Highlighting + - Tables + - JSON + - Embeds + - remark plugin support +- File Uploads +- Animations - Conversation Pagination -- Dynamic Grouping +- Smart/Dynamic Grouping of Sessions - Keyboard shortcuts - Tailwind for Themeing -## Installation -- `npm i reachat` - -## References -- https://www.copilotkit.ai/ -- https://www.chatbotui.com/ -- https://llm-ui.com/ -- https://github.com/huggingface/chat-ui - Amazing clean UI with very good web search, my go to currently. (they added the ability to do it all locally very recently!) -- https://github.com/oobabooga/text-generation-webui - Best overall, supports any model format and has many extensions -- https://github.com/ParisNeo/lollms-webui/ - Has PDF, stable diffusion and web search integration -- https://github.com/h2oai/h2ogpt - Has PDF, Web search, best for files ingestion (supports many file formats) -- https://github.com/SillyTavern/SillyTavern - Best for custom characters and roleplay -- https://github.com/NimbleBoxAI/ChainFury - Has great UI and web search (experimental) -- https://github.com/nomic-ai/gpt4all - Basic UI that replicated ChatGPT -- https://github.com/imartinez/privateGPT - Basic UI that replicated ChatGPT with PDF integration -- https://github.com/LostRuins/koboldcpp - Easy to install and simple interface -- LM Studio - Clean UI, focuses on GGUF format -- https://github.com/lobehub/lobe-chat - Nice rich UI with the ability to load extensions for web search, TTS and more -- https://github.com/ollama-webui/ollama-webui - ChatGPT like UI with easy way to download models -- https://github.com/turboderp/exui - very fast and vram efficient -- https://github.com/PromtEngineer/localGPT - Focuses on PDF files -- https://github.com/shinomakoi/AI-Messenger - Supports EXLv2 and LLava - -## Credits -- Icons from [https://lucide.dev/](https://lucide.dev/) +## 📦 Install + +To use reachat in your project, install it via npm/yarn: + +``` +npm i reachat --save +``` + +## 🔭 Development + +If you want to run reachat locally, its super easy! + +- Clone the repository +- `npm i` +- `npm start` +- Browser opens to Storybook page + +## ❤️ Contributors & Credits + +Thanks to all our contributors! + + + diff --git a/package-lock.json b/package-lock.json index b1fa9cc..3e0f408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "reablocks": "^8.4.3", "react-markdown": "^9.0.1", "reakeys": "^2.0.3", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "remark-youtube": "^1.3.2" }, "devDependencies": { "@storybook/addon-docs": "^8.2.6", @@ -14714,6 +14715,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-youtube": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/remark-youtube/-/remark-youtube-1.3.2.tgz", + "integrity": "sha512-Rtre0gfwzdfcjBbjkFQlQKJs5J7e6xEFBrto/amYWX70Jjzh2EH4lUQnRGKkGAWuReo7XZMqJalv67ygRIN/wg==", + "dependencies": { + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "remark-rehype": "^11.1.0" + } + }, "node_modules/requireindex": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", diff --git a/package.json b/package.json index de55c80..cb48734 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "reablocks": "^8.4.3", "react-markdown": "^9.0.1", "reakeys": "^2.0.3", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "remark-youtube": "^1.3.2" }, "devDependencies": { "@storybook/addon-docs": "^8.2.6", @@ -114,5 +115,6 @@ "hooks": { "pre-commit": "lint-staged" } - } + }, + "packageManager": "pnpm@9.5.0+sha1.8c155dc114e1689d18937974f6571e0ceee66f1d" } diff --git a/src/Markdown/Markdown.tsx b/src/Markdown/Markdown.tsx index 3d78cf0..349c37e 100644 --- a/src/Markdown/Markdown.tsx +++ b/src/Markdown/Markdown.tsx @@ -31,6 +31,7 @@ export const Markdown: FC = ({ th: props => , td: props => , a: props => , + p: props =>

}} > {children as string} diff --git a/src/Markdown/index.ts b/src/Markdown/index.ts index f9a1f6b..323b766 100644 --- a/src/Markdown/index.ts +++ b/src/Markdown/index.ts @@ -1,4 +1,4 @@ export * from './Markdown'; -export * from './remarkCve'; export * from './Table'; export * from './CodeHighlighter'; +export * from './plugins'; diff --git a/src/Markdown/plugins/index.ts b/src/Markdown/plugins/index.ts new file mode 100644 index 0000000..5699fe9 --- /dev/null +++ b/src/Markdown/plugins/index.ts @@ -0,0 +1 @@ +export * from './remarkCve'; diff --git a/src/Markdown/remarkCve.ts b/src/Markdown/plugins/remarkCve.ts similarity index 100% rename from src/Markdown/remarkCve.ts rename to src/Markdown/plugins/remarkCve.ts diff --git a/src/SessionInput.tsx b/src/SessionInput.tsx index 35f2d49..190c001 100644 --- a/src/SessionInput.tsx +++ b/src/SessionInput.tsx @@ -1,9 +1,17 @@ -import { FC, useState, KeyboardEvent, ReactElement, useRef, ChangeEvent } from 'react'; +import { + FC, + useState, + KeyboardEvent, + ReactElement, + useRef, + ChangeEvent, + useContext +} from 'react'; import { Button, Textarea, cn } from 'reablocks'; import SendIcon from '@/assets/send.svg?react'; import StopIcon from '@/assets/stop.svg?react'; import AttachIcon from '@/assets/paperclip.svg?react'; -import { ChatTheme } from './theme'; +import { SessionsContext } from './SessionsContext'; interface SessionInputProps { /** @@ -11,11 +19,6 @@ interface SessionInputProps { */ inputDefaultValue?: string; - /** - * Theme to use for the input. - */ - theme?: ChatTheme; - /** * Allowed file types for upload. */ @@ -63,7 +66,6 @@ interface SessionInputProps { } export const SessionInput: FC = ({ - theme, allowedFiles, onSendMessage, isLoading, @@ -75,6 +77,7 @@ export const SessionInput: FC = ({ stopIcon = , attachIcon = }) => { + const { theme } = useContext(SessionsContext); const [message, setMessage] = useState(''); const fileInputRef = useRef(null); @@ -106,7 +109,7 @@ export const SessionInput: FC = ({ minRows={1} autoFocus value={message} - onChange={(e) => setMessage(e.target.value)} + onChange={e => setMessage(e.target.value)} defaultValue={inputDefaultValue} onKeyPress={handleKeyPress} placeholder={inputPlaceholder} diff --git a/src/SessionListItem.tsx b/src/SessionListItem.tsx deleted file mode 100644 index 32615bc..0000000 --- a/src/SessionListItem.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { FC, ReactElement } from 'react'; -import { ListItem, IconButton, cn, Ellipsis } from 'reablocks'; -import { Session } from './types'; -import TrashIcon from '@/assets/trash.svg?react'; -import { ChatTheme } from './theme'; - -interface SessionListItemProps { - /** - * Theme to use for the session list item. - */ - theme?: ChatTheme; - - /** - * Session to display. - */ - session: Session; - - /** - * Indicates whether this session is currently active - */ - isActive: boolean; - - /** - * Icon to show for delete. - */ - deleteIcon?: ReactElement; - - /** - * Callback function to handle session selection, receives the session ID - */ - onSelectSession?: (sessionId: string) => void; - - /** - * Callback function to handle session deletion, receives the session ID - */ - onDeleteSession?: (sessionId: string) => void; -} - -export const SessionListItem: FC = ({ - session, - isActive, - theme, - onSelectSession, - onDeleteSession, - deleteIcon = -}) => ( - onSelectSession?.(session.id)} - end={ - <> - {onDeleteSession && ( - { - e.stopPropagation(); - onDeleteSession(session.id); - }} - > - {deleteIcon} - - )} - - } - > - - -); diff --git a/src/SessionMessages.tsx b/src/SessionMessages.tsx deleted file mode 100644 index 7e53536..0000000 --- a/src/SessionMessages.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useContext, useEffect, useMemo, useRef } from 'react'; -import { SessionMessage } from './SessionMessage'; -import { Session } from './types'; -import { SessionsContext } from './SessionsContext'; -import { Button, cn, DateFormat, useInfinityList } from 'reablocks'; - -interface SessionMessagesProps { - /** - * Session to display. - */ - session: Session; - - /** - * Limit the number of results returned. Clientside pagination. - */ - limit?: number | null; - - /** - * Text to display for the show more button. - */ - showMoreText?: string; -} - -export const SessionMessages: React.FC = ({ - session, - limit = 10, - showMoreText = 'Show more' -}) => { - const { theme } = useContext(SessionsContext); - const contentRef = useRef(null); - - useEffect(() => { - if (contentRef.current) { - // Scroll to the bottom of the content in animation queue - requestAnimationFrame(() => - contentRef.current.scrollTop = contentRef.current.scrollHeight); - } - }, [session]); - - function handleShowMore() { - showNext(10); - requestAnimationFrame(() => contentRef.current.scrollTop = 0); - } - - // Reverse the conversations so the last one is the first one - const reversedConvos = useMemo(() => [...session.conversations].reverse(), [session]); - - const { - data, - hasMore, - showNext - } = useInfinityList({ - items: reversedConvos, - limit - }); - - // Reverse the data to the last one last now - const reReversedConvo = useMemo(() => [...data].reverse(), [data]); - - // If we are not paging, just return the conversations - const convosToRender = limit ? reReversedConvo : session.conversations; - - return ( -

-
-

- {session.title} -

- -
-
- {hasMore && ( - - )} - {convosToRender.map((conversation) => ( - - ))} -
-
- ); -}; diff --git a/src/SessionMessages/SessionEmpty.tsx b/src/SessionMessages/SessionEmpty.tsx new file mode 100644 index 0000000..e6a50cf --- /dev/null +++ b/src/SessionMessages/SessionEmpty.tsx @@ -0,0 +1,14 @@ +import { FC, ReactNode, useContext } from 'react'; +import { SessionsContext } from '@/SessionsContext'; +import { cn } from 'reablocks'; + +interface SessionEmptyProps { + newSessionContent?: string | ReactNode; +} + +export const SessionEmpty: FC = ({ + newSessionContent = '' +}) => { + const { theme } = useContext(SessionsContext); + return
{newSessionContent}
; +}; diff --git a/src/SessionMessage.tsx b/src/SessionMessages/SessionMessage.tsx similarity index 82% rename from src/SessionMessage.tsx rename to src/SessionMessages/SessionMessage.tsx index d9c59c0..edfc9ed 100644 --- a/src/SessionMessage.tsx +++ b/src/SessionMessages/SessionMessage.tsx @@ -1,5 +1,5 @@ -import { FC, ReactElement, useContext } from 'react'; -import { SessionsContext } from './SessionsContext'; +import { FC, PropsWithChildren, ReactElement, useContext } from 'react'; +import { SessionsContext } from '@/SessionsContext'; import { IconButton, cn } from 'reablocks'; import remarkGfm from 'remark-gfm'; import CopyIcon from '@/assets/copy.svg?react'; @@ -7,18 +7,19 @@ import ThumbsDownIcon from '@/assets/thumbs-down.svg?react'; import ThumbUpIcon from '@/assets/thumbs-up.svg?react'; import RefreshIcon from '@/assets/refresh.svg?react'; import { PluggableList } from 'react-markdown/lib'; -import { Markdown } from './Markdown'; +import { Markdown } from '@/Markdown'; +import remarkYoutube from 'remark-youtube'; -interface SessionMessageProps { +export interface SessionMessageProps { /** * Question to display. */ - question: string; + question?: string; /** * Response to display. */ - response: string; + response?: string; /** * Icon to show for copy. @@ -57,8 +58,8 @@ interface SessionMessageProps { } export const SessionMessage: FC = ({ - question, - response, + question = '', + response = '', copyIcon = , thumbsUpIcon = , thumbsDownIcon = , @@ -67,23 +68,21 @@ export const SessionMessage: FC = ({ onDownvote, onRefresh }) => { - const { - theme, - remarkPlugins = [remarkGfm] - } = useContext(SessionsContext); + const { theme, remarkPlugins = [remarkGfm, remarkYoutube] } = useContext(SessionsContext); const handleCopy = (text: string) => { - navigator.clipboard.writeText(text).then(() => { - console.log('Text copied to clipboard'); - }).catch(err => { - console.error('Could not copy text: ', err); - }); + navigator.clipboard + .writeText(text) + .then(() => { + console.log('Text copied to clipboard'); + }) + .catch(err => { + console.error('Could not copy text: ', err); + }); }; return ( -
+
{question} diff --git a/src/SessionMessages/SessionMessages.tsx b/src/SessionMessages/SessionMessages.tsx new file mode 100644 index 0000000..4ee59e4 --- /dev/null +++ b/src/SessionMessages/SessionMessages.tsx @@ -0,0 +1,106 @@ +import React, { + PropsWithChildren, + ReactNode, + useContext, + useEffect, + useMemo, + useRef +} from 'react'; +import { SessionEmpty } from './SessionEmpty'; +import { SessionMessage } from './SessionMessage'; +import { SessionsContext } from '@/SessionsContext'; +import { Button, cn, DateFormat, useInfinityList } from 'reablocks'; +import { Slot } from '@radix-ui/react-slot'; + +interface SessionMessagesProps extends PropsWithChildren { + /** + * Content to display when there are no sessions selected or a new session is started. + */ + newSessionContent?: string | ReactNode; + + /** + * Limit the number of results returned. Clientside pagination. + */ + limit?: number | null; + + /** + * Text to display for the show more button. + */ + showMoreText?: string; +} + +export const SessionMessages: React.FC = ({ + children, + newSessionContent, + limit = 10, + showMoreText = 'Show more' +}) => { + const { activeSession, theme } = useContext(SessionsContext); + const contentRef = useRef(null); + const MessageComponent = children ? Slot : SessionMessage; + + useEffect(() => { + if (contentRef.current) { + // Scroll to the bottom of the content in animation queue + requestAnimationFrame( + () => (contentRef.current.scrollTop = contentRef.current.scrollHeight) + ); + } + }, [activeSession]); + + function handleShowMore() { + showNext(10); + requestAnimationFrame(() => (contentRef.current.scrollTop = 0)); + } + + // Reverse the conversations so the last one is the first one + const reversedConvos = useMemo( + () => [...activeSession?.conversations ?? []].reverse(), + [activeSession] + ); + + const { data, hasMore, showNext } = useInfinityList({ + items: reversedConvos, + limit + }); + + // Reverse the data to the last one last now + const reReversedConvo = useMemo(() => [...data].reverse(), [data]); + + // If we are not paging, just return the conversations + const convosToRender = limit ? reReversedConvo : activeSession?.conversations; + + if (!activeSession) { + return ; + } + + return ( +
+
+

{activeSession.title}

+ +
+
+ {hasMore && ( + + )} + {convosToRender.map(conversation => ( + + {children} + + ))} +
+
+ ); +}; diff --git a/src/SessionMessages/index.ts b/src/SessionMessages/index.ts new file mode 100644 index 0000000..0aac940 --- /dev/null +++ b/src/SessionMessages/index.ts @@ -0,0 +1,3 @@ +export * from './SessionEmpty'; +export * from './SessionMessage'; +export * from './SessionMessages'; diff --git a/src/Sessions.tsx b/src/Sessions.tsx index ded878f..1836c43 100644 --- a/src/Sessions.tsx +++ b/src/Sessions.tsx @@ -1,10 +1,16 @@ -import { CSSProperties, FC, PropsWithChildren, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { + CSSProperties, + FC, + PropsWithChildren, + ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { useHotkeys } from 'reakeys'; import { cn, useComponentTheme } from 'reablocks'; import { Session } from './types'; -import { SessionsList } from './SessionsList'; -import { SessionMessages } from './SessionMessages'; -import { SessionInput } from './SessionInput'; import { ChatTheme, chatTheme } from './theme'; import { SessionsContext } from './SessionsContext'; import { PluggableList } from 'react-markdown/lib'; @@ -25,7 +31,7 @@ export interface SessionsProps extends PropsWithChildren { * meant to be displayed alongside other content. Full prompts are larger * and are meant to be displayed on their own. */ - viewType: 'companion' | 'console'; + viewType?: 'companion' | 'console'; /** * The list of allowed file types. If null or not defined, not file upload @@ -68,11 +74,6 @@ export interface SessionsProps extends PropsWithChildren { */ theme?: ChatTheme; - /** - * Content to display when there are no sessions selected or a new session is started. - */ - newSessionContent?: string | ReactNode; - /** * Remark plugins to apply to the request/response. */ @@ -105,6 +106,7 @@ export interface SessionsProps extends PropsWithChildren { } export const Sessions: FC = ({ + children, viewType = 'console', sessions, onSelectSession, @@ -116,7 +118,6 @@ export const Sessions: FC = ({ onSendMessage, onStopMessage, onNewSession, - newSessionContent = '', allowedFiles, newSessionText = 'New Session', inputDefaultValue, @@ -125,21 +126,29 @@ export const Sessions: FC = ({ className }) => { const theme = useComponentTheme('chat', customTheme); - const [internalActiveSessionID, setInternalActiveSessionID] = useState(activeSessionId); + const [internalActiveSessionID, setInternalActiveSessionID] = useState< + string | undefined + >(activeSessionId); useEffect(() => { setInternalActiveSessionID(activeSessionId); }, [activeSessionId]); - const handleSelectSession = useCallback((sessionId: string) => { - setInternalActiveSessionID(sessionId); - onSelectSession?.(sessionId); - }, [onSelectSession]); + const handleSelectSession = useCallback( + (sessionId: string) => { + setInternalActiveSessionID(sessionId); + onSelectSession?.(sessionId); + }, + [onSelectSession] + ); - const handleDeleteSession = useCallback((sessionId: string) => { - setInternalActiveSessionID(undefined); - onDeleteSession?.(sessionId); - }, [onDeleteSession]); + const handleDeleteSession = useCallback( + (sessionId: string) => { + setInternalActiveSessionID(undefined); + onDeleteSession?.(sessionId); + }, + [onDeleteSession] + ); const handleCreateNewSession = useCallback(() => { setInternalActiveSessionID(undefined); @@ -158,29 +167,33 @@ export const Sessions: FC = ({ } ]); - const activeSession = useMemo(() => - sessions.find(session => session.id === internalActiveSessionID), - [sessions, internalActiveSessionID]); - - const contextValue = useMemo(() => ({ - sessions, - activeSession, - remarkPlugins, - theme, - activeSessionId: internalActiveSessionID, - selectSession: handleSelectSession, - deleteSession: handleDeleteSession, - createSession: handleCreateNewSession - }), [ - theme, - remarkPlugins, - sessions, - activeSession, - internalActiveSessionID, - handleSelectSession, - handleDeleteSession, - handleCreateNewSession - ]); + const activeSession = useMemo( + () => sessions.find(session => session.id === internalActiveSessionID), + [sessions, internalActiveSessionID] + ); + + const contextValue = useMemo( + () => ({ + sessions, + activeSession, + remarkPlugins, + theme, + activeSessionId: internalActiveSessionID, + selectSession: handleSelectSession, + deleteSession: handleDeleteSession, + createSession: handleCreateNewSession + }), + [ + theme, + remarkPlugins, + sessions, + activeSession, + internalActiveSessionID, + handleSelectSession, + handleDeleteSession, + handleCreateNewSession + ] + ); return ( @@ -191,35 +204,7 @@ export const Sessions: FC = ({ })} style={style} > - <> - -
- {activeSession ? ( - - ) : ( -
- {newSessionContent} -
- )} - -
- + {children}
); diff --git a/src/SessionsList.tsx b/src/SessionsList.tsx deleted file mode 100644 index 04ba501..0000000 --- a/src/SessionsList.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { FC, Fragment, useContext, useMemo } from 'react'; -import { SessionListItem } from './SessionListItem'; -import { List, ListItem, Button, cn, Divider } from 'reablocks'; -import { groupSessionsByDate } from './utils'; -import { SessionsContext } from './SessionsContext'; - -interface SessionsListProps { - /** - * Text to show for the new session button. - */ - newSessionText?: string; - - /** - * Callback function to handle session selection, receives the session ID - */ - onSelectSession?: (sessionId: string) => void; - - /** - * Callback function to handle session deletion, receives the session ID - */ - onDeleteSession?: (sessionId: string) => void; - - /** - * Callback function to handle creating a new session. - */ - onCreateNewSession?: () => void; -} - -export const SessionsList: FC = ({ - newSessionText = 'New Session', - onSelectSession, - onDeleteSession, - onCreateNewSession -}) => { - const { theme, activeSessionId, sessions } = useContext(SessionsContext); - const groups = useMemo(() => groupSessionsByDate(sessions), [sessions]); - - return ( - - - - - - {Object.keys(groups).map(k => ( - - - {k} - - {groups[k].map(s => ( - - ))} - - ))} - - ); -}; diff --git a/src/SessionsList/NewSessionButton.tsx b/src/SessionsList/NewSessionButton.tsx new file mode 100644 index 0000000..61b481f --- /dev/null +++ b/src/SessionsList/NewSessionButton.tsx @@ -0,0 +1,27 @@ +import { Button, cn } from 'reablocks'; +import { FC, PropsWithChildren, useContext } from 'react'; +import { SessionsContext } from '@/SessionsContext'; +import { Slot } from '@radix-ui/react-slot'; + +interface NewSessionButtonProps extends PropsWithChildren { + newSessionText?: string; +} + +export const NewSessionButton: FC = ({ + children, + newSessionText = 'New Session' +}) => { + const { theme, createSession } = useContext(SessionsContext); + const Comp = children ? Slot : Button; + return ( + + {children || newSessionText} + + ); +}; diff --git a/src/SessionsList/SessionGroups.tsx b/src/SessionsList/SessionGroups.tsx new file mode 100644 index 0000000..b783a30 --- /dev/null +++ b/src/SessionsList/SessionGroups.tsx @@ -0,0 +1,13 @@ +import { FC, ReactNode, useContext, useMemo } from 'react'; +import { GroupedSessions, groupSessionsByDate } from '@/utils'; +import { SessionsContext } from '@/SessionsContext'; + +export interface SessionGroupsProps { + children: (groups: GroupedSessions[]) => ReactNode; +} + +export const SessionGroups: FC = ({ children }) => { + const { sessions } = useContext(SessionsContext); + const groups = useMemo(() => groupSessionsByDate(sessions), [sessions]); + return <>{children(groups)}; +}; diff --git a/src/SessionsList/SessionListItem.tsx b/src/SessionsList/SessionListItem.tsx new file mode 100644 index 0000000..648c77c --- /dev/null +++ b/src/SessionsList/SessionListItem.tsx @@ -0,0 +1,64 @@ +import { FC, ReactElement, ReactNode, useContext } from 'react'; +import { ListItem, IconButton, cn, Ellipsis } from 'reablocks'; +import { Session } from '@/types'; +import TrashIcon from '@/assets/trash.svg?react'; +import { SessionsContext } from '@/SessionsContext'; +import { Slot } from '@radix-ui/react-slot'; + +export interface SessionListItemProps { + children?: ReactNode; + + /** + * Session to display. + */ + session: Session; + + /** + * Indicates whether the session is deletable. + */ + deletable?: boolean; + + /** + * Icon to show for delete. + */ + deleteIcon?: ReactElement; +} + +export const SessionListItem: FC = ({ + children, + session, + deletable = true, + deleteIcon = +}) => { + const { activeSessionId, selectSession, deleteSession, theme } = + useContext(SessionsContext); + const Comp = children ? Slot : ListItem; + return ( + selectSession?.(session.id)} + end={ + <> + {deletable && ( + { + e.stopPropagation(); + deleteSession(session.id); + }} + className={cn(theme.sessions.session.delete)} + > + {deleteIcon} + + )} + + } + > + {children || } + + ); +}; diff --git a/src/SessionsList/SessionsGroup.tsx b/src/SessionsList/SessionsGroup.tsx new file mode 100644 index 0000000..eb3560f --- /dev/null +++ b/src/SessionsList/SessionsGroup.tsx @@ -0,0 +1,28 @@ +import { FC, PropsWithChildren, useContext } from 'react'; +import { SessionsContext } from '@/SessionsContext'; +import { ListItem, cn } from 'reablocks'; + +interface SessionsGroupProps extends PropsWithChildren { + heading?: string; +} + +export const SessionsGroup: FC = ({ + heading, + children +}) => { + const { theme } = useContext(SessionsContext); + return ( + <> + {heading && ( + + {heading} + + )} + {children} + + ); +}; diff --git a/src/SessionsList/SessionsList.tsx b/src/SessionsList/SessionsList.tsx new file mode 100644 index 0000000..eb236bd --- /dev/null +++ b/src/SessionsList/SessionsList.tsx @@ -0,0 +1,9 @@ +import { FC, PropsWithChildren, useContext } from 'react'; +import { List, cn } from 'reablocks'; +import { SessionsContext } from '@/SessionsContext'; + +export const SessionsList: FC = ({ children }) => { + const { theme } = useContext(SessionsContext); + + return {children}; +}; diff --git a/src/SessionsList/index.ts b/src/SessionsList/index.ts new file mode 100644 index 0000000..e8e2073 --- /dev/null +++ b/src/SessionsList/index.ts @@ -0,0 +1,5 @@ +export * from './SessionsList'; +export * from './SessionListItem'; +export * from './NewSessionButton'; +export * from './SessionGroups'; +export * from './SessionsGroup'; diff --git a/src/assets/menu.svg b/src/assets/menu.svg new file mode 100644 index 0000000..c1c8c17 --- /dev/null +++ b/src/assets/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/index.ts b/src/index.ts index 4f72c23..9991359 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,6 @@ export * from './SessionInput'; export * from './SessionMessages'; export * from './Sessions'; -export * from './SessionMessage'; -export * from './SessionListItem'; export * from './SessionsList'; export * from './types'; export * from './theme'; diff --git a/src/theme.ts b/src/theme.ts index f0c7383..8583e10 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -13,6 +13,7 @@ export interface ChatTheme { messages: { base: string; title: string; + date: string; content: string; header: string; showMore: string; @@ -47,14 +48,15 @@ export const chatTheme: ChatTheme = { create: 'mb-4', session: { base: '', - delete: 'w-4 h-4' + delete: '[&>svg]:w-4 [&>svg]:h-4 opacity-50' } }, messages: { base: 'flex flex-col flex-1 overflow-hidden', title: 'text-2xl font-bold', + date: 'text-sm whitespace-nowrap pt-2', content: 'mt-2 flex-1 overflow-auto', - header: 'flex justify-between items-center', + header: 'flex justify-between items-start gap-2', showMore: 'mb-4', message: { base: 'mb-6 flex flex-col border-gray-400 border p-5 rounded', diff --git a/src/utils.spec.ts b/src/utils.spec.ts index a109a5f..68ffd47 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { groupSessionsByDate } from './utils'; import { Session } from './types'; -import { subDays, subWeeks, subMonths, subYears } from 'date-fns'; +import { subDays } from 'date-fns'; describe('groupSessionsByDate', () => { const createSession = (daysAgo: number): Session => ({ @@ -24,17 +24,22 @@ describe('groupSessionsByDate', () => { const grouped = groupSessionsByDate(sessions); - expect(Object.keys(grouped)).toEqual(['Today', 'Yesterday', 'Last Week', 'Last Month', expect.stringMatching(/^[A-Z][a-z]+$/), 'Last Year']); - expect(grouped['Today'].length).toBe(1); - expect(grouped['Yesterday'].length).toBe(1); - expect(grouped['Last Week'].length).toBe(1); - expect(grouped['Last Month'].length).toBe(1); - expect(Object.values(grouped).flat().length).toBe(sessions.length); + expect(grouped).toHaveLength(6); + expect(grouped[0].heading).toBe('Today'); + expect(grouped[1].heading).toBe('Yesterday'); + expect(grouped[2].heading).toBe('Last Week'); + expect(grouped[3].heading).toBe('Last Month'); + expect(grouped[4].heading).toMatch(/^[A-Z][a-z]+$/); + expect(grouped[5].heading).toBe('Last Year'); + + expect(grouped[0].sessions).toHaveLength(1); + expect(grouped[1].sessions).toHaveLength(1); + expect(grouped.flatMap(g => g.sessions)).toHaveLength(sessions.length); }); it('handles empty input', () => { const grouped = groupSessionsByDate([]); - expect(Object.keys(grouped)).toHaveLength(0); + expect(grouped).toHaveLength(0); }); it('groups multiple sessions in the same category', () => { @@ -47,8 +52,11 @@ describe('groupSessionsByDate', () => { const grouped = groupSessionsByDate(sessions); - expect(grouped['Today'].length).toBe(2); - expect(grouped['Yesterday'].length).toBe(2); + expect(grouped).toHaveLength(2); + expect(grouped[0].heading).toBe('Today'); + expect(grouped[0].sessions).toHaveLength(2); + expect(grouped[1].heading).toBe('Yesterday'); + expect(grouped[1].sessions).toHaveLength(2); }); it('sorts groups in the correct order', () => { @@ -60,10 +68,24 @@ describe('groupSessionsByDate', () => { ]; const grouped = groupSessionsByDate(sessions); - const groupOrder = Object.keys(grouped); - expect(groupOrder[0]).toBe('Today'); - expect(groupOrder[1]).toBe('Yesterday'); - expect(groupOrder[groupOrder.length - 1]).toBe('Last Year'); + expect(grouped[0].heading).toBe('Today'); + expect(grouped[1].heading).toBe('Yesterday'); + expect(grouped[grouped.length - 1].heading).toBe('Last Year'); + }); + + it('sorts sessions within each group by createdAt in descending order', () => { + const now = new Date(); + const sessions: Session[] = [ + { ...createSession(0), createdAt: new Date(now.getTime() - 1000) }, + { ...createSession(0), createdAt: now }, + { ...createSession(1), createdAt: new Date(now.getTime() - 24 * 60 * 60 * 1000 - 1000) }, + { ...createSession(1), createdAt: new Date(now.getTime() - 24 * 60 * 60 * 1000) }, + ]; + + const grouped = groupSessionsByDate(sessions); + + expect(grouped[0].sessions[0].createdAt).toEqual(now); + expect(grouped[1].sessions[0].createdAt).toEqual(new Date(now.getTime() - 24 * 60 * 60 * 1000)); }); }); diff --git a/src/utils.ts b/src/utils.ts index 61b44fd..dc33304 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,9 @@ import { format, isToday, isYesterday, isThisWeek, isThisMonth, isThisYear, parseISO } from 'date-fns'; import { Session } from './types'; -interface GroupedSessions { - [key: string]: Session[]; +export interface GroupedSessions { + heading: string; + sessions: Session[]; } const sortOrder = [ @@ -25,8 +26,8 @@ const sortOrder = [ 'Last Year' ]; -export function groupSessionsByDate(sessions: Session[]): GroupedSessions { - const grouped: GroupedSessions = {}; +export function groupSessionsByDate(sessions: Session[]): GroupedSessions[] { + const grouped: any = {}; sessions.forEach(session => { const createdAt = new Date(session.createdAt); @@ -65,13 +66,10 @@ export function groupSessionsByDate(sessions: Session[]): GroupedSessions { sortOrder.indexOf(a) - sortOrder.indexOf(b) ); - const sortedGrouped: GroupedSessions = {}; - sortedGroups.forEach(key => { - // Sort sessions within each group by createdAt date (most recent first) - sortedGrouped[key] = grouped[key].sort((a, b) => + return sortedGroups.map(heading => ({ + heading, + sessions: grouped[heading].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - }); - - return sortedGrouped; + ) + })); } diff --git a/stories/Demos.stories.tsx b/stories/Demos.stories.tsx index a490a4c..646f6bb 100644 --- a/stories/Demos.stories.tsx +++ b/stories/Demos.stories.tsx @@ -1,9 +1,31 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, FC } from 'react'; import OpenAI from 'openai'; import { Meta } from '@storybook/react'; -import { Sessions, Session, remarkCve } from '../src'; -import { Card, Input } from 'reablocks'; +import { + Sessions, + Session, + remarkCve, + SessionsList, + SessionsGroup, + SessionListItem, + NewSessionButton, + SessionMessages, + SessionGroups, + SessionMessageProps, + SessionInput, + SessionListItemProps +} from '../src'; +import { + Card, + Divider, + IconButton, + Input, + List, + ListItem, + Menu +} from 'reablocks'; import { subDays, subMinutes, subHours } from 'date-fns'; +import MenuIcon from '@/assets/menu.svg?react'; export default { title: 'Demos', @@ -17,9 +39,21 @@ const fakeSessions: Session[] = [ createdAt: new Date(), updatedAt: new Date(), conversations: [ - { id: '1', question: 'What is React?', response: 'React is a JavaScript library for building user interfaces.', createdAt: new Date(), updatedAt: new Date() }, - { id: '2', question: 'What is JSX?', response: 'JSX is a syntax extension for JavaScript.', createdAt: new Date(), updatedAt: new Date() }, - ], + { + id: '1', + question: 'What is React?', + response: 'React is a JavaScript library for building user interfaces.', + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: '2', + question: 'What is JSX?', + response: 'JSX is a syntax extension for JavaScript.', + createdAt: new Date(), + updatedAt: new Date() + } + ] }, { id: '2', @@ -27,159 +61,460 @@ const fakeSessions: Session[] = [ createdAt: new Date(), updatedAt: new Date(), conversations: [ - { id: '1', question: 'What is TypeScript?', response: 'TypeScript is a typed superset of JavaScript.', createdAt: new Date(), updatedAt: new Date() }, - { id: '2', question: 'What is a component?', response: 'A component is a reusable piece of UI.', createdAt: new Date(), updatedAt: new Date() }, - ], - }, + { + id: '1', + question: 'What is TypeScript?', + response: 'TypeScript is a typed superset of JavaScript.', + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: '2', + question: 'What is a component?', + response: 'A component is a reusable piece of UI.', + createdAt: new Date(), + updatedAt: new Date() + } + ] + } ]; export const Console = () => { return ( -
+
{}} - /> + viewType="console" + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; -export const NewSessionContent = () => { +export const Embeds = () => { + const fakeSessionsWithEmbeds: Session[] = [ + { + id: '1', + title: 'Session with Embeds', + createdAt: new Date(), + updatedAt: new Date(), + conversations: [ + { + id: '1', + question: 'Can you show me a video about React?', + response: ` + ## Watch this video + + https://youtu.be/enTFE2c68FQ + + https://www.youtube.com/watch?v=enTFE2c68FQ + + These links showcase a video about React basics. You can click on either link to watch the video.`, + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: '2', + question: 'Do you have another video recommendation?', + response: ` + Certainly! Here's another great video about web development: + + ## Check out this tutorial + + https://www.youtube.com/watch?v=dQw4w9WgXcQ + + This video covers some interesting web development concepts.`, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + ]; + return ( -
+
- Type a question to get a response... -
- } - onDeleteSession={() => {}} - /> + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; -export const DefaultSession = () => { +export const NewSessionContent = () => { return ( -
+
{}} - /> + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + Type a question to get a response... +
+ } + /> + +
+
); }; -export const Companion = () => { +export const DefaultSession = () => { return ( - +
{}} - /> - + activeSessionId="1" + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
+
); }; +// export const Companion = () => { +// return ( +// +// {}} +// /> +// +// ); +// }; + export const Loading = () => { return ( -
+
{}} - /> + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; export const FileUploads = () => { return ( -
+
{}} - /> + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; export const DefaultInputValue = () => { return ( -
+
{}} - /> + onDeleteSession={() => alert('delete!')} + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; export const UndeleteableSessions = () => { return ( -
- +
+ + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; -export const SessionGrouping = () => { - const createSessionWithDate = (id: string, title: string, daysAgo: number): Session => ({ - id, - title, - createdAt: subDays(new Date(), daysAgo), - updatedAt: subDays(new Date(), daysAgo), - conversations: [ - { id: `${id}-1`, question: 'Sample question', response: 'Sample response', createdAt: subDays(new Date(), daysAgo), updatedAt: subDays(new Date(), daysAgo) }, - ], - }); - - const sessionsWithVariousDates: Session[] = [ - createSessionWithDate('1', 'Today Session', 0), - createSessionWithDate('2', 'Yesterday Session', 1), - createSessionWithDate('2', 'Yesterday Session 2', 1), - createSessionWithDate('3', 'Last Week Session', 6), - createSessionWithDate('4', 'Two Weeks Ago Session', 14), - createSessionWithDate('5', 'Last Month Session', 32), - createSessionWithDate('6', 'Two Months Ago Session', 65), - createSessionWithDate('7', 'Six Months Ago Session', 180), - createSessionWithDate('8', 'Last Year Session', 370), - createSessionWithDate('9', 'Two Years Ago Session', 740), - ]; +// export const SessionGrouping = () => { +// const createSessionWithDate = (id: string, title: string, daysAgo: number): Session => ({ +// id, +// title, +// createdAt: subDays(new Date(), daysAgo), +// updatedAt: subDays(new Date(), daysAgo), +// conversations: [ +// { id: `${id}-1`, question: 'Sample question', response: 'Sample response', createdAt: subDays(new Date(), daysAgo), updatedAt: subDays(new Date(), daysAgo) }, +// ], +// }); - return ( -
- {}} - /> -
- ); -}; +// const sessionsWithVariousDates: Session[] = [ +// createSessionWithDate('1', 'Today Session', 0), +// createSessionWithDate('2', 'Yesterday Session', 1), +// createSessionWithDate('2', 'Yesterday Session 2', 1), +// createSessionWithDate('3', 'Last Week Session', 6), +// createSessionWithDate('4', 'Two Weeks Ago Session', 14), +// createSessionWithDate('5', 'Last Month Session', 32), +// createSessionWithDate('6', 'Two Months Ago Session', 65), +// createSessionWithDate('7', 'Six Months Ago Session', 180), +// createSessionWithDate('8', 'Last Year Session', 370), +// createSessionWithDate('9', 'Two Years Ago Session', 740), +// ]; + +// return ( +//
+// {}} +// /> +//
+// ); +// }; export const HundredSessions = () => { const generateFakeSessions = (count: number): Session[] => { @@ -203,13 +538,39 @@ export const HundredSessions = () => { const hundredSessions = generateFakeSessions(100); return ( -
- {}} - /> +
+ + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; @@ -225,23 +586,54 @@ export const HundredConversations = () => { })); }; - const sessionWithHundredConversations: Session[] = [{ - id: 'session-100', - title: 'Session with 100 Conversations', - createdAt: subHours(new Date(), 5), - updatedAt: new Date(), - conversations: generateFakeConversations(100) - }]; + const sessionWithHundredConversations: Session[] = [ + { + id: 'session-100', + title: 'Session with 100 Conversations', + createdAt: subHours(new Date(), 5), + updatedAt: new Date(), + conversations: generateFakeConversations(100) + } + ]; return ( -
+
{}} - /> + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; @@ -260,13 +652,43 @@ export const LongSessionNames = () => { const sessionsWithLongNames = generateFakeSessionsWithLongNames(10); return ( -
+
{}} - /> + activeSessionId="session-10" + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; @@ -321,28 +743,61 @@ export const MarkdownShowcase = () => { [Perspective](https://en.wikipedia.org/wiki/Philosophical_question) `; - const sessionWithMarkdown: Session[] = [{ - id: 'session-markdown', - title: 'Markdown Showcase', - createdAt: subHours(new Date(), 1), - updatedAt: new Date(), - conversations: [{ - id: 'conversation-1', - question: markdownQuestion, - response: markdownResponse, - createdAt: new Date() - }] - }]; + const sessionWithMarkdown: Session[] = [ + { + id: 'session-markdown', + title: 'Markdown Showcase', + createdAt: subHours(new Date(), 1), + updatedAt: new Date(), + conversations: [ + { + id: 'conversation-1', + question: markdownQuestion, + response: markdownResponse, + createdAt: new Date() + } + ] + } + ]; return ( -
+
{}} - /> + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; @@ -366,29 +821,62 @@ export const CVEExample = () => { - CVE-2021-45046 `; - const sessionWithMarkdown: Session[] = [{ - id: 'session-cve', - title: 'CVE Showcase', - createdAt: subHours(new Date(), 1), - updatedAt: new Date(), - conversations: [{ - id: 'conversation-1', - question: markdownQuestion, - response: markdownResponse, - createdAt: new Date() - }] - }]; + const sessionWithMarkdown: Session[] = [ + { + id: 'session-cve', + title: 'CVE Showcase', + createdAt: subHours(new Date(), 1), + updatedAt: new Date(), + conversations: [ + { + id: 'conversation-1', + question: markdownQuestion, + response: markdownResponse, + createdAt: new Date() + } + ] + } + ]; return ( -
+
{}} remarkPlugins={[remarkCve as any]} - /> + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; @@ -410,70 +898,106 @@ export const OpenAIIntegration = () => { } }, [apiKey]); - const handleNewMessage = useCallback(async (message: string, sessionId: string = '1') => { - setIsLoading(true); - try { - const response = await openai.current.chat.completions.create({ - model: 'gpt-3.5-turbo', - messages: [{ role: 'user', content: message }], - }); + const handleNewMessage = useCallback( + async (message: string, sessionId: string = '1') => { + setIsLoading(true); + try { + const response = await openai.current.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: message }] + }); - const aiResponse = response.choices[0]?.message?.content || 'Sorry, I couldn\'t generate a response.'; - - setSessions(prevSessions => { - const sessionIndex = prevSessions.findIndex(s => s.id === sessionId); - if (sessionIndex === -1) { - // Create a new session - return [...prevSessions, { - id: sessionId, - title: message.slice(0, 30), - createdAt: new Date(), - updatedAt: new Date(), - conversations: [{ - id: Date.now().toString(), - question: message, - response: aiResponse, - createdAt: new Date(), - updatedAt: new Date() - }] - }]; - } else { - // Add to existing session - const updatedSessions = [...prevSessions]; - updatedSessions[sessionIndex] = { - ...updatedSessions[sessionIndex], - updatedAt: new Date(), - conversations: [ - ...updatedSessions[sessionIndex].conversations, + const aiResponse = + response.choices[0]?.message?.content || + "Sorry, I couldn't generate a response."; + + setSessions(prevSessions => { + const sessionIndex = prevSessions.findIndex(s => s.id === sessionId); + if (sessionIndex === -1) { + // Create a new session + return [ + ...prevSessions, { - id: Date.now().toString(), - question: message, - response: aiResponse, + id: sessionId, + title: message.slice(0, 30), createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + conversations: [ + { + id: Date.now().toString(), + question: message, + response: aiResponse, + createdAt: new Date(), + updatedAt: new Date() + } + ] } - ] - }; - return updatedSessions; - } - }); + ]; + } else { + // Add to existing session + const updatedSessions = [...prevSessions]; + updatedSessions[sessionIndex] = { + ...updatedSessions[sessionIndex], + updatedAt: new Date(), + conversations: [ + ...updatedSessions[sessionIndex].conversations, + { + id: Date.now().toString(), + question: message, + response: aiResponse, + createdAt: new Date(), + updatedAt: new Date() + } + ] + }; + return updatedSessions; + } + }); - setActiveSessionId(sessionId);; - } catch (error) { - console.error('Error calling OpenAI API:', error); - } finally { - setIsLoading(false); - } - }, [openai]); + setActiveSessionId(sessionId); + } catch (error) { + console.error('Error calling OpenAI API:', error); + } finally { + setIsLoading(false); + } + }, + [openai] + ); const handleDeleteSession = useCallback((sessionId: string) => { setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId)); }, []); return ( -
- setApiKey(e.target.value)} /> -
+
+ setApiKey(e.target.value)} + /> +
{ onDeleteSession={handleDeleteSession} onSendMessage={handleNewMessage} activeSessionId={activeSessionId} - /> + > + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + ))} + + )) + } + + +
+ + +
+
); }; + +const CustomSessionMessage: FC = ({ + question, + response +}) => ( +
+ + This is my question: {question} + +
+ This is the response: {response} +
+); + +const CustomSessionListItem: FC = ({ session }) => { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + return ( + <> + { + e.stopPropagation(); + setOpen(true); + }} + > + + + } + > + {session.title} + + setOpen(false)} reference={btnRef}> + + + alert('rename')}>Rename + alert('delete')}>Delete + + + + + ); +}; + +export const CustomComponents = () => { + return ( +
+ + + + + + + + {groups => + groups.map(({ heading, sessions }) => ( + + {sessions.map(s => ( + + + + ))} + + )) + } + + +
+ + + + +
+
+
+ ); +};