WIP: Update UI to match Official Style; Vision and Assistants 👷🏽 (#1190)

* wip: initial client side code

* wip: initial api code

* refactor: export query keys from own module, export assistant hooks

* refactor(SelectDropDown): more customization via props

* feat: create Assistant and render real Assistants

* refactor: major refactor of UI components to allow multi-chat, working alongside CreationPanel

* refactor: move assistant routes to own directory

* fix(CreationHeader): state issue with assistant select

* refactor: style changes for form, fix setSiblingIdx from useChatHelpers to use latestMessageParentId, fix render issue with ChatView and change location

* feat: parseCompactConvo: begin refactor of slimmer JSON payloads between client/api

* refactor(endpoints): add assistant endpoint, also use EModelEndpoint as much as possible

* refactor(useGetConversationsQuery): use object to access query data easily

* fix(MultiMessage): react warning of bad state set, making use of effect during render (instead of useEffect)

* fix(useNewConvo): use correct atom key (index instead of convoId) for reset latestMessageFamily

* refactor: make routing navigation/conversation change simpler

* chore: add removeNullishValues for smaller payloads, remove unused fields, setup frontend pinging of assistant endpoint

* WIP: initial complete assistant run handling

* fix: CreationPanel form correctly setting internal state

* refactor(api/assistants/chat): revise functions to working run handling strategy

* refactor(UI): initial major refactor of ChatForm and options

* feat: textarea hook

* refactor: useAuthRedirect hook and change directory name

* feat: add ChatRoute (/c/), make optionsBar absolute and change on textarea height, add temp header

* feat: match new toggle Nav open button to ChatGPT's

* feat: add OpenAI custom classnames

* feat: useOriginNavigate

* feat: messages loading view

* fix: conversation navigation and effects

* refactor: make toggle change nav opacity

* WIP: new endpoint menu

* feat: NewEndpointsMenu complete

* fix: ensure set key dialog shows on endpoint change, and new conversation resets messages

* WIP: textarea styling fix, add temp footer, create basic file handling component

* feat: image file handling (UI)

* feat: PopOver and ModelSelect in Header, remove GenButtons

* feat: drop file handling

* refactor: bug fixes
use SSE at route level
add opts to useOriginNavigate
delay render of unfinishedMessage to avoid flickering
pass params (convoId) to chatHelpers to set messages query data based on param when the route is new (fixes can't continue convo on /new/)
style(MessagesView): matches height to official
fix(SSE): pass paramId and invalidate convos
style(Message): make bg uniform

* refactor(useSSE): setStorage within setConversation updates

* feat: conversationKeysAtom, allConversationsSelector, update convos query data on created message (if new), correctly handle convo deletion (individual)

* feat: add popover select dropdowns to allow options in header while allowing horizontal scroll for mobile

* style(pluginsSelect): styling changes

* refactor(NewEndpointsMenu): make UI components modular

* feat: Presets complete

* fix: preset editing, make by index

* fix: conversations not setting on inital navigation, fix getMessages() based on query param

* fix: changing preset no longer resets latestMessage

* feat: useOnClickOutside for OptionsPopover and fix bug that causes selection of preset when deleting

* fix: revert /chat/ switchToConvo, also use NewDeleteButton in Convo

* fix: Popover correctly closes on close Popover button using custom condition for useOnClickOutside

* style: new message and nav styling

* style: hover/sibling buttons and preset menu scrolling

* feat: new convo header button

* style(Textarea): minor style changes to textarea buttons

* feat: stop/continue generating and hide hoverbuttons when submitting

* feat: compact AI Provider schemas to make json payloads and db saves smaller

* style: styling changes for consistency on chat route

* fix: created usePresetIndexOptions to prevent bugs between /c/ and /chat/ routes when editing presets, removed redundant code from the new dialog

* chore: make /chat/ route default for now since we still lack full image support
This commit is contained in:
Danny Avila 2023-11-16 10:42:24 -05:00 committed by GitHub
parent adbeb46399
commit bac1fb67d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
171 changed files with 8380 additions and 468 deletions

View file

@ -0,0 +1,8 @@
// Container Component
const Container = ({ children }: { children: React.ReactNode }) => (
<div className="text-message peer flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto break-words peer-[.text-message]:mt-5">
{children}
</div>
);
export default Container;

View file

@ -0,0 +1,117 @@
import { useRef } from 'react';
import { useUpdateMessageMutation } from 'librechat-data-provider';
import Container from '~/components/Messages/Content/Container';
import { useChatContext } from '~/Providers';
import type { TEditProps } from '~/common';
import { useLocalize } from '~/hooks';
const EditMessage = ({
text,
message,
isSubmitting,
ask,
enterEdit,
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const { getMessages, setMessages, conversation } = useChatContext();
const textEditor = useRef<HTMLDivElement | null>(null);
const { conversationId, parentMessageId, messageId } = message;
const updateMessageMutation = useUpdateMessageMutation(conversationId ?? '');
const localize = useLocalize();
const resubmitMessage = () => {
const text = textEditor?.current?.innerText ?? '';
if (message.isCreatedByUser) {
ask({
text,
parentMessageId,
conversationId,
});
setSiblingIdx((siblingIdx ?? 0) - 1);
} else {
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === parentMessageId);
if (!parentMessage) {
return;
}
ask(
{ ...parentMessage },
{
editedText: text,
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
},
);
setSiblingIdx((siblingIdx ?? 0) - 1);
}
enterEdit(true);
};
const updateMessage = () => {
const messages = getMessages();
if (!messages) {
return;
}
const text = textEditor?.current?.innerText ?? '';
updateMessageMutation.mutate({
conversationId: conversationId ?? '',
model: conversation?.model ?? 'gpt-3.5-turbo',
messageId,
text,
});
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text,
isEdited: true,
}
: msg,
),
);
enterEdit(true);
};
return (
<Container>
<div
data-testid="message-text-editor"
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none"
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
>
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={resubmitMessage}
>
{localize('com_ui_save')} {'&'} {localize('com_ui_submit')}
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}
onClick={updateMessage}
>
{localize('com_ui_save')}
</button>
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
{localize('com_ui_cancel')}
</button>
</div>
</Container>
);
};
export default EditMessage;

View file

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import ReactMarkdown from 'react-markdown';
import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useChatContext } from '~/Providers';
import { langSubset, validateIframe } from '~/utils';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
type TCodeProps = {
inline: boolean;
className: string;
children: React.ReactNode;
};
type TContentProps = {
content: string;
message: TMessage;
showCursor?: boolean;
};
const code = React.memo(({ inline, className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
if (inline) {
return <code className={className}>{children}</code>;
} else {
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
}
});
const p = React.memo(({ children }: { children: React.ReactNode }) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
const Markdown = React.memo(({ content, message, showCursor }: TContentProps) => {
const [cursor, setCursor] = useState('█');
const { isSubmitting, latestMessage } = useChatContext();
const isInitializing = content === '<span className="result-streaming">█</span>';
const { isEdited, messageId } = message ?? {};
const isLatestMessage = messageId === latestMessage?.messageId;
const currentContent = content?.replace('z-index: 1;', '') ?? '';
useEffect(() => {
let timer1: NodeJS.Timeout, timer2: NodeJS.Timeout;
if (!showCursor) {
setCursor('');
return;
}
if (isSubmitting && isLatestMessage) {
timer1 = setInterval(() => {
setCursor('');
timer2 = setTimeout(() => {
setCursor('█');
}, 200);
}, 1000);
} else {
setCursor('');
}
// This is the cleanup function that React will run when the component unmounts
return () => {
clearInterval(timer1);
clearTimeout(timer2);
};
}, [isSubmitting, isLatestMessage, showCursor]);
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
[rehypeRaw],
];
let isValidIframe: string | boolean | null = false;
if (!isEdited) {
isValidIframe = validateIframe(currentContent);
}
if (isEdited || ((!isInitializing || !isLatestMessage) && !isValidIframe)) {
rehypePlugins.pop();
}
return (
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
linkTarget="_new"
components={
{
code,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{isLatestMessage && isSubmitting && !isInitializing
? currentContent + cursor
: currentContent}
</ReactMarkdown>
);
});
export default Markdown;

View file

@ -0,0 +1,130 @@
import { Fragment, Suspense } from 'react';
import type { TResPlugin } from 'librechat-data-provider';
import type { TMessageContent, TText, TDisplayProps } from '~/common';
import Plugin from '~/components/Messages/Content/Plugin';
import Error from '~/components/Messages/Content/Error';
import { DelayedRender } from '~/components/ui';
import { useAuthContext } from '~/hooks';
import EditMessage from './EditMessage';
import Container from './Container';
import Markdown from './Markdown';
import { cn } from '~/utils';
const ErrorMessage = ({ text }: TText) => {
const { logout } = useAuthContext();
if (text.includes('ban')) {
logout();
return null;
}
return (
<Container>
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
<Error text={text} />
</div>
</Container>
);
};
// Display Message Component
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => (
<Container>
<div
className={cn(
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
)}
>
{!isCreatedByUser ? (
<Markdown content={text} message={message} showCursor={showCursor} />
) : (
<>{text}</>
)}
</div>
</Container>
);
// Unfinished Message Component
const UnfinishedMessage = () => (
<ErrorMessage text="This is an unfinished message. The AI may still be generating a response, it was aborted, or a censor was triggered. Refresh or visit later to see more updates." />
);
// Content Component
const MessageContent = ({
text,
edit,
error,
unfinished,
isSubmitting,
isLast,
...props
}: TMessageContent) => {
if (error) {
return <ErrorMessage text={text} />;
} else if (edit) {
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
} else {
const marker = ':::plugin:::\n';
const splitText = text.split(marker);
const { message } = props;
const { plugins, messageId } = message;
const displayedIndices = new Set<number>();
// Function to get the next non-empty text index
const getNextNonEmptyTextIndex = (currentIndex: number) => {
for (let i = currentIndex + 1; i < splitText.length; i++) {
// Allow the last index to be last in case it has text
// this may need to change if I add back streaming
if (i === splitText.length - 1) {
return currentIndex;
}
if (splitText[i].trim() !== '' && !displayedIndices.has(i)) {
return i;
}
}
return currentIndex; // If no non-empty text is found, return the current index
};
return splitText.map((text, idx) => {
let currentText = text.trim();
let plugin: TResPlugin | null = null;
if (plugins) {
plugin = plugins[idx];
}
// If the current text is empty, get the next non-empty text index
const displayTextIndex = currentText === '' ? getNextNonEmptyTextIndex(idx) : idx;
currentText = splitText[displayTextIndex];
const isLastIndex = displayTextIndex === splitText.length - 1;
const isEmpty = currentText.trim() === '';
const showText =
(currentText && !isEmpty && !displayedIndices.has(displayTextIndex)) ||
(isEmpty && isLastIndex);
displayedIndices.add(displayTextIndex);
return (
<Fragment key={idx}>
{plugin && <Plugin key={`plugin-${messageId}-${idx}`} plugin={plugin} />}
{showText ? (
<DisplayMessage
key={`display-${messageId}-${idx}`}
showCursor={isLastIndex && isLast}
text={currentText}
{...props}
/>
) : null}
{!isSubmitting && unfinished && (
<Suspense>
<DelayedRender delay={250}>
<UnfinishedMessage key={`unfinished-${messageId}-${idx}`} />
</DelayedRender>
</Suspense>
)}
</Fragment>
);
});
}
};
export default MessageContent;

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerations, useLocalize } from '~/hooks';
import { cn } from '~/utils';
type THoverButtons = {
isEditing: boolean;
enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation | null;
isSubmitting: boolean;
message: TMessage;
regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
latestMessage: TMessage | null;
};
export default function HoverButtons({
isEditing,
enterEdit,
copyToClipboard,
conversation,
isSubmitting,
message,
regenerate,
handleContinue,
latestMessage,
}: THoverButtons) {
const localize = useLocalize();
const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button
className={cn(
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
<button
className={cn(
'hover-button ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark /> : <Clipboard />}
</button>
{regenerateEnabled ? (
<button
className="hover-button active rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible"
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
{continueSupported ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible "
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
</div>
);
}

View file

@ -0,0 +1,200 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { Plugin } from '~/components/Messages/Content';
import MessageContent from './Content/MessageContent';
import { Icon } from '~/components/Endpoints';
import SiblingSwitch from './SiblingSwitch';
import type { TMessageProps } from '~/common';
import { useChatContext } from '~/Providers';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
// import { cn } from '~/utils';
export default function Message(props: TMessageProps) {
const {
message,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx,
} = props;
const {
ask,
regenerate,
autoScroll,
abortScroll,
isSubmitting,
conversation,
setAbortScroll,
handleContinue,
latestMessage,
setLatestMessage,
} = useChatContext();
const { conversationId } = conversation ?? {};
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
const isLast = !children?.length;
const edit = messageId === currentEditId;
useEffect(() => {
if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom]);
useEffect(() => {
if (!message) {
return;
} else if (isLast) {
setLatestMessage({ ...message });
}
}, [isLast, message, setLatestMessage]);
if (!message) {
return null;
}
const enterEdit = (cancel?: boolean) =>
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => {
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
};
// const commonClasses =
// 'w-full border-b text-gray-800 group border-black/10 dark:border-gray-900/50 dark:text-gray-100 dark:border-none';
// const uniqueClasses = isCreatedByUser
// ? 'bg-white dark:bg-gray-800 dark:text-gray-20'
// : 'bg-white dark:bg-gray-800 dark:text-gray-70';
// const messageProps = {
// className: cn(commonClasses, uniqueClasses),
// titleclass: '',
// };
const icon = Icon({
...conversation,
...message,
model: message?.model ?? conversation?.model,
size: 28.8,
});
const regenerateMessage = () => {
if (isSubmitting && isCreatedByUser) {
return;
}
regenerate(message);
};
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
setIsCopied(true);
copy(text ?? '');
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
return (
<>
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 md:py-6">
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:gap-6 md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="gizmo-shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
</div>
</div>
</div>
</div>
<div className="agent-turn relative flex w-[calc(100%-50px)] w-full flex-col lg:w-[calc(100%-36px)]">
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{/* Legacy Plugins */}
{message?.plugin && <Plugin plugin={message?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={text ?? ''}
message={message}
enterEdit={enterEdit}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={
setSiblingIdx ??
(() => {
return;
})
}
/>
</div>
</div>
{isLast && isSubmitting ? null : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
/>
</SubRow>
)}
</div>
</div>
</div>
</div>
<MultiMessage
key={messageId}
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}

View file

@ -0,0 +1,116 @@
import { useLayoutEffect, useState, useRef, useCallback } from 'react';
import type { ReactNode } from 'react';
import type { TMessage } from 'librechat-data-provider';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { CSSTransition } from 'react-transition-group';
import { useChatContext } from '~/Providers';
import MultiMessage from './MultiMessage';
import { useScrollToRef } from '~/hooks';
export default function MessagesView({
messagesTree: _messagesTree,
Header,
}: {
messagesTree?: TMessage[] | null;
Header?: ReactNode;
}) {
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const { conversation, showPopover, setAbortScroll } = useChatContext();
const { conversationId } = conversation ?? {};
// TODO: screenshot target ref
// const { screenshotTargetRef } = useScreenshot();
const checkIfAtBottom = useCallback(() => {
if (!scrollableRef.current) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const percent = Math.abs(clientHeight - diff) / clientHeight;
const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15;
setShowScrollButton(hasScrollbar);
}, [scrollableRef]);
useLayoutEffect(() => {
const timeoutId = setTimeout(() => {
checkIfAtBottom();
}, 650);
// Add a listener on the window object
window.addEventListener('scroll', checkIfAtBottom);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('scroll', checkIfAtBottom);
};
}, [_messagesTree, checkIfAtBottom]);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debouncedHandleScroll = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(checkIfAtBottom, 100);
};
const scrollCallback = () => setShowScrollButton(false);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
callback: scrollCallback,
smoothCallback: () => {
scrollCallback();
setAbortScroll(false);
},
});
return (
<div
className="flex-1 overflow-y-auto pt-0"
ref={scrollableRef}
onScroll={debouncedHandleScroll}
>
<div className="dark:gpt-dark-gray h-full">
<div>
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
{(_messagesTree && _messagesTree?.length == 0) || _messagesTree === null ? (
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-900/50 dark:bg-gray-800 dark:text-gray-300">
Nothing found
</div>
) : (
<>
{Header && Header}
<MultiMessage
key={conversationId} // avoid internal state mixture
messageId={conversationId ?? null}
messagesTree={_messagesTree}
scrollToBottom={scrollToBottom}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
<CSSTransition
in={showScrollButton}
timeout={400}
classNames="scroll-down"
unmountOnExit={false}
// appear
>
{() =>
showScrollButton &&
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
}
</CSSTransition>
</>
)}
<div
className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-900/50"
ref={messagesEndRef}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';
export default function MultiMessage({
// messageId is used recursively here
messageId,
messagesTree,
scrollToBottom,
currentEditId,
setCurrentEditId,
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesTree?.length]);
useEffect(() => {
if (messagesTree?.length && siblingIdx >= messagesTree?.length) {
setSiblingIdx(0);
}
}, [siblingIdx, messagesTree?.length, setSiblingIdx]);
if (!(messagesTree && messagesTree?.length)) {
return null;
}
const message = messagesTree[messagesTree.length - siblingIdx - 1];
if (!message) {
return null;
}
return (
<Message
key={message.messageId}
message={message}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
/>
);
}

View file

@ -0,0 +1,71 @@
import type { TMessageProps } from '~/common';
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
export default function SiblingSwitch({
siblingIdx,
siblingCount,
setSiblingIdx,
}: TSiblingSwitchProps) {
if (siblingIdx === undefined) {
return null;
} else if (siblingCount === undefined) {
return null;
}
const previous = () => {
setSiblingIdx && setSiblingIdx(siblingIdx - 1);
};
const next = () => {
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
};
return siblingCount > 1 ? (
<div className="visible flex items-center justify-center gap-1 self-center pt-0 text-xs">
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
onClick={previous}
disabled={siblingIdx == 0}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span className="flex-shrink-0 flex-grow tabular-nums">
{siblingIdx + 1}/{siblingCount}
</span>
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
onClick={next}
disabled={siblingIdx == siblingCount - 1}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
) : null;
}

View file

@ -0,0 +1,19 @@
import { cn } from '~/utils';
type TSubRowProps = {
children: React.ReactNode;
classes?: string;
subclasses?: string;
onClick?: () => void;
};
export default function SubRow({ children, classes = '', onClick }: TSubRowProps) {
return (
<div
className={cn('mt-1 flex justify-start gap-3 empty:hidden lg:flex', classes)}
onClick={onClick}
>
{children}
</div>
);
}