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

View file

@ -24,6 +24,7 @@ import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper'; import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui'; import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import { TemporaryChat } from './TemporaryChat';
import TextareaHeader from './TextareaHeader'; import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand'; import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder'; import AudioRecorder from './AudioRecorder';
@ -47,7 +48,7 @@ const ChatForm = ({ index = 0 }) => {
const TextToSpeech = useRecoilValue(store.textToSpeech); const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback); const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const isTemporary = useRecoilValue(store.isTemporary); const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
const isSearching = useRecoilValue(store.isSearching); const isSearching = useRecoilValue(store.isSearching);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index)); const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
@ -145,11 +146,8 @@ const ChatForm = ({ index = 0 }) => {
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false; const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const baseClasses = cn( 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]', 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; const uploadActive = endpointSupportsFiles && !isUploadDisabled;
@ -185,12 +183,11 @@ const ChatForm = ({ index = 0 }) => {
/> />
)} )}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} /> <PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div <div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
className={cn( <TemporaryChat
'transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl text-text-primary ', isTemporaryChat={isTemporaryChat}
isTemporary ? 'text-white' : 'duration-200', setIsTemporaryChat={setIsTemporaryChat}
)} />
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> <TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}> <FileFormWrapper disableInputs={disableInputs}>
{endpoint && ( {endpoint && (
@ -243,7 +240,6 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef={textAreaRef} textAreaRef={textAreaRef}
disabled={!!disableInputs} disabled={!!disableInputs}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isTemporary={isTemporary}
/> />
)} )}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />} {TextToSpeech && automaticPlayback && <StreamAudio index={index} />}

View file

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

View file

@ -10,6 +10,7 @@ export interface MentionItemProps {
icon?: React.ReactNode; icon?: React.ReactNode;
isActive?: boolean; isActive?: boolean;
description?: string; description?: string;
style?: React.CSSProperties;
} }
export default function MentionItem({ export default function MentionItem({
@ -19,21 +20,28 @@ export default function MentionItem({
icon, icon,
isActive, isActive,
description, description,
style,
type = 'mention', type = 'mention',
}: MentionItemProps) { }: MentionItemProps) {
return ( 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 <div
className={cn( 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', '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 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="flex min-w-0 flex-grow items-center justify-between">
<div className="truncate"> <div className="truncate">
<span className="font-medium">{name}</span> <span className="font-medium">{name}</span>
{description ? ( {description != null && description ? (
<span className="text-token-text-tertiary ml-2 text-sm font-light"> <span className="text-token-text-tertiary ml-2 text-sm font-light">
{description} {description}
</span> </span>

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react'; import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { useSetRecoilState, useRecoilValue } from 'recoil'; import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
@ -42,6 +43,8 @@ const PopoverContainer = memo(
}, },
); );
const ROW_HEIGHT = 40;
function PromptsCommand({ function PromptsCommand({
index, index,
textAreaRef, textAreaRef,
@ -63,8 +66,10 @@ function PromptsCommand({
const mappedArray = data.map((group) => ({ const mappedArray = data.map((group) => ({
id: group._id, id: group._id,
value: group.command ?? group.name, value: group.command ?? group.name,
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${ label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? '' group.name
}: ${
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
}`, }`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />, icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
})); }));
@ -85,12 +90,12 @@ function PromptsCommand({
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null); const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null);
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index)); const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const prompts = useMemo(() => data?.promptGroups ?? [], [data]); const prompts = useMemo(() => data?.promptGroups, [data]);
const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]); const promptsMap = useMemo(() => data?.promptsMap, [data]);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({ const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '', value: '',
options: prompts, options: prompts ?? [],
}); });
const handleSelect = useCallback( const handleSelect = useCallback(
@ -107,22 +112,20 @@ function PromptsCommand({
removeCharIfLast(textAreaRef.current, commandChar); removeCharIfLast(textAreaRef.current, commandChar);
} }
const isValidPrompt = mention && promptsMap && promptsMap[mention.id]; const group = promptsMap?.[mention.id];
if (!group) {
if (!isValidPrompt) {
return; return;
} }
const group = promptsMap[mention.id];
const hasVariables = detectVariables(group.productionPrompt?.prompt ?? ''); const hasVariables = detectVariables(group.productionPrompt?.prompt ?? '');
if (group && hasVariables) { if (hasVariables) {
if (e && e.key === 'Tab') { if (e && e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
} }
setVariableGroup(group); setVariableGroup(group);
setVariableDialogOpen(true); setVariableDialogOpen(true);
return; return;
} else if (group) { } else {
submitPrompt(group.productionPrompt?.prompt ?? ''); submitPrompt(group.productionPrompt?.prompt ?? '');
} }
}, },
@ -154,6 +157,37 @@ function PromptsCommand({
return null; 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 ( return (
<PopoverContainer <PopoverContainer
index={index} index={index}
@ -161,7 +195,7 @@ function PromptsCommand({
variableGroup={variableGroup} variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen} 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"> <div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input <input
// The user expects focus to transition to the input field when the popover is opened // The user expects focus to transition to the input field when the popover is opened
@ -213,24 +247,23 @@ function PromptsCommand({
} }
if (!isLoading && open) { if (!isLoading && open) {
return (matches as PromptOption[]).map((mention, index) => ( return (
<MentionItem <div className="max-h-40">
index={index} <AutoSizer disableHeight>
type="prompt" {({ width }) => (
key={`${mention.value}-${index}`} <List
onClick={() => { width={width}
if (timeoutRef.current) { overscanRowCount={5}
clearTimeout(timeoutRef.current); rowHeight={ROW_HEIGHT}
} rowCount={matches.length}
timeoutRef.current = null; rowRenderer={rowRenderer}
handleSelect(mention); scrollToIndex={activeIndex}
}} height={Math.min(matches.length * ROW_HEIGHT, 160)}
name={mention.label ?? ''} />
icon={mention.icon} )}
description={mention.description} </AutoSizer>
isActive={index === activeIndex} </div>
/> );
));
} }
return null; 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 { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, getConfigDefaults } from 'librechat-data-provider'; import { Constants, getConfigDefaults } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import temporaryStore from '~/store/temporary';
import { Switch } from '~/components/ui'; import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
export const TemporaryChat = () => { export const TemporaryChat = () => {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const defaultInterface = getConfigDefaults().interface; const defaultInterface = getConfigDefaults().interface;
const [isTemporary, setIsTemporary] = useRecoilState(temporaryStore.isTemporary); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? ''; const conversationId = conversation?.conversationId ?? '';
const interfaceConfig = useMemo( const interfaceConfig = useMemo(
@ -20,7 +21,7 @@ export const TemporaryChat = () => {
[startupConfig], [startupConfig],
); );
if (!interfaceConfig.temporaryChat) { if (interfaceConfig.temporaryChat === false) {
return null; return null;
} }
@ -39,20 +40,21 @@ export const TemporaryChat = () => {
}; };
return ( 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="flex items-center">
<div className={cn('flex flex-1 items-center gap-2', isActiveConvo && 'opacity-40')}> <div className={cn('flex flex-1 items-center gap-2', isActiveConvo && 'opacity-40')}>
<MessageCircleDashed className="icon-sm" /> <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>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<Switch <Switch
id="enableUserMsgMarkdown" id="temporary-chat-switch"
checked={isTemporary} checked={isTemporary}
onCheckedChange={onClick} onCheckedChange={onClick}
disabled={isActiveConvo} disabled={isActiveConvo}
className="ml-4" className="ml-4"
data-testid="enableUserMsgMarkdown" aria-label="Toggle temporary chat"
data-testid="temporary-chat-switch"
/> />
</div> </div>
</div> </div>

View file

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

View file

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

11
package-lock.json generated
View file

@ -16,6 +16,7 @@
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.9.1", "@axe-core/playwright": "^4.9.1",
"@playwright/test": "^1.38.1", "@playwright/test": "^1.38.1",
"@types/react-virtualized": "^9.22.0",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -15126,6 +15127,16 @@
"@types/react": "*" "@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": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",

View file

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