diff --git a/src/components/ChatBox/ChatBox.scss b/src/components/ChatBox/ChatBox.scss
index 8403ca00..9a24f0c6 100644
--- a/src/components/ChatBox/ChatBox.scss
+++ b/src/components/ChatBox/ChatBox.scss
@@ -1,9 +1,14 @@
-.scroller {
- overflow-y: scroll;
- scrollbar-width: thin;
- width: 100%;
- opacity: 1;
- margin-right: 0;
+.xpert-chat-scroller {
+ position: relative;
+ flex: 1;
+
+ .messages-list {
+ overflow-y: scroll;
+ scrollbar-width: thin;
+ position: absolute;
+ inset: 0;
+ padding: 1rem 0;
+ }
&:after {
content: ""; /* Add an empty content area after the chat messages */
@@ -11,20 +16,38 @@
height: 0;
clear: both;
}
-}
-.loading {
- font-size: 13px;
- padding-left: 10px;
+ .loading {
+ font-size: 13px;
+ padding-left: 10px;
- &:after {
- overflow: hidden;
- display: inline-block;
- vertical-align: bottom;
- -webkit-animation: ellipsis steps(4,end) 900ms infinite;
- animation: ellipsis steps(4,end) 900ms infinite;
- content: "\2026"; /* ascii code for the ellipsis character */
- width: 0px;
+ &:after {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ -webkit-animation: ellipsis steps(4,end) 900ms infinite;
+ animation: ellipsis steps(4,end) 900ms infinite;
+ content: "\2026"; /* ascii code for the ellipsis character */
+ width: 0px;
+ }
+ }
+
+ .separator {
+ position: absolute;
+ z-index: 100;
+ height: 5px;
+ padding: 5px;
+ opacity: 0.3;
+
+ &--top {
+ inset: 0 0 auto 0;
+ background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent);
+ }
+
+ &--bottom {
+ inset: auto 0 0 0;
+ background: linear-gradient(0, rgba(0, 0, 0, 0.35), transparent);
+ }
}
}
diff --git a/src/components/ChatBox/index.jsx b/src/components/ChatBox/index.jsx
index df2eec73..719f93c9 100644
--- a/src/components/ChatBox/index.jsx
+++ b/src/components/ChatBox/index.jsx
@@ -1,9 +1,9 @@
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
-import PropTypes from 'prop-types';
import Message from '../Message';
import './ChatBox.scss';
import MessageDivider from '../MessageDivider';
+import { scrollToBottom } from '../../utils/scroll';
function isToday(date) {
const today = new Date();
@@ -15,33 +15,43 @@ function isToday(date) {
}
// container for all of the messages
-const ChatBox = ({ chatboxContainerRef }) => {
+const ChatBox = () => {
+ const firstRender = useRef(true);
+ const messageContainerRef = useRef();
+
const { messageList, apiIsLoading } = useSelector(state => state.learningAssistant);
const messagesBeforeToday = messageList.filter((m) => (!isToday(new Date(m.timestamp))));
const messagesToday = messageList.filter((m) => (isToday(new Date(m.timestamp))));
- // message divider should not display if no messages or if all messages sent today.
+ useEffect(() => {
+ if (firstRender.current) {
+ scrollToBottom(messageContainerRef);
+ firstRender.current = false;
+ return;
+ }
+
+ scrollToBottom(messageContainerRef, true);
+ }, [messageList.length]);
+
return (
-
- {messagesBeforeToday.map(({ role, content, timestamp }) => (
-
- ))}
- {(messageList.length !== 0 && messagesBeforeToday.length !== 0) && (
)}
- {messagesToday.map(({ role, content, timestamp }) => (
-
- ))}
- {apiIsLoading && (
+
+
+ {messagesBeforeToday.map(({ role, content, timestamp }) => (
+
+ ))}
+ {/* Message divider should not display if no messages or if all messages sent today. */}
+ {(messageList.length !== 0 && messagesBeforeToday.length !== 0) && (
)}
+ {messagesToday.map(({ role, content, timestamp }) => (
+
+ ))}
+ {apiIsLoading && (
Xpert is thinking
- )}
+ )}
+
+
+
);
};
-ChatBox.propTypes = {
- chatboxContainerRef: PropTypes.oneOfType([
- PropTypes.func,
- PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
- ]).isRequired,
-};
-
export default ChatBox;
diff --git a/src/components/ChatBox/index.test.jsx b/src/components/ChatBox/index.test.jsx
index 889ac6de..37733877 100644
--- a/src/components/ChatBox/index.test.jsx
+++ b/src/components/ChatBox/index.test.jsx
@@ -1,8 +1,8 @@
import React from 'react';
import { screen, act } from '@testing-library/react';
-
import { render as renderComponent } from '../../utils/utils.test';
-import { initialState } from '../../data/slice';
+import { initialState, setMessageList } from '../../data/slice';
+import { scrollToBottom } from '../../utils/scroll';
import ChatBox from '.';
@@ -16,6 +16,8 @@ const defaultProps = {
chatboxContainerRef: jest.fn(),
};
+jest.mock('../../utils/scroll');
+
const render = async (props = {}, sliceState = {}) => {
const componentProps = {
...defaultProps,
@@ -30,27 +32,34 @@ const render = async (props = {}, sliceState = {}) => {
},
},
};
- return act(async () => renderComponent(
-
,
- initState,
- ));
+
+ let handlers;
+ await act(async () => {
+ handlers = renderComponent(
+
,
+ initState,
+ );
+ });
+
+ return { ...handlers, initState, componentProps };
};
describe('
', () => {
- beforeEach(() => {
+ afterEach(() => {
jest.resetAllMocks();
});
- it('message divider does not appear when no messages', () => {
+
+ it('message divider does not appear when no messages', async () => {
const messageList = [];
const sliceState = {
messageList,
};
- render(undefined, sliceState);
+ await render(undefined, sliceState);
expect(screen.queryByText('Today')).not.toBeInTheDocument();
});
- it('message divider does not appear when all messages from today', () => {
+ it('message divider does not appear when all messages from today', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date - 60 },
@@ -59,14 +68,14 @@ describe('
', () => {
const sliceState = {
messageList,
};
- render(undefined, sliceState);
+ await render(undefined, sliceState);
expect(screen.queryByText('hi')).toBeInTheDocument();
expect(screen.queryByText('hello')).toBeInTheDocument();
expect(screen.queryByText('Today')).not.toBeInTheDocument();
});
- it('message divider shows when all messages from before today', () => {
+ it('message divider shows when all messages from before today', async () => {
const date = new Date();
const messageList = [
{ role: 'user', content: 'hi', timestamp: date.setDate(date.getDate() - 1) },
@@ -75,14 +84,14 @@ describe('
', () => {
const sliceState = {
messageList,
};
- render(undefined, sliceState);
+ await render(undefined, sliceState);
expect(screen.queryByText('hi')).toBeInTheDocument();
expect(screen.queryByText('hello')).toBeInTheDocument();
expect(screen.queryByText('Today')).toBeInTheDocument();
});
- it('correctly divides old and new messages', () => {
+ it('correctly divides old and new messages', async () => {
const today = new Date();
const messageList = [
{ role: 'user', content: 'Today yesterday', timestamp: today.setDate(today.getDate() - 1) },
@@ -91,7 +100,7 @@ describe('
', () => {
const sliceState = {
messageList,
};
- render(undefined, sliceState);
+ await render(undefined, sliceState);
const results = screen.getAllByText('Today', { exact: false });
expect(results.length).toBe(3);
@@ -99,4 +108,53 @@ describe('
', () => {
expect(results[1]).toHaveTextContent('Today');
expect(results[2]).toHaveTextContent('Today today');
});
+
+ it('scrolls to the last comment immediately when rendered', async () => {
+ const date = new Date();
+ const messageList = [
+ { role: 'user', content: 'hi', timestamp: date - 60 },
+ { role: 'user', content: 'hello', timestamp: date },
+ ];
+ const sliceState = {
+ messageList,
+ };
+
+ await act(() => render(undefined, sliceState));
+
+ const messagesContainer = screen.getByTestId('messages-container');
+
+ expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer });
+ });
+
+ it('scrolls to the last comment smoothly when adding messages', async () => {
+ const date = new Date();
+ const messageList = [
+ { role: 'user', content: 'hi', timestamp: date - 60 },
+ { role: 'user', content: 'hello', timestamp: date - 30 },
+ ];
+ const sliceState = {
+ messageList,
+ };
+
+ let store;
+
+ await act(async () => {
+ ({ store } = await render(undefined, sliceState));
+ });
+
+ const messagesContainer = screen.getByTestId('messages-container');
+
+ expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer });
+
+ act(() => {
+ store.dispatch(setMessageList({
+ messageList: [
+ ...messageList,
+ { role: 'user', content: 'New message', timestamp: +date },
+ ],
+ }));
+ });
+
+ expect(scrollToBottom).toHaveBeenCalledWith({ current: messagesContainer }, true);
+ });
});
diff --git a/src/components/Message/index.jsx b/src/components/Message/index.jsx
index dda2e725..16180eaa 100644
--- a/src/components/Message/index.jsx
+++ b/src/components/Message/index.jsx
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
const Message = ({ variant, message }) => (
diff --git a/src/components/MessageForm/MessageForm.scss b/src/components/MessageForm/MessageForm.scss
index b58e1e39..8c8aed5b 100644
--- a/src/components/MessageForm/MessageForm.scss
+++ b/src/components/MessageForm/MessageForm.scss
@@ -1,11 +1,22 @@
.message-form {
+ padding: 0.75rem 1.5rem;
+
.send-message-input {
.pgn__form-control-floating-label {
- color: #ADADAD;
+ color: $gray-300;
}
input {
border-radius: 1rem;
+ border: 1px solid $gray-200;
}
}
+
+ .pgn__form-control-decorator-group {
+ margin-inline-end: 0;
+ }
+
+ button {
+ color: $gray-400;
+ }
}
diff --git a/src/components/MessageForm/index.jsx b/src/components/MessageForm/index.jsx
index cae5616f..ef96649d 100644
--- a/src/components/MessageForm/index.jsx
+++ b/src/components/MessageForm/index.jsx
@@ -61,18 +61,16 @@ const MessageForm = ({ courseId, shouldAutofocus, unitId }) => {
return (
-
-
+
);
};
diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss
index 8d385146..0e6e4b42 100644
--- a/src/components/Sidebar/Sidebar.scss
+++ b/src/components/Sidebar/Sidebar.scss
@@ -9,8 +9,6 @@
background-color: white;
width: 100%;
max-width: 25rem;
- /* Add smooth scrolling behavior */
- scroll-behavior: smooth;
h1 {
font-size: 1.25rem;
@@ -18,17 +16,29 @@
}
button.chat-close {
- top: 0;
- right: 0;
+ top: 0.75rem;
+ right: 1.5rem;
+ height: 1.5rem;
+ width: 1.5rem;
+
+ .btn-icon__icon {
+ width: 1.375rem !important;
+ height: 1.375rem !important;
+ }
}
.sidebar-header {
+ display: flex;
+ align-items: center;
background: linear-gradient(to left, rgb(58, 101, 108) 0px, rgb(0, 29, 34));
width: 100%;
- height: 60px;
+ height: 48px;
+ padding: 0 1.5rem;
+
svg {
- height: 30px;
+ display: block;
+ height: 24px;
}
}
@@ -37,13 +47,3 @@
background-color: #F49974;
}
}
-
-.separator {
- z-index: 100;
- width: 100%;
- height: 5px;
- padding: 5px;
- background: -webkit-linear-gradient(270deg, rgba(0, 0, 0, 0.35), transparent);
- background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent);
- opacity: 0.3;
-}
diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx
index 5f6425d8..f8cb8e20 100644
--- a/src/components/Sidebar/index.jsx
+++ b/src/components/Sidebar/index.jsx
@@ -1,4 +1,4 @@
-import React, { useRef, useEffect } from 'react';
+import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
@@ -37,42 +37,6 @@ const Sidebar = ({
const { track } = useTrackEvent();
- const chatboxContainerRef = useRef(null);
-
- // this use effect is intended to scroll to the bottom of the chat window, in the case
- // that a message is larger than the chat window height.
- useEffect(() => {
- const messageContainer = chatboxContainerRef.current;
-
- if (messageContainer) {
- const { scrollHeight, clientHeight } = messageContainer;
- const maxScrollTop = scrollHeight - clientHeight;
- const duration = 200;
-
- if (maxScrollTop > 0) {
- const startTime = Date.now();
- const endTime = startTime + duration;
-
- const scroll = () => {
- const currentTime = Date.now();
- const timeFraction = (currentTime - startTime) / duration;
- const scrollTop = maxScrollTop * timeFraction;
-
- messageContainer.scrollTo({
- top: scrollTop,
- behavior: 'smooth',
- });
-
- if (currentTime < endTime) {
- requestAnimationFrame(scroll);
- }
- };
-
- requestAnimationFrame(scroll);
- }
- }
- }, [messageList, isOpen, apiError]);
-
const handleClick = () => {
setIsOpen(false);
@@ -120,10 +84,10 @@ const Sidebar = ({
const getSidebar = () => (
-
+
{upgradeable
@@ -132,11 +96,7 @@ const Sidebar = ({
{getDaysRemainingMessage()}
)}
-
-
+
{
apiError
&& (
@@ -145,7 +105,9 @@ const Sidebar = ({
)
}
- {getMessageForm()}
+
+ {getMessageForm()}
+
);
@@ -165,7 +127,7 @@ const Sidebar = ({
data-testid="sidebar"
>
{
+ setTimeout(() => {
+ containerRef.current.scrollTo({
+ top: containerRef.current.scrollHeight,
+ behavior: smooth ? 'smooth' : 'instant',
+ });
+ });
+};
diff --git a/src/utils/scroll.test.js b/src/utils/scroll.test.js
new file mode 100644
index 00000000..9df9fc27
--- /dev/null
+++ b/src/utils/scroll.test.js
@@ -0,0 +1,37 @@
+import { scrollToBottom } from './scroll';
+
+describe('scrollToBottom()', () => {
+ beforeAll(() => jest.useFakeTimers());
+
+ it('should scroll to the scrollHeight of the referred component instantly', () => {
+ const ref = {
+ current: {
+ scrollTo: jest.fn(),
+ scrollHeight: 42,
+ },
+ };
+ scrollToBottom(ref);
+ jest.runAllTimers();
+
+ expect(ref.current.scrollTo).toHaveBeenCalledWith({
+ top: ref.current.scrollHeight,
+ behavior: 'instant',
+ });
+ });
+
+ it('should scroll to the scrollHeight of the referred component smoothly', () => {
+ const ref = {
+ current: {
+ scrollTo: jest.fn(),
+ scrollHeight: 128,
+ },
+ };
+ scrollToBottom(ref, true);
+ jest.runAllTimers();
+
+ expect(ref.current.scrollTo).toHaveBeenCalledWith({
+ top: ref.current.scrollHeight,
+ behavior: 'smooth',
+ });
+ });
+});
diff --git a/src/widgets/Xpert.test.jsx b/src/widgets/Xpert.test.jsx
index 118c5860..b56ede6a 100644
--- a/src/widgets/Xpert.test.jsx
+++ b/src/widgets/Xpert.test.jsx
@@ -20,6 +20,10 @@ jest.mock('../experiments', () => ({
usePromptExperimentDecision: jest.fn(),
}));
+jest.mock('../utils/scroll', () => ({
+ scrollToBottom: jest.fn(),
+}));
+
const initialState = {
learningAssistant: {
currentMessage: '',