Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 59 additions & 44 deletions libs/chatbot/lib/components/ChatBot/BotMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,46 +46,53 @@ const getActions = (text: string, onActionClick: SentimentActionClick) => ({
const userFeedbackForm = (
onSubmit: (quickResponse: string | undefined, additionalFeedback: string | undefined) => void,
onClose: VoidFunction,
) => ({
): UserFeedbackProps => ({
onClose,
onSubmit,
title: 'Please provide feedback',
textAreaAriaLabel: 'Additional feedback',
textAreaPlaceholder: 'Add details here',
hasTextArea: true,
closeButtonAriaLabel: 'Close feedback form',
focusOnLoad: false,
focusOnLoad: true,
});

export type BotMessageProps = {
onFeedbackSubmit: (req: FeedbackRequest) => Promise<void>;
messageIndex: number;
message: MsgProps;
onScrollToBottom: () => void;
isLoading: boolean;
isLastMsg: boolean;
initHeight?: number;
};

const BotMessage = ({
onFeedbackSubmit,
messageIndex,
message,
onScrollToBottom,
isLoading,
initHeight,
isLastMsg,
}: BotMessageProps) => {
const [height, setHeight] = React.useState(initHeight);
const [isNegativeFeedback, setIsNegativeFeedback] = React.useState<boolean>(false);
const msgRef = React.useRef<HTMLDivElement>(null);
const scrollToMsgRef = React.useRef<HTMLDivElement>(null);

// Scroll to bottom when negative feedback form opens
React.useEffect(() => {
React.useLayoutEffect(() => {
if (isNegativeFeedback) {
// Use requestAnimationFrame to ensure the form is rendered and painted
requestAnimationFrame(() => {
// Double RAF to ensure layout is complete
requestAnimationFrame(() => {
onScrollToBottom();
});
});
scrollToMsgRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [isNegativeFeedback, onScrollToBottom]);
}, [isNegativeFeedback]);

// run on every re-render
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (height && !isLoading && msgRef.current && msgRef.current.scrollHeight > height) {
setHeight(undefined);
}
});

const actions = React.useMemo(() => {
return getActions(message.pfProps.content || '', (positiveFeedback) => {
Expand Down Expand Up @@ -133,37 +140,45 @@ const BotMessage = ({
}, [isNegativeFeedback, onFeedbackSubmit, messageIndex]);

return (
<Message
{...message.pfProps}
actions={isLoading ? undefined : actions}
userFeedbackForm={userFeedbackFormConfig}
extraContent={{
afterMainContent: isLoading ? (
<MsgLoading />
) : message.actions?.length ? (
<Stack hasGutter>
{message.actions.map(({ title, url }, idx) => (
<StackItem key={idx}>
<Button
onClick={() => {
try {
saveAs(url);
} catch (error) {
// eslint-disable-next-line
console.error('Download failed: ', error);
}
}}
variant="secondary"
icon={<DownloadIcon />}
>
{title}
</Button>
</StackItem>
))}
</Stack>
) : undefined,
}}
/>
<>
<Message
{...message.pfProps}
style={height && isLastMsg ? { minHeight: height } : undefined}
actions={isLoading ? undefined : actions}
userFeedbackForm={userFeedbackFormConfig}
innerRef={msgRef}
extraContent={{
afterMainContent: (
<>
<div ref={scrollToMsgRef} />
{isLoading && <MsgLoading />}
{!isLoading && message.actions?.length && (
<Stack hasGutter>
{message.actions.map(({ title, url }, idx) => (
<StackItem key={idx}>
<Button
onClick={() => {
try {
saveAs(url);
} catch (error) {
// eslint-disable-next-line
console.error('Download failed: ', error);
}
}}
variant="secondary"
icon={<DownloadIcon />}
>
{title}
</Button>
</StackItem>
))}
</Stack>
)}
</>
),
}}
/>
</>
);
};

Expand Down
60 changes: 40 additions & 20 deletions libs/chatbot/lib/components/ChatBot/ChatBotWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Message,
MessageBar,
MessageBox,
MessageBoxHandle,
} from '@patternfly-6/chatbot';
import {
Alert,
Expand Down Expand Up @@ -69,6 +70,7 @@ const ChatBotWindow = ({
onClose,
username,
}: ChatBotWindowProps) => {
const [triggerScroll, setTriggerScroll] = React.useState(0);
const [msg, setMsg] = React.useState('');
const [error, setError] = React.useState<string>();
const [isStreaming, setIsStreaming] = React.useState(false);
Expand All @@ -78,7 +80,8 @@ const ChatBotWindow = ({
);
const [isConfirmModalOpen, setIsConfirmModalOpen] = React.useState(false);
const scrollToBottomRef = React.useRef<HTMLDivElement>(null);
const hasInitiallyScrolled = React.useRef(false);
const lastUserMsgRef = React.useRef<HTMLDivElement>(null);
const msgBoxRef = React.useRef<MessageBoxHandle>(null);

React.useEffect(() => {
!isConfirmModalOpen && focusNewMessageBox();
Expand All @@ -99,16 +102,26 @@ const ChatBotWindow = ({
}
};

const scrollToBottom = React.useCallback((behavior: ScrollBehavior = 'smooth') => {
scrollToBottomRef.current?.scrollIntoView({ behavior });
}, []);

React.useEffect(() => {
// Determine scroll behavior: auto for initial render with existing messages, smooth for new content
const scrollBehavior = !hasInitiallyScrolled.current && messages.length > 0 ? 'auto' : 'smooth';
scrollToBottom(scrollBehavior);
hasInitiallyScrolled.current = true;
}, [messages, scrollToBottom]);
if (triggerScroll === 0) {
scrollToBottomRef.current?.scrollIntoView({ behavior: 'auto' });
} else {
const msgTop = lastUserMsgRef.current?.offsetTop;
if (msgTop !== undefined && msgBoxRef.current) {
msgBoxRef.current.scrollTo({
top: msgTop,
behavior: 'smooth',
});
}
}
}, [triggerScroll]);

const getVisibleHeight = () => {
if (lastUserMsgRef.current && msgBoxRef.current) {
return msgBoxRef.current.clientHeight - lastUserMsgRef.current.clientHeight - 64;
}
return undefined;
};

const handleSend = async (message: string | number) => {
setError(undefined);
Expand All @@ -117,6 +130,7 @@ const ChatBotWindow = ({
let eventEnded = false;
const timestamp = new Date().toLocaleString();
try {
setAnnouncement(`Message from User: ${message}. Message from Bot is loading.`);
setMessages((msgs) => [
...msgs,
{
Expand All @@ -128,13 +142,6 @@ const ChatBotWindow = ({
timestamp,
},
},
]);
setAnnouncement(`Message from User: ${message}. Message from Bot is loading.`);

let convId = '';

setMessages((msgs) => [
...msgs,
{
pfProps: {
role: botRole,
Expand All @@ -145,6 +152,9 @@ const ChatBotWindow = ({
},
},
]);
setTriggerScroll(triggerScroll + 1);

let convId = '';

const resp = await onApiCall('/v1/streaming_query', {
method: 'POST',
Expand Down Expand Up @@ -291,6 +301,8 @@ const ChatBotWindow = ({
[onApiCall, conversationId, messages],
);

const lastUserMsg = [...messages].reverse().findIndex((msg) => msg.pfProps.role === userRole);

return (
<Chatbot displayMode={ChatbotDisplayMode.default}>
<ChatbotHeader>
Expand Down Expand Up @@ -325,7 +337,7 @@ const ChatBotWindow = ({
</ChatbotHeaderTitle>
</ChatbotHeader>
<ChatbotContent>
<MessageBox announcement={announcement} position={'top'}>
<MessageBox announcement={announcement} position={'top'} ref={msgBoxRef}>
{isAlertVisible && (
<Alert
variant="info"
Expand Down Expand Up @@ -366,6 +378,7 @@ const ChatBotWindow = ({
/>
)}
{messages.map((message, index) => {
const isLastMsg = index === messages.length - 1;
const messageKey = conversationId ? `${conversationId}-${index}` : index;
const isBotMessage = message.pfProps.role === botRole;
if (isBotMessage) {
Expand All @@ -375,13 +388,20 @@ const ChatBotWindow = ({
messageIndex={index}
message={message}
onFeedbackSubmit={onFeedbackSubmit}
onScrollToBottom={scrollToBottom}
isLoading={index === messages.length - 1 && isStreaming}
initHeight={isLastMsg ? getVisibleHeight() : undefined}
isLastMsg={isLastMsg}
/>
);
}

return <Message key={messageKey} {...message.pfProps} />;
return (
<Message
key={messageKey}
{...message.pfProps}
innerRef={index === messages.length - 1 - lastUserMsg ? lastUserMsgRef : undefined}
/>
);
})}
{error && (
<ChatbotAlert
Expand Down
Loading