Repository: tjtanjin/react-chatbotify (ts)

Issue:
[Task] Add unit test cases for useMessagesInternal hook
**Task Description:**
Currently, we run unit tests using jest which can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/__tests__). 

However, the test cases are not yet comprehensive enough and work can be done to cover more grounds. Specifically for this issue, we hope to add test cases for the `useMessagesInternal` hook.

**Deliverable(s):**
New unit test cases that cover the testing of the `useMessagesInternal` hook.

**Additional Context:**
- Hooks API can be found [**here**](https://react-chatbotify.com/docs/api/hooks)
- Existing unit test for hooks can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/__tests__/hooks) which may serve as a good source for reference.


Pull request:
test: add unit tests for useMessagesInternal hook
#### Description

This PR introduces unit tests for the useMessagesInternal hook. These tests cover the hook's behavior, including returning messages, removing messages, using helper functions, and handling edge cases like empty fields.

Closes #126 

#### What change does this PR introduce?

Please select the relevant option(s).

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update (changes to docs/code comments)

#### What is the proposed approach?

The proposed approach involves writing unit tests for useMessagesInternal. The tests use renderHook to test the hook's output and behavior, mocking necessary context and utility functions to ensure the correct configuration flow when settings are updated.

#### Checklist:

- [x] The commit message follows our adopted [guidelines](https://www.conventionalcommits.org/en/v1.0.0/)
- [x] Testing has been done for the change(s) added (for bug fixes/features)
- [x] Relevant comments/docs have been added/updated (for bug fixes/features)

Patch:
diff --git a/src/components/Buttons/VoiceButton/VoiceButton.tsx b/src/components/Buttons/VoiceButton/VoiceButton.tsx
index e611e01..376ebd2 100644
--- a/src/components/Buttons/VoiceButton/VoiceButton.tsx
+++ b/src/components/Buttons/VoiceButton/VoiceButton.tsx
@@ -1,4 +1,4 @@
-import { useEffect, MouseEvent, useState, useRef } from "react";
+import { useEffect, MouseEvent, useRef, useState } from "react";
 
 import MediaDisplay from "../../ChatBotBody/MediaDisplay/MediaDisplay";
 import { startVoiceRecording, stopVoiceRecording } from "../../../services/VoiceService";
@@ -52,7 +52,7 @@ const VoiceButton = () => {
 			handleSubmitText();
 		}
 	}, [voiceInputTrigger])
-	
+
 	// handles starting and stopping of voice recording on toggle
 	useEffect(() => {
 		if (voiceToggledOn) {
diff --git a/src/components/ChatBotBody/ChatBotBody.tsx b/src/components/ChatBotBody/ChatBotBody.tsx
index 5102243..40ff9bf 100644
--- a/src/components/ChatBotBody/ChatBotBody.tsx
+++ b/src/components/ChatBotBody/ChatBotBody.tsx
@@ -1,4 +1,4 @@
-import { Dispatch, SetStateAction, useEffect, CSSProperties, MouseEvent } from "react";
+import { Dispatch, SetStateAction, CSSProperties, MouseEvent } from "react";
 
 import ChatMessagePrompt from "./ChatMessagePrompt/ChatMessagePrompt";
 import ToastPrompt from "./ToastPrompt/ToastPrompt";
@@ -16,17 +16,13 @@ import "./ChatBotBody.css";
 /**
  * Contains chat messages between the user and bot.
  * 
- * @param chatScrollHeight number representing chat window scroll height
  * @param setChatScrollHeight setter for tracking chat window scroll height
  */
 const ChatBotBody = ({
-	chatScrollHeight,
 	setChatScrollHeight,
 }: {
-	chatScrollHeight: number;
 	setChatScrollHeight: Dispatch<SetStateAction<number>>;
 }) => {
-
 	// handles settings
 	const { settings } = useSettingsContext();
 
@@ -45,11 +41,8 @@ const ChatBotBody = ({
 	// handles bot states
 	const {
 		isBotTyping,
-		isLoadingChatHistory,
-		setIsLoadingChatHistory,
-		isScrolling,
 		setIsScrolling,
-		setUnreadCount
+		setUnreadCount,
 	} = useBotStatesContext();
 
 	// handles bot refs
@@ -88,60 +81,6 @@ const ChatBotBody = ({
 		...styles.toastPromptContainerStyle
 	};
 
-	// shifts scroll position when messages are updated and when bot is typing
-	useEffect(() => {
-		if (isLoadingChatHistory) {
-			if (!chatBodyRef.current) {
-				return;
-			}
-
-			const { scrollHeight } = chatBodyRef.current;
-			const scrollDifference = scrollHeight - chatScrollHeight;
-			chatBodyRef.current.scrollTop = chatBodyRef.current.scrollTop + scrollDifference;
-			setIsLoadingChatHistory(false);
-			return;
-		}
-
-		if (settings.chatWindow?.autoJumpToBottom || !isScrolling) {
-			// defer update to next event loop, handles edge case where messages are sent too fast
-			// and the scrolling does not properly reach the bottom
-			setTimeout(() => {
-				if (!chatBodyRef.current) {
-					return;
-				}
-
-				chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight;
-				if (isChatWindowOpen) {
-					setUnreadCount(0);
-				}
-			})
-		}
-	}, [messages.length, isBotTyping]);
-
-	// shifts scroll position when scroll height changes
-	useEffect(() => {
-		if (!chatBodyRef.current) {
-			return;
-		}
-
-		// used to return chat history to correct height
-		setChatScrollHeight(chatBodyRef.current.scrollHeight);
-
-		if (!isScrolling) {
-			chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight;
-			if (isChatWindowOpen) {
-				setUnreadCount(0);
-			}
-		}
-	}, [chatBodyRef.current?.scrollHeight]);
-
-	// sets unread count to 0 if not scrolling
-	useEffect(() => {
-		if (!isScrolling) {
-			setUnreadCount(0);
-		}
-	}, [isScrolling]);
-
 	/**
 	 * Checks and updates whether a user is scrolling in chat window.
 	 */
@@ -149,15 +88,24 @@ const ChatBotBody = ({
 		if (!chatBodyRef.current) {
 			return;
 		}
+
+		// used to return chat history to correct height
+		setChatScrollHeight(chatBodyRef.current.scrollHeight);
+
 		const { scrollTop, clientHeight, scrollHeight } = chatBodyRef.current;
-		setIsScrolling(
-			scrollTop + clientHeight < scrollHeight - (settings.chatWindow?.messagePromptOffset ?? 30)
-		);
+		const isScrolling = scrollTop + clientHeight < scrollHeight - (settings.chatWindow?.messagePromptOffset ?? 30);
+		setIsScrolling(isScrolling);
 
 		// workaround to ensure user never truly scrolls to bottom by introducing a 1 pixel offset
 		// this is necessary to prevent unexpected scroll behaviors of the chat window when user reaches the bottom
-		if (!isScrolling && scrollTop + clientHeight >= scrollHeight - 1) {
-			chatBodyRef.current.scrollTop = scrollHeight - clientHeight - 1;
+		if (!isScrolling) {
+			if (scrollTop + clientHeight >= scrollHeight - 1) {
+				chatBodyRef.current.scrollTop = scrollHeight - clientHeight - 1;
+			}
+
+			if (isChatWindowOpen) {
+				setUnreadCount(0);
+			}
 		}
 	};
 
diff --git a/src/components/ChatBotContainer.tsx b/src/components/ChatBotContainer.tsx
index 68f24a6..eaa1f9e 100644
--- a/src/components/ChatBotContainer.tsx
+++ b/src/components/ChatBotContainer.tsx
@@ -48,7 +48,7 @@ const ChatBotContainer = ({
 	const { inputRef } = useBotRefsContext();
 
 	// handles chat window
-	const { chatScrollHeight,
+	const {
 		setChatScrollHeight,
 		viewportHeight,
 		viewportWidth,
@@ -146,7 +146,7 @@ const ChatBotContainer = ({
 					}
 					<div style={getChatWindowStyle()} className="rcb-chat-window">
 						{settings.general?.showHeader && <ChatBotHeader buttons={headerButtons}/>}
-						<ChatBotBody chatScrollHeight={chatScrollHeight} setChatScrollHeight={setChatScrollHeight}/>
+						<ChatBotBody setChatScrollHeight={setChatScrollHeight}/>
 						{settings.general?.showInputRow && <ChatBotInput buttons={chatInputButtons}/>}
 						{settings.general?.showFooter && <ChatBotFooter buttons={footerButtons}/>}
 					</div>
diff --git a/src/constants/internal/DefaultSettings.tsx b/src/constants/internal/DefaultSettings.tsx
index 22f963e..fd79059 100644
--- a/src/constants/internal/DefaultSettings.tsx
+++ b/src/constants/internal/DefaultSettings.tsx
@@ -154,7 +154,7 @@ export const DefaultSettings: Settings = {
 						background: "linear-gradient(to right, #42b0c5, #491d8d)",
 					}}
 				>
-					<ChatIcon style={{ width: "80%", height: "80%" }} />
+					<ChatIcon style={{ width: "80%", height: "80%", fill: "#fff" }} />
 				</div>
 				<span key={2} style={{fontWeight: "bold"}}> React ChatBotify</span>
 			</div>
diff --git a/src/hooks/internal/useBotEffectsInternal.tsx b/src/hooks/internal/useBotEffectsInternal.tsx
index a6dff32..c743c91 100644
--- a/src/hooks/internal/useBotEffectsInternal.tsx
+++ b/src/hooks/internal/useBotEffectsInternal.tsx
@@ -4,7 +4,6 @@ import ChatHistoryButton from "../../components/ChatHistoryButton/ChatHistoryBut
 import { preProcessBlock } from "../../services/BlockService/BlockService";
 import { saveChatHistory, setHistoryStorageValues } from "../../services/ChatHistoryService";
 import { createMessage } from "../../utils/messageBuilder";
-import { isChatBotVisible } from "../../utils/displayChecker";
 import { useIsDesktopInternal } from "./useIsDesktopInternal";
 import { useChatWindowInternal } from "./useChatWindowInternal";
 import { useNotificationInternal } from "./useNotificationsInternal";
@@ -39,7 +38,7 @@ export const useBotEffectsInternal = () => {
 		removeMessage,
 		streamMessage,
 		messages,
-		setMessages
+		replaceMessages,
 	} = useMessagesInternal();
 
 	// handles paths
@@ -50,8 +49,8 @@ export const useBotEffectsInternal = () => {
 
 	// handles bot states
 	const {
-		isChatWindowOpen,
 		isBotTyping,
+		isChatWindowOpen,
 		isScrolling,
 		hasFlowStarted,
 		setIsChatWindowOpen,
@@ -65,14 +64,14 @@ export const useBotEffectsInternal = () => {
 	} = useBotStatesContext();
 
 	// handles bot refs
-	const { flowRef, chatBodyRef, streamMessageMap, paramsInputRef, keepVoiceOnRef } = useBotRefsContext();
+	const { chatBodyRef, flowRef, streamMessageMap, paramsInputRef, keepVoiceOnRef } = useBotRefsContext();
 	const flow = flowRef.current as Flow;
 
 	// handles chat window
 	const { viewportHeight, setViewportHeight, setViewportWidth, openChat } = useChatWindowInternal();
 
 	// handles notifications
-	const { playNotificationSound, setUpNotifications } = useNotificationInternal();
+	const { setUpNotifications } = useNotificationInternal();
 
 	// handles user first interaction
 	const { handleFirstInteraction } = useFirstInteractionInternal();
@@ -115,6 +114,13 @@ export const useBotEffectsInternal = () => {
 		}, 1)
 	}, [])
 
+	// scrolls to bottom if bot is typing and user is not scrolling
+	useEffect(() => {
+		if (!isScrolling && chatBodyRef?.current) {
+			chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight;
+		}
+	}, [isBotTyping])
+
 	// renders chat history button if enabled and triggers update if chat history configurations change
 	useEffect(() => {
 		if (settings.chatHistory?.disabled) {
@@ -125,7 +131,7 @@ export const useBotEffectsInternal = () => {
 			if (chatHistory != null) {
 				// note: must always render this button even if autoload (chat history logic relies on system message)
 				const messageContent = createMessage(<ChatHistoryButton/>, "system");
-				setMessages([messageContent]);
+				replaceMessages([messageContent]);
 				if (settings.chatHistory?.autoLoad) {
 					showChatHistory();
 				}
@@ -168,29 +174,6 @@ export const useBotEffectsInternal = () => {
 		});
 	}, [isDesktop]);
 
-	// triggers saving of chat history and checks for notifications
-	useEffect(() => {
-		saveChatHistory(messages);
-
-		// if messages are empty or chatbot is open and user is not scrolling, no need to notify
-		if (messages.length === 0 || isChatWindowOpen && !isScrolling) {
-			return;
-		}
-
-		// if chatbot is embedded and visible, no need to notify
-		if (settings.general?.embedded && isChatBotVisible(chatBodyRef.current as HTMLDivElement) || isBotTyping) {
-			return;
-		}
-
-		const lastMessage = messages[messages.length - 1];
-		// if message is sent by user or is bot typing or bot is embedded, return
-		if (!lastMessage || lastMessage.sender === "user") {
-			return;
-		}
-
-		playNotificationSound();
-	}, [messages.length]);
-
 	// handles scrolling/resizing window on mobile devices
 	useEffect(() => {
 		if (isDesktop) {
diff --git a/src/hooks/internal/useButtonsInternal.ts b/src/hooks/internal/useButtonsInternal.ts
index 02ac31e..3d2ed81 100644
--- a/src/hooks/internal/useButtonsInternal.ts
+++ b/src/hooks/internal/useButtonsInternal.ts
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useMemo } from "react";
 
 import {
 	createAudioButton,
@@ -20,11 +20,6 @@ export const useButtonInternal = () => {
 	// handles settings
 	const { settings } = useSettingsContext();
 
-	// buttons to show in header, chat input and footer
-	const [headerButtons, setHeaderButtons] = useState<Array<JSX.Element>>([]);
-	const [chatInputButtons, setChatInputButtons] = useState<Array<JSX.Element>>([]);
-	const [footerButtons, setFooterButtons] = useState<Array<JSX.Element>>([]);
-	
 	// handles the rendering of buttons
 	const staticButtonComponentMap = useMemo(() => ({
 		[Button.CLOSE_CHAT_BUTTON]: () => createCloseChatButton(),
@@ -36,13 +31,15 @@ export const useButtonInternal = () => {
 		[Button.VOICE_MESSAGE_BUTTON]: () => createVoiceButton()
 	}), []);
 
-	// sets buttons to be shown
-	useEffect(() => {
-		const buttonConfig = getButtonConfig(settings, staticButtonComponentMap);
-		setHeaderButtons(buttonConfig.header);
-		setChatInputButtons(buttonConfig.chatInput);
-		setFooterButtons(buttonConfig.footer);
+	// computes button configurations whenever settings or map changes
+	const { header, chatInput, footer } = useMemo(() => {
+		return getButtonConfig(settings, staticButtonComponentMap);
 	}, [settings, staticButtonComponentMap]);
 
-	return {headerButtons, chatInputButtons, footerButtons};
+	// memoizes creation of jsx elements
+	const headerButtons = useMemo(() => header, [header]);
+	const chatInputButtons = useMemo(() => chatInput, [chatInput]);
+	const footerButtons = useMemo(() => footer, [footer]);
+
+	return { headerButtons, chatInputButtons, footerButtons };
 };
diff --git a/src/hooks/internal/useChatHistoryInternal.ts b/src/hooks/internal/useChatHistoryInternal.ts
index da3022b..bdebf22 100644
--- a/src/hooks/internal/useChatHistoryInternal.ts
+++ b/src/hooks/internal/useChatHistoryInternal.ts
@@ -1,7 +1,9 @@
 import { useCallback } from "react";
 
-import { useRcbEventInternal } from "./useRcbEventInternal";
 import { getHistoryMessages, loadChatHistory } from "../../services/ChatHistoryService";
+import { useRcbEventInternal } from "./useRcbEventInternal";
+import { useChatWindowInternal } from "./useChatWindowInternal";
+import { useBotRefsContext } from "../../context/BotRefsContext";
 import { useMessagesContext } from "../../context/MessagesContext";
 import { useSettingsContext } from "../../context/SettingsContext";
 import { useStylesContext } from "../../context/StylesContext";
@@ -27,9 +29,15 @@ export const useChatHistoryInternal = () => {
 		setIsLoadingChatHistory,
 	} = useBotStatesContext();
 
+	// handles bot refs
+	const { chatBodyRef } = useBotRefsContext();
+
 	// handles rcb events
 	const { callRcbEvent } = useRcbEventInternal();
 
+	// handles chat window
+	const { chatScrollHeight } = useChatWindowInternal();
+
 	/**
 	 * Loads and shows chat history in the chat window.
 	 * 
@@ -49,7 +57,9 @@ export const useChatHistoryInternal = () => {
 			}
 		}
 		setIsLoadingChatHistory(true);
-		loadChatHistory(settings, styles, chatHistory, setMessages);
+		loadChatHistory(settings, styles, chatHistory, setMessages,
+			chatBodyRef, chatScrollHeight, setIsLoadingChatHistory
+		);
 	}, [settings, styles, setMessages]);
 
 	return { isLoadingChatHistory, setIsLoadingChatHistory, showChatHistory };
diff --git a/src/hooks/internal/useFlowInternal.ts b/src/hooks/internal/useFlowInternal.ts
index ae7f687..645df54 100644
--- a/src/hooks/internal/useFlowInternal.ts
+++ b/src/hooks/internal/useFlowInternal.ts
@@ -1,21 +1,21 @@
+import { useMessagesInternal } from "./useMessagesInternal";
+import { usePathsInternal } from "./usePathsInternal";
+import { useToastsInternal } from "./useToastsInternal";
 import { useBotRefsContext } from "../../context/BotRefsContext";
 import { useBotStatesContext } from "../../context/BotStatesContext";
-import { useMessagesContext } from "../../context/MessagesContext";
-import { usePathsContext } from "../../context/PathsContext";
-import { useToastsContext } from "../../context/ToastsContext";
 
 /**
  * Internal custom hook for managing flow.
  */
 export const useFlowInternal = () => {
 	// handles messages
-	const { setMessages } = useMessagesContext();
+	const { replaceMessages } = useMessagesInternal();
 	
 	// handles paths
-	const { setPaths } = usePathsContext();
+	const { replacePaths } = usePathsInternal();
 
 	// handles toasts
-	const { setToasts } = useToastsContext();
+	const { replaceToasts } = useToastsInternal();
 
 	// handles bot states
 	const { hasFlowStarted } = useBotStatesContext();
@@ -27,9 +27,9 @@ export const useFlowInternal = () => {
 	 * Restarts the conversation flow for the chatbot.
 	 */
 	const restartFlow = () => {
-		setMessages([]);
-		setToasts([]);
-		setPaths(["start"]);
+		replaceMessages([]);
+		replaceToasts([]);
+		replacePaths(["start"]);
 	}
 	
 	/**
diff --git a/src/hooks/internal/useIsDesktopInternal.ts b/src/hooks/internal/useIsDesktopInternal.ts
index 9d1279d..cd1fe2a 100644
--- a/src/hooks/internal/useIsDesktopInternal.ts
+++ b/src/hooks/internal/useIsDesktopInternal.ts
@@ -5,7 +5,12 @@ export const useIsDesktopInternal = () => {
 		if (typeof window === 'undefined' || !window.navigator) {
 			return false; // Default to false if running on server-side
 		}
-		return !(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
+		const userAgent = navigator.userAgent;
+		const isNotMobileUA = !(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent));
+		const isWideEnough = window.innerWidth >= 768;
+
+		// device is desktop if it is not a mobile agent and if the width is wide enough
+		return isNotMobileUA && isWideEnough;
 	}, []);
 
 	// boolean indicating if user is on desktop (otherwise treated as on mobile)
diff --git a/src/hooks/internal/useMessagesInternal.ts b/src/hooks/internal/useMessagesInternal.ts
index e9bd535..986c9da 100644
--- a/src/hooks/internal/useMessagesInternal.ts
+++ b/src/hooks/internal/useMessagesInternal.ts
@@ -4,6 +4,8 @@ import { processAudio } from "../../services/AudioService";
 import { saveChatHistory } from "../../services/ChatHistoryService";
 import { createMessage } from "../../utils/messageBuilder";
 import { parseMarkupMessage } from "../../utils/markupParser";
+import { isChatBotVisible } from "../../utils/displayChecker";
+import { useNotificationInternal } from "./useNotificationsInternal";
 import { useRcbEventInternal } from "./useRcbEventInternal";
 import { useSettingsContext } from "../../context/SettingsContext";
 import { useMessagesContext } from "../../context/MessagesContext";
@@ -23,13 +25,22 @@ export const useMessagesInternal = () => {
 	const { messages, setMessages } = useMessagesContext();
 
 	// handles bot states
-	const { audioToggledOn, isChatWindowOpen, setIsBotTyping, setUnreadCount } = useBotStatesContext();
+	const {
+		audioToggledOn,
+		isChatWindowOpen,
+		isScrolling,
+		setIsBotTyping,
+		setUnreadCount
+	} = useBotStatesContext();
 
 	// handles bot refs
-	const { streamMessageMap } = useBotRefsContext();
+	const { streamMessageMap, chatBodyRef } = useBotRefsContext();
 
 	// handles rcb events
 	const { callRcbEvent } = useRcbEventInternal();
+	
+	// handles notification
+	const { playNotificationSound } = useNotificationInternal();
 
 	/**
 	 * Simulates the streaming of a message from the bot.
@@ -43,7 +54,11 @@ export const useMessagesInternal = () => {
 		setIsBotTyping(false);
 
 		// set an initial empty message to be used for streaming
-		setMessages(prevMessages => [...prevMessages, message]);
+		setMessages(prevMessages => {
+			const updatedMessages = [...prevMessages, message];
+			handlePostMessagesUpdate(updatedMessages);
+			return updatedMessages;
+		});
 		streamMessageMap.current.set("bot", message.id);
 
 		// initialize default message to empty with stream index position 0
@@ -79,6 +94,7 @@ export const useMessagesInternal = () => {
 							break;
 						}
 					}
+					handlePostMessagesUpdate(updatedMessages);
 					return updatedMessages;
 				});
 			}, streamSpeed);
@@ -129,7 +145,11 @@ export const useMessagesInternal = () => {
 			const streamSpeed = settings.userBubble?.streamSpeed as number;
 			await simulateStream(message, streamSpeed, useMarkup);
 		} else {
-			setMessages((prevMessages) => [...prevMessages, message]);
+			setMessages((prevMessages) => {
+				const updatedMessages = [...prevMessages, message];
+				handlePostMessagesUpdate(updatedMessages);
+				return updatedMessages;
+			});
 		}
 
 		// handles post-message inject event
@@ -160,7 +180,11 @@ export const useMessagesInternal = () => {
 			}
 		}
 
-		setMessages((prevMessages) => prevMessages.filter(message => message.id !== messageId));
+		setMessages((prevMessages) => {
+			const updatedMessages = prevMessages.filter(message => message.id !== messageId);
+			handlePostMessagesUpdate(updatedMessages);
+			return updatedMessages;
+		});
 		setUnreadCount((prevCount) => Math.max(prevCount - 1, 0));
 		return messageId;
 	}, [callRcbEvent, messages, settings.event?.rcbRemoveMessage]);
@@ -185,7 +209,11 @@ export const useMessagesInternal = () => {
 			}
 
 			setIsBotTyping(false);
-			setMessages((prevMessages) => [...prevMessages, message]);
+			setMessages((prevMessages) => {
+				const updatedMessages = [...prevMessages, message];
+				handlePostMessagesUpdate(updatedMessages);
+				return [...prevMessages, message];
+			});
 			setUnreadCount(prev => prev + 1);
 			streamMessageMap.current.set(sender, message.id);
 			return message.id;
@@ -212,7 +240,7 @@ export const useMessagesInternal = () => {
 					break;
 				}
 			}
-		
+			handlePostMessagesUpdate(updatedMessages)
 			return updatedMessages;
 		});
 		return streamMessageMap.current.get(sender) ?? null;
@@ -255,12 +283,64 @@ export const useMessagesInternal = () => {
 		return true;
 	}, [callRcbEvent, messages, settings.event?.rcbStopStreamMessage, streamMessageMap])
 
+	/**
+	 * Replaces (overwrites entirely) the current messages with the new messages.
+	 */
+	const replaceMessages = (newMessages: Array<Message>) => {
+		handlePostMessagesUpdate(newMessages);
+		setMessages(newMessages);
+	}
+
+	/**
+	 * Handles post messages updates such as saving chat history, scrolling to bottom
+	 * and playing notification sound.
+	 */
+	const handlePostMessagesUpdate = (updatedMessages: Array<Message>) => {
+		saveChatHistory(updatedMessages);
+
+		// tracks if notification should be played
+		let shouldNotify = true;
+
+		// if messages are empty or chatbot is open and user is not scrolling, no need to notify
+		if (updatedMessages.length === 0 || isChatWindowOpen && !isScrolling) {
+			shouldNotify = false;
+		}
+
+		// if chatbot is embedded and visible, no need to notify
+		if (settings.general?.embedded && isChatBotVisible(chatBodyRef.current as HTMLDivElement)) {
+			shouldNotify = false;
+		}
+
+		const lastMessage = updatedMessages[updatedMessages.length - 1];
+		// if message is sent by user or is bot typing or bot is embedded, return
+		if (!lastMessage || lastMessage.sender === "user") {
+			shouldNotify = false;
+		}
+
+		if (shouldNotify) {
+			playNotificationSound();
+		}
+
+		// if auto scroll enabled or is not scrolling, then scroll to bottom
+		if (settings.chatWindow?.autoJumpToBottom || !isScrolling) {
+			// defer update to next event loop, handles edge case where messages are sent too fast
+			// and the scrolling does not properly reach the bottom
+			setTimeout(() => {
+				if (!chatBodyRef.current) {
+					return;
+				}
+
+				chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight;
+			}, 1)
+		}
+	}
+
 	return {
 		endStreamMessage,
 		injectMessage,
 		removeMessage,
 		streamMessage,
 		messages,
-		setMessages
+		replaceMessages
 	};
 };
diff --git a/src/hooks/internal/usePathsInternal.ts b/src/hooks/internal/usePathsInternal.ts
index c2291a0..6f34efc 100644
--- a/src/hooks/internal/usePathsInternal.ts
+++ b/src/hooks/internal/usePathsInternal.ts
@@ -74,6 +74,13 @@ export const usePathsInternal = () => {
 		return true;
 	}, [paths, setPaths, settings]);
 
+	/**
+	 * Replaces (overwrites entirely) the current paths with the new paths.
+	 */
+	const replacePaths = (newPaths: Array<string>) => {
+		setPaths(newPaths);
+	}
+
 	return {
 		getCurrPath,
 		getPrevPath,
@@ -81,6 +88,6 @@ export const usePathsInternal = () => {
 		blockAllowsAttachment,
 		setBlockAllowsAttachment,
 		paths,
-		setPaths
+		replacePaths
 	};
 };
diff --git a/src/hooks/internal/useSettingsInternal.ts b/src/hooks/internal/useSettingsInternal.ts
index c1231f8..d5a7458 100644
--- a/src/hooks/internal/useSettingsInternal.ts
+++ b/src/hooks/internal/useSettingsInternal.ts
@@ -9,18 +9,25 @@ export const useSettingsInternal = () => {
 	// handles settings
 	const { settings, setSettings } = useSettingsContext();
 
-    /**
-     * Updates the settings for the chatbot.
-     *
-     * @param fields fields to update
-     */
-    const updateSettings = (fields: object) => {
-        setSettings(deepClone(getCombinedConfig(fields, settings) as Settings));
-    }
+	/**
+	 * Updates the settings for the chatbot.
+	 *
+	 * @param fields fields to update
+	 */
+	const updateSettings = (fields: object) => {
+		setSettings(deepClone(getCombinedConfig(fields, settings) as Settings));
+	}
+
+	/**
+	 * Replaces (overwrites entirely) the current settings with the new settings.
+	 */
+	const replaceSettings = (newSettings: Settings) => {
+		setSettings(newSettings);
+	}
 
 	return {
 		settings,
-		setSettings,
-        updateSettings
+		replaceSettings,
+		updateSettings
 	};
 };
diff --git a/src/hooks/internal/useStylesInternal.ts b/src/hooks/internal/useStylesInternal.ts
index 88a0d45..78d5a0c 100644
--- a/src/hooks/internal/useStylesInternal.ts
+++ b/src/hooks/internal/useStylesInternal.ts
@@ -9,18 +9,25 @@ export const useStylesInternal = () => {
 	// handles styles
 	const { styles, setStyles } = useStylesContext();
 
-    /**
-     * Updates the styles for the chatbot.
-     *
-     * @param fields fields to update
-     */
-    const updateStyles = (fields: object) => {
-        setStyles(deepClone(getCombinedConfig(fields, styles) as Styles));
-    }
+	/**
+	 * Updates the styles for the chatbot.
+	 *
+	 * @param fields fields to update
+	 */
+	const updateStyles = (fields: object) => {
+		setStyles(deepClone(getCombinedConfig(fields, styles) as Styles));
+	}
+
+	/**
+	 * Replaces (overwrites entirely) the current styles with the new styles.
+	 */
+	const replaceStyles = (newStyles: Styles) => {
+		setStyles(newStyles);
+	}
 
 	return {
 		styles,
-		setStyles,
-        updateStyles
+		replaceStyles,
+		updateStyles
 	};
 };
diff --git a/src/hooks/internal/useToastsInternal.ts b/src/hooks/internal/useToastsInternal.ts
index cb6aa46..49819ca 100644
--- a/src/hooks/internal/useToastsInternal.ts
+++ b/src/hooks/internal/useToastsInternal.ts
@@ -98,10 +98,17 @@ export const useToastsInternal = () => {
 		return id;
 	}, [callRcbEvent, setToasts]);
 
+	/**
+	 * Replaces (overwrites entirely) the current toasts with the new toasts.
+	 */
+	const replaceToasts = (newToasts: Array<Toast>) => {
+		setToasts(newToasts);
+	}
+
 	return {
 		showToast,
 		dismissToast,
 		toasts,
-		setToasts
+		replaceToasts
 	};
 };
diff --git a/src/hooks/useMessages.ts b/src/hooks/useMessages.ts
index 02f453b..733cb6b 100644
--- a/src/hooks/useMessages.ts
+++ b/src/hooks/useMessages.ts
@@ -11,7 +11,7 @@ export const useMessages = () => {
 		removeMessage,
 		streamMessage,
 		messages,
-		setMessages
+		replaceMessages,
 	} = useMessagesInternal();
 
 	return {
@@ -20,6 +20,6 @@ export const useMessages = () => {
 		removeMessage,
 		streamMessage,
 		messages,
-		setMessages,
+		replaceMessages,
 	};
 };
diff --git a/src/hooks/usePaths.ts b/src/hooks/usePaths.ts
index 3867e44..ac49bc7 100644
--- a/src/hooks/usePaths.ts
+++ b/src/hooks/usePaths.ts
@@ -10,7 +10,7 @@ export const usePaths = () => {
 		getPrevPath,
 		goToPath,
 		paths,
-		setPaths
+		replacePaths,
 	} = usePathsInternal();
 
 	return {
@@ -18,6 +18,6 @@ export const usePaths = () => {
 		getPrevPath,
 		goToPath,
 		paths,
-		setPaths
+		replacePaths,
 	};
 };
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index 317f4b9..e3a8626 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -5,11 +5,11 @@ import { useSettingsInternal } from "./internal/useSettingsInternal";
  */
 export const useSettings = () => {
 	// handles settings
-	const { settings, setSettings, updateSettings } = useSettingsInternal();
+	const { settings, replaceSettings, updateSettings } = useSettingsInternal();
 
 	return {
 		settings,
-		setSettings,
+		replaceSettings,
 		updateSettings
 	};
 };
diff --git a/src/hooks/useStyles.ts b/src/hooks/useStyles.ts
index 5b5943f..d61dd96 100644
--- a/src/hooks/useStyles.ts
+++ b/src/hooks/useStyles.ts
@@ -5,11 +5,11 @@ import { useStylesInternal } from "./internal/useStylesInternal";
  */
 export const useStyles = () => {
 	// handles styles
-	const { styles, setStyles, updateStyles } = useStylesInternal();
+	const { styles, replaceStyles, updateStyles } = useStylesInternal();
 
 	return {
 		styles,
-		setStyles,
+		replaceStyles,
 		updateStyles
 	};
 };
diff --git a/src/hooks/useToasts.ts b/src/hooks/useToasts.ts
index 0820508..f54502e 100644
--- a/src/hooks/useToasts.ts
+++ b/src/hooks/useToasts.ts
@@ -5,12 +5,12 @@ import { useToastsInternal } from "./internal/useToastsInternal";
  */
 export const useToasts = () => {
 	// handles toasts
-	const { showToast, dismissToast, toasts, setToasts } = useToastsInternal();
+	const { showToast, dismissToast, toasts, replaceToasts } = useToastsInternal();
 
 	return {
 		showToast,
 		dismissToast,
 		toasts,
-		setToasts
+		replaceToasts
 	};
 };
diff --git a/src/services/ChatHistoryService.tsx b/src/services/ChatHistoryService.tsx
index 6c39834..454ac59 100644
--- a/src/services/ChatHistoryService.tsx
+++ b/src/services/ChatHistoryService.tsx
@@ -124,11 +124,13 @@ const parseMessageToString = (message: Message) => {
  * @param styles styles provided to the bot
  * @param chatHistory chat history to show
  * @param setMessages setter for updating messages
- * @param prevTextAreaDisabled boolean indicating if text area was previously disabled
- * @param setTextAreaDisabled setter for enabling/disabling user text area
+ * @param chatBodyRef reference to the chat body
+ * @param chatScrollHeight current chat scroll height
+ * @param setIsLoadingChatHistory setter for whether chat history is loading
  */
 const loadChatHistory = (settings: Settings, styles: Styles, chatHistory: Message[],
-	setMessages: Dispatch<SetStateAction<Message[]>>) => {
+	setMessages: Dispatch<SetStateAction<Message[]>>, chatBodyRef: React.RefObject<HTMLDivElement | null>,
+	chatScrollHeight: number, setIsLoadingChatHistory: Dispatch<SetStateAction<boolean>>) => {
 
 	historyLoaded = true;
 	if (chatHistory != null) {
@@ -160,6 +162,17 @@ const loadChatHistory = (settings: Settings, styles: Styles, chatHistory: Messag
 					return [...parsedMessages, lineBreakMessage, ...prevMessages];
 				});
 			}, 500)
+
+			// slight delay afterwards to maintain scroll position
+			setTimeout(() => {
+				if (!chatBodyRef.current) {
+					return;
+				}
+				const { scrollHeight } = chatBodyRef.current;
+				const scrollDifference = scrollHeight - chatScrollHeight;
+				chatBodyRef.current.scrollTop = chatBodyRef.current.scrollTop + scrollDifference;
+				setIsLoadingChatHistory(false);
+			}, 510)
 		} catch {
 			// remove chat history on error (to address corrupted storage values)
 			localStorage.removeItem(settings.chatHistory?.storageKey as string);


Test patch:
diff --git a/__tests__/context/MessagesContext.test.tsx b/__tests__/context/MessagesContext.test.tsx
new file mode 100644
index 0000000..37b6ad7
--- /dev/null
+++ b/__tests__/context/MessagesContext.test.tsx
@@ -0,0 +1,114 @@
+import React from 'react'
+import { expect } from '@jest/globals'
+import { act, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom/jest-globals'
+
+import {
+	useMessagesContext,
+	MessagesProvider,
+} from '../../src/context/MessagesContext'
+
+const TestComponent = () => {
+	const { messages, setMessages } = useMessagesContext()
+
+	const handleAddMessage = () => {
+		setMessages([
+			...messages,
+			{
+				id: '1',
+				content: 'Hello World!',
+				sender: 'user1',
+				type: 'message',
+				timestamp: new Date().toUTCString(),
+			},
+		])
+	}
+
+	const handleClearMessage = () => {
+		setMessages([])
+	}
+
+	return (
+		<div>
+			<p data-testid="messages">
+				Messages: {messages.map((message) => message.content).join(', ')}
+			</p>
+			<p data-testid="messagesCount">Messages Count: {messages.length}</p>
+
+			<button onClick={handleAddMessage}>Add Message</button>
+			<button onClick={handleClearMessage}>Clear Message</button>
+		</div>
+	)
+}
+
+describe('MessagesContext', () => {
+	it('provides the correct default values', () => {
+		render(
+			<MessagesProvider>
+				<TestComponent />
+			</MessagesProvider>
+		)
+
+		expect(screen.getByTestId('messages')).toHaveTextContent(`Messages:`)
+		expect(screen.getByTestId('messagesCount')).toHaveTextContent(
+			`Messages Count: 0`
+		)
+	})
+
+	it('allows adding messages in the context', () => {
+		render(
+			<MessagesProvider>
+				<TestComponent />
+			</MessagesProvider>
+		)
+
+		const addMessageBtn = screen.getByText('Add Message')
+
+		act(() => {
+			addMessageBtn.click()
+		})
+
+		expect(screen.getByTestId('messages')).toHaveTextContent(
+			`Messages: Hello World!`
+		)
+		expect(screen.getByTestId('messagesCount')).toHaveTextContent(
+			`Messages Count: 1`
+		)
+
+		act(() => {
+			addMessageBtn.click()
+		})
+
+		expect(screen.getByTestId('messagesCount')).toHaveTextContent(
+			`Messages Count: 2`
+		)
+	})
+
+	it('allows updating messages in the context', () => {
+		render(
+			<MessagesProvider>
+				<TestComponent />
+			</MessagesProvider>
+		)
+
+		const clearMessageBtn = screen.getByText('Clear Message')
+		const addMessageBtn = screen.getByText('Add Message')
+
+		act(() => {
+			clearMessageBtn.click()
+		})
+
+		expect(screen.getByTestId('messages')).toHaveTextContent(`Messages:`)
+		expect(screen.getByTestId('messagesCount')).toHaveTextContent(
+			`Messages Count: 0`
+		)
+
+		act(() => {
+			addMessageBtn.click()
+		})
+
+		expect(screen.getByTestId('messagesCount')).toHaveTextContent(
+			`Messages Count: 1`
+		)
+	})
+})
diff --git a/__tests__/hooks/internal/useChatHistoryInternal.test.ts b/__tests__/hooks/internal/useChatHistoryInternal.test.ts
index 3bace45..de7beff 100644
--- a/__tests__/hooks/internal/useChatHistoryInternal.test.ts
+++ b/__tests__/hooks/internal/useChatHistoryInternal.test.ts
@@ -90,6 +90,9 @@ describe("useChatHistoryInternal Hook", () => {
 			expect.any(Object),
 			initialChatHistory,
 			expect.any(Function),
+			expect.any(Object),
+			expect.any(Number),
+			expect.any(Function),
 		);
 
 		// checks if history is being loaded
diff --git a/__tests__/hooks/internal/useMessagesInternal.test.ts b/__tests__/hooks/internal/useMessagesInternal.test.ts
new file mode 100644
index 0000000..51fa758
--- /dev/null
+++ b/__tests__/hooks/internal/useMessagesInternal.test.ts
@@ -0,0 +1,122 @@
+import { renderHook } from "@testing-library/react";
+import { act } from "react";
+import { useMessagesInternal } from "../../../src/hooks/internal/useMessagesInternal";
+import { useSettingsContext } from "../../../src/context/SettingsContext";
+import { useMessagesContext } from "../../../src/context/MessagesContext";
+import { useBotStatesContext } from "../../../src/context/BotStatesContext";
+import { useBotRefsContext } from "../../../src/context/BotRefsContext";
+import { useRcbEventInternal } from "../../../src/hooks/internal/useRcbEventInternal";
+import { Message } from "../../../src/types/Message";
+
+jest.mock("../../../src/context/SettingsContext");
+jest.mock("../../../src/context/MessagesContext");
+jest.mock("../../../src/context/BotStatesContext");
+jest.mock("../../../src/context/BotRefsContext");
+jest.mock("../../../src/hooks/internal/useRcbEventInternal");
+jest.mock("../../../src/services/AudioService");
+jest.mock("../../../src/services/ChatHistoryService");
+
+describe("useMessagesInternal", () => {
+  const mockSetMessages = jest.fn();
+  const mockSetIsBotTyping = jest.fn();
+  const mockSetUnreadCount = jest.fn();
+  const mockCallRcbEvent = jest.fn();
+  const mockStreamMessageMap = { current: new Map() };
+  const mockMessages: Message[] = [];
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (useSettingsContext as jest.Mock).mockReturnValue({
+      settings: {
+        botBubble: { dangerouslySetInnerHtml: false, simStream: false },
+        userBubble: { dangerouslySetInnerHtml: false, simStream: false },
+        event: {},
+      },
+    });
+    (useMessagesContext as jest.Mock).mockReturnValue({
+      messages: mockMessages,
+      setMessages: mockSetMessages,
+    });
+    (useBotStatesContext as jest.Mock).mockReturnValue({
+      audioToggledOn: false,
+      isChatWindowOpen: true,
+      setIsBotTyping: mockSetIsBotTyping,
+      setUnreadCount: mockSetUnreadCount,
+    });
+    (useBotRefsContext as jest.Mock).mockReturnValue({
+      streamMessageMap: mockStreamMessageMap,
+    });
+    (useRcbEventInternal as jest.Mock).mockReturnValue({
+      callRcbEvent: mockCallRcbEvent,
+    });
+  });
+
+  it("should return expected functions and values", () => {
+    const { result } = renderHook(() => useMessagesInternal());
+
+    expect(result.current).toHaveProperty("endStreamMessage");
+    expect(result.current).toHaveProperty("injectMessage");
+    expect(result.current).toHaveProperty("removeMessage");
+    expect(result.current).toHaveProperty("streamMessage");
+    expect(result.current).toHaveProperty("messages");
+    expect(result.current).toHaveProperty("setMessages");
+  });
+
+  it("should inject a message correctly", async () => {
+    const { result } = renderHook(() => useMessagesInternal());
+
+    await act(async () => {
+      const messageId = await result.current.injectMessage("Test message", "bot");
+      expect(messageId).toBeTruthy();
+    });
+
+    expect(mockSetMessages).toHaveBeenCalled();
+    expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it("should remove a message correctly", async () => {
+    const mockMessageId = "test-id";
+    const mockMessage: Message = { id: mockMessageId, content: "Test", sender: "bot", type: "text",
+        timestamp: String(Date.now()) };
+    (useMessagesContext as jest.Mock).mockReturnValue({
+      messages: [mockMessage],
+      setMessages: mockSetMessages,
+    });
+
+    const { result } = renderHook(() => useMessagesInternal());
+
+    await act(async () => {
+      const removedId = await result.current.removeMessage(mockMessageId);
+      expect(removedId).toBe(mockMessageId);
+    });
+
+    expect(mockSetMessages).toHaveBeenCalled();
+    expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it("should stream a message correctly", async () => {
+    const { result } = renderHook(() => useMessagesInternal());
+
+    await act(async () => {
+      const messageId = await result.current.streamMessage("Test stream", "bot");
+      expect(messageId).toBeTruthy();
+    });
+
+    expect(mockSetMessages).toHaveBeenCalled();
+    expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function));
+    expect(mockStreamMessageMap.current.has("bot")).toBeTruthy();
+  });
+
+  it("should end stream message correctly", async () => {
+    mockStreamMessageMap.current.set("bot", "test-id");
+    const { result } = renderHook(() => useMessagesInternal());
+
+    await act(async () => {
+      const success = await result.current.endStreamMessage("bot");
+      expect(success).toBeTruthy();
+    });
+
+    expect(mockStreamMessageMap.current.has("bot")).toBeFalsy();
+  });
+
+});
\ No newline at end of file
diff --git a/__tests__/hooks/internal/useSettingsInternal.test.ts b/__tests__/hooks/internal/useSettingsInternal.test.ts
index 79860b5..84c247e 100644
--- a/__tests__/hooks/internal/useSettingsInternal.test.ts
+++ b/__tests__/hooks/internal/useSettingsInternal.test.ts
@@ -17,6 +17,7 @@ jest.mock("../../../src/utils/configParser", () => ({
 
 describe("useSettingsInternal", () => {
     const mockSetSettings = jest.fn();
+    const mockReplaceSettings = jest.fn();
     const mockSettings: Settings = { general: { primaryColor: "red" } };
 
     beforeEach(() => {
@@ -24,15 +25,17 @@ describe("useSettingsInternal", () => {
         (useSettingsContext as jest.Mock).mockReturnValue({
             settings: mockSettings,
             setSettings: mockSetSettings,
+            replaceSettings: mockReplaceSettings,
         });
     });
 
-    it("should return settings, setSettings and updateSettings method", () => {
+    it("should return settings, replaceSettings and updateSettings method", () => {
         const { result } = renderHook(() => useSettingsInternal());
 
         expect(result.current.settings).toEqual(mockSettings);
-        expect(result.current.setSettings).toBe(mockSetSettings);
+        expect(result.current.replaceSettings).toBeDefined();
         expect(result.current.updateSettings).toBeDefined();
+        expect(typeof result.current.replaceSettings).toBe("function");
         expect(typeof result.current.updateSettings).toBe("function");
     });
 
diff --git a/__tests__/services/BlockService/CheckboxProcessor.test.tsx b/__tests__/services/BlockService/CheckboxProcessor.test.tsx
index db52fee..96a9dfc 100644
--- a/__tests__/services/BlockService/CheckboxProcessor.test.tsx
+++ b/__tests__/services/BlockService/CheckboxProcessor.test.tsx
@@ -74,8 +74,6 @@ describe("processCheckboxes", () => {
 		const expectedCheckboxes = { items: ["Item 1", "Item 2"], min: 2, max: 2 };
 		expect(mockParams.injectMessage).toHaveBeenCalled();
 		const [content] = (mockParams.injectMessage as jest.Mock).mock.calls[0];
-		console.log("content.props.checkboxes:", content.props.checkboxes);
-		console.log("expectedCheckboxes:", expectedCheckboxes);
 
 		expect(content.props.checkboxes).toMatchObject(expectedCheckboxes);
 	});