diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md index 81a8ee63..33620492 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md @@ -34,6 +34,7 @@ import ChatbotFooter, { ChatbotFootnote } from '@patternfly/chatbot/dist/dynamic import MessageBar from '@patternfly/chatbot/dist/dynamic/MessageBar'; import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import Compare from '@patternfly/chatbot/dist/dynamic/Compare'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; import ChatbotHeader, { @@ -103,3 +104,24 @@ This demo displays an embedded ChatBot. Embedded ChatBots are meant to be placed ```js file="./EmbeddedChatbot.tsx" isFullscreen ``` + +### Comparing ChatBots + +To let users compare how different ChatBots respond to the same prompt, you can add multiple ChatBots within the same window. The following demo illustrates a comparison view pattern that allows users to toggle between different conversations in a single ChatBot window. +

+Your code structure should look like this: + +```noLive + +
+ + + + +
+
+``` + +```js file="./EmbeddedComparisonChatbot.tsx" isFullscreen + +``` diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/EmbeddedComparisonChatbot.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/EmbeddedComparisonChatbot.tsx new file mode 100644 index 00000000..b90bb265 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/EmbeddedComparisonChatbot.tsx @@ -0,0 +1,206 @@ +import React from 'react'; + +import { + Page, + Masthead, + MastheadMain, + MastheadBrand, + MastheadLogo, + PageSidebarBody, + PageSidebar, + MastheadToggle, + PageToggleButton +} from '@patternfly/react-core'; +import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'; +import ChatbotContent from '@patternfly/chatbot/dist/dynamic/ChatbotContent'; +import ChatbotWelcomePrompt from '@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt'; +import ChatbotFooter from '@patternfly/chatbot/dist/dynamic/ChatbotFooter'; +import MessageBar from '@patternfly/chatbot/dist/dynamic/MessageBar'; +import MessageBox from '@patternfly/chatbot/dist/dynamic/MessageBox'; +import Message, { MessageProps } from '@patternfly/chatbot/dist/dynamic/Message'; +import ChatbotHeader, { ChatbotHeaderMain } from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'; +import Compare from '@patternfly/chatbot/dist/dynamic/Compare'; +import { BarsIcon } from '@patternfly/react-icons'; +import userAvatar from '../Messages/user_avatar.svg'; +import patternflyAvatar from '../Messages/patternfly_avatar.jpg'; + +export const CompareChild = ({ name, input, hasNewInput, setIsSendButtonDisabled }) => { + const [messages, setMessages] = React.useState([]); + const [announcement, setAnnouncement] = React.useState(); + const scrollToBottomRef = React.useRef(null); + const displayMode = ChatbotDisplayMode.embedded; + + // you will likely want to come up with your own unique id function; this is for demo purposes only + const generateId = () => { + const id = Date.now() + Math.random(); + return id.toString(); + }; + + const handleSend = (input: string) => { + const date = new Date(); + const newMessages: MessageProps[] = []; + messages.forEach((message) => newMessages.push(message)); + newMessages.push({ + avatar: userAvatar, + avatarProps: { isBordered: true }, + id: generateId(), + name: 'You', + role: 'user', + content: input, + timestamp: `${date?.toLocaleDateString()} ${date?.toLocaleTimeString()}` + }); + newMessages.push({ + avatar: patternflyAvatar, + id: generateId(), + name, + role: 'bot', + timestamp: `${date?.toLocaleDateString()} ${date?.toLocaleTimeString()}`, + isLoading: true + }); + setMessages(newMessages); + // make announcement to assistive devices that new messages have been added + setAnnouncement(`Message from You: ${input}. Message from ${name} is loading.`); + + // this is for demo purposes only; in a real situation, there would be an API response we would wait for + setTimeout(() => { + const loadedMessages: MessageProps[] = []; + // we can't use structuredClone since messages contains functions, but we can't mutate + // items that are going into state or the UI won't update correctly + newMessages.forEach((message) => loadedMessages.push(message)); + loadedMessages.pop(); + loadedMessages.push({ + id: generateId(), + role: 'bot', + content: `API response from ${name} goes here`, + name, + avatar: patternflyAvatar, + isLoading: false, + actions: { + // eslint-disable-next-line no-console + positive: { onClick: () => console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + share: { onClick: () => console.log('Share') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }, + timestamp: date.toLocaleString() + }); + setMessages(loadedMessages); + // make announcement to assistive devices that new message has loaded + setAnnouncement(`Message from ${name}: API response goes here`); + setIsSendButtonDisabled(false); + }, 5000); + }; + + React.useEffect(() => { + if (input) { + handleSend(input); + } + }, [hasNewInput]); + + // Auto-scrolls to the latest message + React.useEffect(() => { + // don't scroll the first load, but scroll if there's a current stream or a new source has popped up + if (messages.length > 0) { + scrollToBottomRef.current?.scrollIntoView(); + } + }, [messages]); + + return ( + + + {name} + + + + + {messages.map((message) => ( + + ))} +
+
+
+
+ ); +}; + +export const EmbeddedComparisonChatbotDemo: React.FunctionComponent = () => { + const [input, setInput] = React.useState(); + const [hasNewInput, setHasNewInput] = React.useState(false); + const [isSidebarOpen, setIsSidebarOpen] = React.useState(false); + const [isSendButtonDisabled, setIsSendButtonDisabled] = React.useState(false); + + const handleSend = (value: string) => { + setInput(value); + setHasNewInput(!hasNewInput); + setIsSendButtonDisabled(true); + }; + + const masthead = ( + + + + setIsSidebarOpen(!isSidebarOpen)} + id="fill-nav-toggle" + > + + + + + + Logo + + + + + ); + + const sidebar = ( + + Navigation + + ); + + return ( + +
+ + } + secondChild={ + + } + firstChildDisplayName="ChatBot 1" + secondChildDisplayName="ChatBot 2" + /> + + + +
+
+ ); +}; diff --git a/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/comparing-chatbots.png b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/comparing-chatbots.png new file mode 100644 index 00000000..673e0e3f Binary files /dev/null and b/packages/module/patternfly-docs/generated/patternfly-ai/chatbot/overview/demo/comparing-chatbots.png differ diff --git a/packages/module/src/Compare/Compare.scss b/packages/module/src/Compare/Compare.scss new file mode 100644 index 00000000..ce4cee44 --- /dev/null +++ b/packages/module/src/Compare/Compare.scss @@ -0,0 +1,72 @@ +.pf-chatbot__compare-container { + display: flex; + flex-direction: column; + position: relative; + height: 100%; +} +.pf-chatbot__compare-toggle { + width: 100%; + + .pf-v6-c-toggle-group__button { + width: 100%; + display: flex; + justify-content: center; + } +} +.pf-chatbot__compare { + display: flex; + height: 100%; + width: 100%; + + @media screen and (max-width: 900px) { + overflow-y: auto; + } + + .pf-chatbot__compare-item:first-of-type { + border-right: 1px solid var(--pf-t--global--border--color--default); + + @media screen and (max-width: 900px) { + border-right: 0px; + } + } +} + +.pf-chatbot__compare-item { + flex: 1; + + .pf-chatbot--embedded .pf-chatbot__messagebox { + width: 100%; + } + + .pf-chatbot__content { + padding: 0; + } + + .pf-chatbot.pf-chatbot--embedded { + @media screen and (max-width: 900px) { + height: 100%; + } + } +} +.pf-chatbot__compare-item-hidden { + display: block; + + @media screen and (max-width: 900px) { + display: none; + } +} + +.pf-chatbot__compare-mobile-controls { + padding: var(--pf-t--global--spacer--md) var(--pf-t--global--spacer--lg) 0 var(--pf-t--global--spacer--lg); + display: none; + background-color: var(--pf-t--global--background--color--secondary--default); + position: sticky; + top: 0; + z-index: 9999; + + @media screen and (max-width: 900px) { + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--md); + } +} diff --git a/packages/module/src/Compare/Compare.test.tsx b/packages/module/src/Compare/Compare.test.tsx new file mode 100644 index 00000000..b00a8cfb --- /dev/null +++ b/packages/module/src/Compare/Compare.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Compare from './Compare'; + +const firstChild = ( +
+

Child 1

+
+); + +const secondChild = ( +
+

Child 2

+
+); + +describe('Compare', () => { + it('should render compare correctly', () => { + render( + + ); + expect(screen.getByRole('heading', { name: /Child 1/i })).toBeTruthy(); + expect(screen.getByRole('heading', { name: /Child 2/i })).toBeTruthy(); + }); +}); diff --git a/packages/module/src/Compare/Compare.tsx b/packages/module/src/Compare/Compare.tsx new file mode 100644 index 00000000..87176c3c --- /dev/null +++ b/packages/module/src/Compare/Compare.tsx @@ -0,0 +1,98 @@ +import React, { PropsWithChildren } from 'react'; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; + +interface CompareProps { + /** First of two children to render */ + firstChild: React.ReactNode; + /** Second of two children to render */ + secondChild: React.ReactNode; + /** Display name for first child, used in mobile toggle */ + firstChildDisplayName: string; + /** Display name for second child, used in mobile toggle */ + secondChildDisplayName: string; + /** Aria label for mobile toggle group */ + toggleGroupAriaLabel?: string; + /** Callback for when mobile toggle is used */ + onToggleClick?: (event: MouseEvent | React.MouseEvent | React.KeyboardEvent) => void; +} + +export const Compare = ({ + firstChild, + secondChild, + firstChildDisplayName, + secondChildDisplayName, + onToggleClick, + toggleGroupAriaLabel = 'Select which chatbot to display' +}: PropsWithChildren) => { + const [isSelected, setIsSelected] = React.useState('toggle-group-chatbot-1'); + const [showFirstChatbot, setShowFirstChatbot] = React.useState(true); + const [showSecondChatbot, setShowSecondChatbot] = React.useState(false); + + React.useEffect(() => { + // we want to show the first if we switch to the mobile toggle view + // and reset/switch back to normal otherwise + const updateChatbotVisibility = () => { + if (window.innerWidth >= 901) { + setShowFirstChatbot(true); + setShowSecondChatbot(true); + } else { + setShowFirstChatbot(true); + setShowSecondChatbot(false); + setIsSelected('toggle-group-chatbot-1'); + } + }; + window.addEventListener('resize', updateChatbotVisibility); + + return () => { + window.removeEventListener('resize', updateChatbotVisibility); + }; + }, []); + + // this only happens on mobile + const handleChildToggleClick = ( + event: MouseEvent | React.MouseEvent | React.KeyboardEvent + ) => { + const id = event.currentTarget.id; + setIsSelected(id); + setShowSecondChatbot(!showSecondChatbot); + setShowFirstChatbot(!showFirstChatbot); + onToggleClick && onToggleClick(event); + }; + + return ( + <> +
+ + + + +
+
+
+ {firstChild} +
+
+ {secondChild} +
+
+ + ); +}; + +export default Compare; diff --git a/packages/module/src/Compare/index.ts b/packages/module/src/Compare/index.ts new file mode 100644 index 00000000..ad502674 --- /dev/null +++ b/packages/module/src/Compare/index.ts @@ -0,0 +1,2 @@ +export { default } from './Compare'; +export * from './Compare'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 700c7caf..6e1cc449 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -39,6 +39,9 @@ export * from './ChatbotWelcomePrompt'; export { default as CodeModal } from './CodeModal'; export * from './CodeModal'; +export { default as Compare } from './Compare'; +export * from './Compare'; + export { default as FileDetails } from './FileDetails'; export * from './FileDetails'; diff --git a/packages/module/src/main.scss b/packages/module/src/main.scss index d55d9b56..b6023cdb 100644 --- a/packages/module/src/main.scss +++ b/packages/module/src/main.scss @@ -10,6 +10,7 @@ @import './ChatbotToggle/ChatbotToggle'; @import './ChatbotWelcomePrompt/ChatbotWelcomePrompt'; @import './CodeModal/CodeModal'; +@import './Compare/Compare'; @import './FileDetails/FileDetails'; @import './FileDetailsLabel/FileDetailsLabel'; @import './FileDropZone/FileDropZone';