💬 fix: Temporary Chat PR's broken components and improved UI (#5705)

* 💬 fix: Temporary Chat PR's broken components and improved UI

* 💬 fix: bring back hover effect on AudioRecorder button

* style: adjust position of Mention component popover

* refactor: PromptsCommand typing and style position

* refactor: virtualize mention UI

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-02-07 02:15:38 +01:00 committed by GitHub
parent 63afb317c6
commit 70e410f38b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 196 additions and 86 deletions

View file

@ -13,7 +13,6 @@ export default function AudioRecorder({
methods,
textAreaRef,
isSubmitting,
isTemporary = false,
}: {
isRTL: boolean;
disabled: boolean;
@ -21,7 +20,6 @@ export default function AudioRecorder({
methods: ReturnType<typeof useChatFormContext>;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
isSubmitting: boolean;
isTemporary?: boolean;
}) {
const { setValue, reset } = methods;
const localize = useLocalize();
@ -78,11 +76,7 @@ export default function AudioRecorder({
if (isLoading === true) {
return <Spinner className="stroke-gray-700 dark:stroke-gray-300" />;
}
return (
<ListeningIcon
className={cn(isTemporary ? 'stroke-white' : 'stroke-gray-700 dark:stroke-gray-300')}
/>
);
return <ListeningIcon className="stroke-gray-700 dark:stroke-gray-300" />;
};
return (

View file

@ -24,6 +24,7 @@ import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { TemporaryChat } from './TemporaryChat';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
@ -47,7 +48,7 @@ const ChatForm = ({ index = 0 }) => {
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const isTemporary = useRecoilValue(store.isTemporary);
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
const isSearching = useRecoilValue(store.isSearching);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
@ -145,11 +146,8 @@ const ChatForm = ({ index = 0 }) => {
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
isTemporary
? 'bg-gray-600 text-white placeholder-white/20'
: 'bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50',
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
@ -185,12 +183,11 @@ const ChatForm = ({ index = 0 }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div
className={cn(
'transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl text-text-primary ',
isTemporary ? 'text-white' : 'duration-200',
)}
>
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
@ -243,7 +240,6 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
isTemporary={isTemporary}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { MentionOption, ConvoGenerator } from '~/common';
@ -9,6 +10,8 @@ import { useLocalize, useCombobox } from '~/hooks';
import { removeCharIfLast } from '~/utils';
import MentionItem from './MentionItem';
const ROW_HEIGHT = 40;
export default function Mention({
setShowMentionPopover,
newConversation,
@ -121,8 +124,41 @@ export default function Mention({
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [type, activeIndex]);
const rowRenderer = ({
index,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const mention = matches[index] as MentionOption;
return (
<MentionItem
type={type}
index={index}
key={key}
style={style}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
);
};
return (
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
// The user expects focus to transition to the input field when the popover is opened
@ -167,27 +203,20 @@ export default function Mention({
}}
/>
{open && (
<div className="max-h-40 overflow-y-auto">
{(matches as MentionOption[]).map((mention, index) => (
<MentionItem
type={type}
index={index}
key={`${mention.value}-${index}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
))}
<div className="max-h-40">
<AutoSizer disableHeight>
{({ width }) => (
<List
width={width}
overscanRowCount={5}
rowHeight={ROW_HEIGHT}
rowCount={matches.length}
rowRenderer={rowRenderer}
scrollToIndex={activeIndex}
height={Math.min(matches.length * ROW_HEIGHT, 160)}
/>
)}
</AutoSizer>
</div>
)}
</div>

View file

@ -10,6 +10,7 @@ export interface MentionItemProps {
icon?: React.ReactNode;
isActive?: boolean;
description?: string;
style?: React.CSSProperties;
}
export default function MentionItem({
@ -19,21 +20,28 @@ export default function MentionItem({
icon,
isActive,
description,
style,
type = 'mention',
}: MentionItemProps) {
return (
<button tabIndex={index} onClick={onClick} id={`${type}-item-${index}`} className="w-full">
<button
tabIndex={index}
onClick={onClick}
id={`${type}-item-${index}`}
className="w-full"
style={style}
>
<div
className={cn(
'text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium hover:bg-surface-secondary',
isActive ? 'bg-surface-active' : 'bg-transparent',
isActive === true ? 'bg-surface-active' : 'bg-transparent',
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
<div className="flex min-w-0 flex-grow items-center justify-between">
<div className="truncate">
<span className="font-medium">{name}</span>
{description ? (
{description != null && description ? (
<span className="text-token-text-tertiary ml-2 text-sm font-light">
{description}
</span>

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
@ -42,6 +43,8 @@ const PopoverContainer = memo(
},
);
const ROW_HEIGHT = 40;
function PromptsCommand({
index,
textAreaRef,
@ -63,8 +66,10 @@ function PromptsCommand({
const mappedArray = data.map((group) => ({
id: group._id,
value: group.command ?? group.name,
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? ''
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.name
}: ${
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
}));
@ -85,12 +90,12 @@ function PromptsCommand({
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null);
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const prompts = useMemo(() => data?.promptGroups ?? [], [data]);
const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]);
const prompts = useMemo(() => data?.promptGroups, [data]);
const promptsMap = useMemo(() => data?.promptsMap, [data]);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options: prompts,
options: prompts ?? [],
});
const handleSelect = useCallback(
@ -107,22 +112,20 @@ function PromptsCommand({
removeCharIfLast(textAreaRef.current, commandChar);
}
const isValidPrompt = mention && promptsMap && promptsMap[mention.id];
if (!isValidPrompt) {
const group = promptsMap?.[mention.id];
if (!group) {
return;
}
const group = promptsMap[mention.id];
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
if (group && hasVariables) {
if (hasVariables) {
if (e && e.key === 'Tab') {
e.preventDefault();
}
setVariableGroup(group);
setVariableDialogOpen(true);
return;
} else if (group) {
} else {
submitPrompt(group.productionPrompt?.prompt ?? '');
}
},
@ -154,6 +157,37 @@ function PromptsCommand({
return null;
}
const rowRenderer = ({
index,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const mention = matches[index] as PromptOption;
return (
<MentionItem
index={index}
type="prompt"
key={key}
style={style}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
);
};
return (
<PopoverContainer
index={index}
@ -161,7 +195,7 @@ function PromptsCommand({
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
@ -213,24 +247,23 @@ function PromptsCommand({
}
if (!isLoading && open) {
return (matches as PromptOption[]).map((mention, index) => (
<MentionItem
index={index}
type="prompt"
key={`${mention.value}-${index}`}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
));
return (
<div className="max-h-40">
<AutoSizer disableHeight>
{({ width }) => (
<List
width={width}
overscanRowCount={5}
rowHeight={ROW_HEIGHT}
rowCount={matches.length}
rowRenderer={rowRenderer}
scrollToIndex={activeIndex}
height={Math.min(matches.length * ROW_HEIGHT, 160)}
/>
)}
</AutoSizer>
</div>
);
}
return null;
})()}

View file

@ -0,0 +1,38 @@
import { MessageCircleDashed, X } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface TemporaryChatProps {
isTemporaryChat: boolean;
setIsTemporaryChat: (value: boolean) => void;
}
export const TemporaryChat = ({ isTemporaryChat, setIsTemporaryChat }: TemporaryChatProps) => {
const localize = useLocalize();
if (!isTemporaryChat) {
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<MessageCircleDashed className="icon-md" />
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{localize('com_ui_temporary_chat')}
</span>
<button
className="text-token-text-secondary flex-shrink-0"
type="button"
aria-label="Close temporary chat"
onClick={() => setIsTemporaryChat(false)}
>
<X className="pr-1" />
</button>
</div>
</div>
);
};

View file

@ -4,15 +4,16 @@ import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, getConfigDefaults } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
import temporaryStore from '~/store/temporary';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export const TemporaryChat = () => {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const defaultInterface = getConfigDefaults().interface;
const [isTemporary, setIsTemporary] = useRecoilState(temporaryStore.isTemporary);
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const interfaceConfig = useMemo(
@ -20,7 +21,7 @@ export const TemporaryChat = () => {
[startupConfig],
);
if (!interfaceConfig.temporaryChat) {
if (interfaceConfig.temporaryChat === false) {
return null;
}
@ -39,20 +40,21 @@ export const TemporaryChat = () => {
};
return (
<div className="sticky bottom-0 border-t border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-700">
<div className="sticky bottom-0 border-none bg-surface-tertiary px-6 py-4 ">
<div className="flex items-center">
<div className={cn('flex flex-1 items-center gap-2', isActiveConvo && 'opacity-40')}>
<MessageCircleDashed className="icon-sm" />
<span className="text-sm text-gray-700 dark:text-gray-300">Temporary Chat</span>
<span className="text-sm text-text-primary">{localize('com_ui_temporary_chat')}</span>
</div>
<div className="ml-auto flex items-center">
<Switch
id="enableUserMsgMarkdown"
id="temporary-chat-switch"
checked={isTemporary}
onCheckedChange={onClick}
disabled={isActiveConvo}
className="ml-4"
data-testid="enableUserMsgMarkdown"
aria-label="Toggle temporary chat"
data-testid="temporary-chat-switch"
/>
</div>
</div>

View file

@ -463,6 +463,7 @@ export default {
com_ui_shared_link_delete_success: 'Successfully deleted shared link',
com_ui_shared_link_bulk_delete_success: 'Successfully deleted shared links',
com_ui_search: 'Search',
com_ui_temporary_chat: 'Temporary Chat',
com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.',
com_auth_error_login_rl:

View file

@ -1,9 +1,6 @@
import { atom } from 'recoil';
import { atomWithLocalStorage } from '~/store/utils';
const isTemporary = atom<boolean>({
key: 'isTemporary',
default: false,
});
const isTemporary = atomWithLocalStorage('isTemporary', false);
export default {
isTemporary,

11
package-lock.json generated
View file

@ -16,6 +16,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.9.1",
"@playwright/test": "^1.38.1",
"@types/react-virtualized": "^9.22.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"cross-env": "^7.0.3",
@ -15126,6 +15127,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized": {
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.22.0.tgz",
"integrity": "sha512-JL/YCCFZ123za//cj10Apk54F0UGFMrjOE0QHTuXt1KBMFrzLOGv9/x6Uc/pZ0Gaf4o6w61Fostvlw0DwuPXig==",
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",

View file

@ -82,6 +82,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.9.1",
"@playwright/test": "^1.38.1",
"@types/react-virtualized": "^9.22.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"cross-env": "^7.0.3",