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: '',