diff --git a/frontend/src/Hero.tsx b/frontend/src/Hero.tsx index e515af16..0161eac2 100644 --- a/frontend/src/Hero.tsx +++ b/frontend/src/Hero.tsx @@ -1,6 +1,6 @@ -import { Fragment } from 'react'; import DocsGPT3 from './assets/cute_docsgpt3.svg'; import { useTranslation } from 'react-i18next'; + export default function Hero({ handleQuestion, }: { @@ -17,40 +17,41 @@ export default function Hero({ header: string; query: string; }>; - return ( -
-
-
- DocsGPT - docsgpt -
-
+ return ( +
+ {/* Header Section */} +
+
+ DocsGPT + docsgpt +
-
- {demos?.map( - (demo: { header: string; query: string }, key: number) => - demo.header && - demo.query && ( - + + {/* Demo Buttons Section */} +
+
+ {demos?.map( + (demo: { header: string; query: string }, key: number) => + demo.header && + demo.query && ( - - ), - )} + ), + )} +
); diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index 39dff2b0..3fd84bdc 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -35,7 +35,7 @@ const Input = ({
) => void; + onSubmit: () => void; + loading: boolean; +} + +export default function MessageInput({ + value, + onChange, + onSubmit, + loading, +}: MessageInputProps) { + const { t } = useTranslation(); + const [isDarkTheme] = useDarkTheme(); + const inputRef = useRef(null); + + const handleInput = () => { + if (inputRef.current) { + if (window.innerWidth < 350) inputRef.current.style.height = 'auto'; + else inputRef.current.style.height = '64px'; + inputRef.current.style.height = `${Math.min( + inputRef.current.scrollHeight, + 96, + )}px`; + } + }; + + // Focus the textarea and set initial height on mount. + useEffect(() => { + inputRef.current?.focus(); + handleInput(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + if (inputRef.current) { + inputRef.current.value = ''; + handleInput(); + } + } + }; + + return ( +
+ + - {status === 'loading' ? ( - {t('loading')} - ) : ( -
- -
- )} + setInput(e.target.value)} + onSubmit={handleQuestionSubmission} + loading={status === 'loading'} + />

diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx new file mode 100644 index 00000000..c83ee50c --- /dev/null +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -0,0 +1,180 @@ +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import ConversationBubble from './ConversationBubble'; +import Hero from '../Hero'; +import { FEEDBACK, Query, Status } from './conversationModels'; +import ArrowDown from '../assets/arrow-down.svg'; +import RetryIcon from '../components/RetryIcon'; +import { useDarkTheme } from '../hooks'; + +interface ConversationMessagesProps { + handleQuestion: (params: { + question: string; + isRetry?: boolean; + updated?: boolean | null; + indx?: number; + }) => void; + handleQuestionSubmission: ( + updatedQuestion?: string, + updated?: boolean, + indx?: number, + ) => void; + handleFeedback?: (query: Query, feedback: FEEDBACK, index: number) => void; + queries: Query[]; + status: Status; +} + +export default function ConversationMessages({ + handleQuestion, + handleQuestionSubmission, + queries, + status, + handleFeedback, +}: ConversationMessagesProps) { + const [isDarkTheme] = useDarkTheme(); + const { t } = useTranslation(); + + const conversationRef = useRef(null); + const [hasScrolledToLast, setHasScrolledToLast] = useState(true); + const [eventInterrupt, setEventInterrupt] = useState(false); + + const handleUserInterruption = () => { + if (!eventInterrupt && status === 'loading') { + setEventInterrupt(true); + } + }; + + const scrollIntoView = () => { + if (!conversationRef?.current || eventInterrupt) return; + + if (status === 'idle' || !queries[queries.length - 1]?.response) { + conversationRef.current.scrollTo({ + behavior: 'smooth', + top: conversationRef.current.scrollHeight, + }); + } else { + conversationRef.current.scrollTop = conversationRef.current.scrollHeight; + } + }; + + const checkScroll = () => { + const el = conversationRef.current; + if (!el) return; + const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10; + setHasScrolledToLast(isBottom); + }; + + useEffect(() => { + !eventInterrupt && scrollIntoView(); + }, [queries.length, queries[queries.length - 1]]); + + useEffect(() => { + if (status === 'idle') { + setEventInterrupt(false); + } + }, [status]); + + useEffect(() => { + conversationRef.current?.addEventListener('scroll', checkScroll); + return () => { + conversationRef.current?.removeEventListener('scroll', checkScroll); + }; + }, []); + + const prepResponseView = (query: Query, index: number) => { + let responseView; + if (query.response) { + responseView = ( + handleFeedback(query, feedback, index) + : undefined + } + /> + ); + } else if (query.error) { + const retryBtn = ( + + ); + responseView = ( + + ); + } + return responseView; + }; + + return ( +

+ {queries.length > 0 && !hasScrolledToLast && ( + + )} + +
+ {queries.length > 0 ? ( + queries.map((query, index) => ( + + + {prepResponseView(query, index)} + + )) + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx index b231ed1e..993556e4 100644 --- a/frontend/src/conversation/SharedConversation.tsx +++ b/frontend/src/conversation/SharedConversation.tsx @@ -1,12 +1,9 @@ -import { Query } from './conversationModels'; -import { Fragment, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; - +import ConversationMessages from './ConversationMessages'; +import MessageInput from '../components/MessageInput'; import conversationService from '../api/services/conversationService'; -import ConversationBubble from './ConversationBubble'; -import Send from '../assets/send.svg'; -import Spinner from '../assets/spinner.svg'; import { selectClientAPIKey, setClientApiKey, @@ -27,6 +24,7 @@ import { } from './sharedConversationSlice'; import { useSelector } from 'react-redux'; import { Helmet } from 'react-helmet'; +import { formatDate } from '../utils/dateTimeUtils'; export const SharedConversation = () => { const navigate = useNavigate(); @@ -38,27 +36,14 @@ export const SharedConversation = () => { const apiKey = useSelector(selectClientAPIKey); const status = useSelector(selectStatus); - const inputRef = useRef(null); - const sharedConversationRef = useRef(null); + const [input, setInput] = useState(''); const { t } = useTranslation(); const dispatch = useDispatch(); const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); - const [eventInterrupt, setEventInterrupt] = useState(false); - const endMessageRef = useRef(null); - const handleUserInterruption = () => { - if (!eventInterrupt && status === 'loading') setEventInterrupt(true); - }; - useEffect(() => { - !eventInterrupt && scrollIntoView(); - }, [queries.length, queries[queries.length - 1]]); useEffect(() => { identifier && dispatch(setIdentifier(identifier)); - const element = document.getElementById('inputbox') as HTMLInputElement; - if (element) { - element.focus(); - } }, []); useEffect(() => { @@ -68,20 +53,6 @@ export const SharedConversation = () => { } }, [queries[queries.length - 1]]); - const scrollIntoView = () => { - if (!sharedConversationRef?.current || eventInterrupt) return; - - if (status === 'idle' || !queries[queries.length - 1].response) { - sharedConversationRef.current.scrollTo({ - behavior: 'smooth', - top: sharedConversationRef.current.scrollHeight, - }); - } else { - sharedConversationRef.current.scrollTop = - sharedConversationRef.current.scrollHeight; - } - }; - const fetchQueries = () => { identifier && conversationService @@ -97,7 +68,7 @@ export const SharedConversation = () => { setFetchedData({ queries: data.queries, title: data.title, - date: data.date, + date: formatDate(data.timestamp), identifier, }), ); @@ -105,47 +76,16 @@ export const SharedConversation = () => { } }); }; - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault(); - const text = e.clipboardData.getData('text/plain'); - inputRef.current && (inputRef.current.innerText = text); - }; - const prepResponseView = (query: Query, index: number) => { - let responseView; - if (query.response) { - responseView = ( - - ); - } else if (query.error) { - responseView = ( - - ); - } - return responseView; - }; + const handleQuestionSubmission = () => { - if (inputRef.current?.textContent && status !== 'loading') { + if (input && status !== 'loading') { if (lastQueryReturnedErr) { // update last failed query with new prompt dispatch( updateQuery({ index: queries.length - 1, query: { - prompt: inputRef.current.textContent, + prompt: input, }, }), ); @@ -154,9 +94,9 @@ export const SharedConversation = () => { isRetry: true, }); } else { - handleQuestion({ question: inputRef.current.textContent }); + handleQuestion({ question: input }); } - inputRef.current.textContent = ''; + setInput(''); } }; @@ -169,7 +109,6 @@ export const SharedConversation = () => { }) => { question = question.trim(); if (question === '') return; - setEventInterrupt(false); !isRetry && dispatch(addQuery({ prompt: question })); //dispatch only new queries dispatch(fetchSharedAnswer({ question })); }; @@ -194,93 +133,47 @@ export const SharedConversation = () => { content="Shared conversations with DocsGPT" /> -
-
-
-
-

- {title} -

-

- {t('sharedConv.subtitle')}{' '} - - DocsGPT - -

-

- {date} -

-
-
- {queries?.map((query, index) => { - return ( - - - - {prepResponseView(query, index)} - - ); - })} -
-
+
+
+

+ {title} +

+

+ {t('sharedConv.subtitle')}{' '} + + DocsGPT + +

+

+ {date} +

- -
+ +
{apiKey ? ( -
-
{ - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleQuestionSubmission(); - } - }} - >
- {status === 'loading' ? ( - - ) : ( -
- -
- )} -
+ setInput(e.target.value)} + onSubmit={() => handleQuestionSubmission()} + loading={status === 'loading'} + /> ) : ( )} - + +

{t('sharedConv.meta')} - +

diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 4473d3a9..f00eb546 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -265,8 +265,7 @@ export const conversationSlice = createSlice({ return state; } state.status = 'failed'; - state.queries[state.queries.length - 1].error = - 'Something went wrong. Please check your internet connection.'; + state.queries[state.queries.length - 1].error = 'Something went wrong'; }); }, }); diff --git a/frontend/src/index.css b/frontend/src/index.css index 88e56469..07760385 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -501,15 +501,15 @@ input:-webkit-autofill:active { .dark input:-webkit-autofill:hover, .dark input:-webkit-autofill:focus, .dark input:-webkit-autofill:active { - -webkit-text-fill-color: #E5E7EB !important; + -webkit-text-fill-color: #e5e7eb !important; -webkit-box-shadow: 0 0 0 30px transparent inset !important; background-color: transparent !important; - caret-color: #E5E7EB; + caret-color: #e5e7eb; } /* Additional autocomplete dropdown styles for dark mode */ .dark input:-webkit-autofill::first-line { - color: #E5E7EB; + color: #e5e7eb; } .inputbox-style { diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 80d673c4..3fd444ad 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -138,7 +138,7 @@ export const ShareConversationModal = ({ {status === 'fetched' ? (