Repository: tjtanjin/react-chatbotify (ts)

Issue:
[Task] Add unit test cases for useIsDesktopInternal 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 `useIsDesktopInternal` hook.

**Deliverable(s):**
New unit test cases that cover the testing of the `useIsDesktopInternal` 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: adds unit tests for the hook useIsDesktopInternal
#### Description

Please include a brief summary of the change and include the relevant issue(s).

Closes #239 (issue)

#### 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?

Please give a short overview/explanation on the approach taken to resolve the issue(s).

#### 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/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/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/PathsContext.test.tsx b/__tests__/context/PathsContext.test.tsx
new file mode 100644
index 0000000..269603a
--- /dev/null
+++ b/__tests__/context/PathsContext.test.tsx
@@ -0,0 +1,84 @@
+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 { usePathsContext, PathsProvider } from '../../src/context/PathsContext'
+
+const TestComponent = () => {
+	const { paths, setPaths } = usePathsContext()
+
+	const handleAddPath = () => {
+		setPaths([...paths, '/path/to/1'])
+	}
+
+	const handleClearPaths = () => {
+		setPaths([])
+	}
+
+	return (
+		<div>
+			<p data-testid="pathsCount">Paths Count: {paths.length}</p>
+
+			<button onClick={handleAddPath}>Add Path</button>
+			<button onClick={handleClearPaths}>Clear Paths</button>
+		</div>
+	)
+}
+
+describe('PathsContext', () => {
+	it('provides the correct default values', () => {
+		render(
+			<PathsProvider>
+				<TestComponent />
+			</PathsProvider>
+		)
+
+		expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 0`)
+	})
+
+	it('allows adding paths in the context', () => {
+		render(
+			<PathsProvider>
+				<TestComponent />
+			</PathsProvider>
+		)
+
+		const addPathBtn = screen.getByText('Add Path')
+
+		act(() => {
+			addPathBtn.click()
+		})
+
+		expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 1`)
+
+		act(() => {
+			addPathBtn.click()
+		})
+
+		expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 2`)
+	})
+
+	it('allows updating paths in the context', () => {
+		render(
+			<PathsProvider>
+				<TestComponent />
+			</PathsProvider>
+		)
+
+		const clearPathsBtn = screen.getByText('Clear Paths')
+		const addPathBtn = screen.getByText('Add Path')
+
+		act(() => {
+			clearPathsBtn.click()
+		})
+
+		expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 0`)
+
+		act(() => {
+			addPathBtn.click()
+		})
+
+		expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths 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/useIsDesktopInternal.test.ts b/__tests__/hooks/internal/useIsDesktopInternal.test.ts
new file mode 100644
index 0000000..9f9b0e0
--- /dev/null
+++ b/__tests__/hooks/internal/useIsDesktopInternal.test.ts
@@ -0,0 +1,76 @@
+import { renderHook } from '@testing-library/react';
+import { useIsDesktopInternal} from "../../../src/hooks/internal/useIsDesktopInternal";
+
+const originalNavigator = window.navigator;
+const originalInnerWidth = window.innerWidth;
+
+const mockWindowProperty = (property: string, value: any) => {
+    Object.defineProperty(window, property, {
+        configurable: true,
+        writable: true,
+        value,
+    });
+};
+
+describe('useIsDesktopInternal', () => {
+    afterEach(() => {
+        Object.defineProperty(window, 'navigator', {
+            configurable: true,
+            writable: true,
+            value: originalNavigator,
+        });
+        Object.defineProperty(window, 'innerWidth', {
+            configurable: true,
+            writable: true,
+            value: originalInnerWidth,
+        });
+    });
+
+    it('should return false when running on server-side (no window object)', () => {
+        mockWindowProperty('navigator', undefined);
+        mockWindowProperty('innerWidth', 0);
+
+        const { result } = renderHook(() => useIsDesktopInternal());
+        expect(result.current).toBe(false);
+    });
+
+    it('should return false for a mobile user agent', () => {
+        mockWindowProperty('navigator', {
+            userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15A372 Safari/604.1',
+        });
+        mockWindowProperty('innerWidth', 800);
+
+        const { result } = renderHook(() => useIsDesktopInternal());
+        expect(result.current).toBe(false);
+    });
+
+    it('should return true for a desktop user agent and wide screen', () => {
+        mockWindowProperty('navigator', {
+            userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+        });
+        mockWindowProperty('innerWidth', 1024);
+
+        const { result } = renderHook(() => useIsDesktopInternal());
+        expect(result.current).toBe(true);
+    });
+
+    it('should return false for a narrow screen, even on a desktop user agent', () => {
+        mockWindowProperty('navigator', {
+            userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+        });
+        mockWindowProperty('innerWidth', 500);
+
+        const { result } = renderHook(() => useIsDesktopInternal());
+        expect(result.current).toBe(false);
+    });
+
+    it('should return true if user agent is not mobile and window width is exactly 768', () => {
+        mockWindowProperty('navigator', {
+            userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
+        });
+        mockWindowProperty('innerWidth', 768);
+
+        const { result } = renderHook(() => useIsDesktopInternal());
+        expect(result.current).toBe(true);
+    });
+});
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);
 	});