mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-02 00:28:51 +01:00
👐 style: Improve a11y/theming for Settings Dialog, Dropdown Menus; fix: SearchBar focus issues (#4091)
* fix: cursor pointer not applying correct in the root component * fix: add cursor-not-allowed to disabled state in SendButton component * feat: update Dropdown to ariakit and changed LLM error's style * feat: switched to ariakit's Dropdown and style improvements * feat: archive updates * refactor: delete conversations in archive * refactor: settings * add cool settings animation * a11y: settings update * style: update settings * style: settings account settings menu; a11y(AccountSettings): switched to AriaKit * a11y: account settings update * style: update my files dialog * fix: tests * chore: remove console.log() --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
eba2c9a032
commit
2d62eca612
58 changed files with 1054 additions and 824 deletions
|
|
@ -1,23 +1,20 @@
|
|||
import { FileText } from 'lucide-react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import { Fragment, useState, memo } from 'react';
|
||||
import { Menu, MenuItem, MenuButton, MenuItems, Transition } from '@headlessui/react';
|
||||
import { FileText, LogOut } from 'lucide-react';
|
||||
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator } from '~/components';
|
||||
import FilesView from '~/components/Chat/Input/Files/FilesView';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import useAvatar from '~/hooks/Messages/useAvatar';
|
||||
import { LinkIcon, GearIcon } from '~/components';
|
||||
import { UserIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Settings from './Settings';
|
||||
import NavLink from './NavLink';
|
||||
import Logout from './Logout';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
function AccountSettings() {
|
||||
const localize = useLocalize();
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const { user, isAuthenticated, logout } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const balanceQuery = useGetUserBalance({
|
||||
enabled: !!isAuthenticated && startupConfig?.checkBalance,
|
||||
|
|
@ -29,115 +26,105 @@ function AccountSettings() {
|
|||
const name = user?.avatar ?? user?.username ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu as="div" className="group relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<MenuButton
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
className={cn(
|
||||
'group-ui-open:bg-surface-tertiary duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-surface-secondary',
|
||||
open ? 'bg-surface-secondary' : '',
|
||||
)}
|
||||
data-testid="nav-user"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
{name.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
) : (
|
||||
<img className="rounded-full" src={user?.avatar ?? avatarSrc} alt="avatar" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Select.SelectProvider>
|
||||
<Select.Select
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-accent"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
{name.length === 0 ? (
|
||||
<div
|
||||
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
|
||||
style={{ marginTop: '0', marginLeft: '0' }}
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{user?.name ?? user?.username ?? localize('com_nav_user')}
|
||||
<UserIcon />
|
||||
</div>
|
||||
</MenuButton>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100 transform"
|
||||
enterFrom="translate-y-2 opacity-0"
|
||||
enterTo="translate-y-0 opacity-100"
|
||||
leave="transition ease-in duration-100 transform"
|
||||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo="translate-y-2 opacity-0"
|
||||
>
|
||||
<MenuItems className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-border-medium bg-header-primary p-1.5 opacity-100 shadow-lg outline-none">
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="none">
|
||||
{user?.email ?? localize('com_nav_user')}
|
||||
</div>
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
{startupConfig?.checkBalance === true &&
|
||||
balanceQuery.data != null &&
|
||||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
<>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm">
|
||||
{`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`}
|
||||
</div>
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
</>
|
||||
)}
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <FileText className="icon-md" />}
|
||||
text={localize('com_nav_my_files')}
|
||||
clickHandler={() => setShowFiles(true)}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
{startupConfig?.helpAndFaqURL !== '/' && (
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <LinkIcon />}
|
||||
text={localize('com_nav_help_faq')}
|
||||
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <GearIcon className="icon-md" />}
|
||||
text={localize('com_nav_settings')}
|
||||
clickHandler={() => {
|
||||
setTimeout(() => setShowSettings(true), 50);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
<MenuItem>
|
||||
{({ focus }) => <Logout className={focus ? 'bg-surface-hover' : ''} />}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
) : (
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={user?.avatar ?? avatarSrc}
|
||||
alt={`${name}'s avatar`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
|
||||
style={{ marginTop: '0', marginLeft: '0' }}
|
||||
>
|
||||
{user?.name ?? user?.username ?? localize('com_nav_user')}
|
||||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[235px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
marginRight: '0px',
|
||||
translate: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
{user?.email ?? localize('com_nav_user')}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{startupConfig?.checkBalance === true &&
|
||||
balanceQuery.data != null &&
|
||||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
<>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
{`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<Select.SelectItem
|
||||
value=""
|
||||
onClick={() => setShowFiles(true)}
|
||||
className="select-item text-sm"
|
||||
>
|
||||
<FileText className="icon-md" aria-hidden="true" />
|
||||
{localize('com_nav_my_files')}
|
||||
</Select.SelectItem>
|
||||
{startupConfig?.helpAndFaqURL !== '/' && (
|
||||
<Select.SelectItem
|
||||
value=""
|
||||
onClick={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
|
||||
className="select-item text-sm"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" />
|
||||
{localize('com_nav_help_faq')}
|
||||
</Select.SelectItem>
|
||||
)}
|
||||
<Select.SelectItem
|
||||
value=""
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="select-item text-sm"
|
||||
>
|
||||
<GearIcon className="icon-md" aria-hidden="true" />
|
||||
{localize('com_nav_settings')}
|
||||
</Select.SelectItem>
|
||||
<DropdownMenuSeparator />
|
||||
<Select.SelectItem
|
||||
aria-selected={true}
|
||||
onClick={() => logout()}
|
||||
value="logout"
|
||||
className="select-item text-sm"
|
||||
>
|
||||
<LogOut className="icon-md" />
|
||||
{localize('com_nav_log_out')}
|
||||
</Select.SelectItem>
|
||||
</Select.SelectPopover>
|
||||
{showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />}
|
||||
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
|
||||
</>
|
||||
</Select.SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { LogOutIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Logout = forwardRef<HTMLButtonElement, { className?: string }>((props, ref) => {
|
||||
const { logout } = useAuthContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group group flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-hover data-[focus]:bg-surface-hover',
|
||||
props.className ?? '',
|
||||
)}
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<LogOutIcon />
|
||||
{localize('com_nav_log_out')}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default Logout;
|
||||
|
|
@ -16,7 +16,7 @@ const NavLink: FC<Props> = forwardRef<HTMLButtonElement, Props>((props, ref) =>
|
|||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
} = {
|
||||
className: cn(
|
||||
'w-full flex gap-2 rounded p-2.5 text-sm cursor-pointer group items-center transition-colors duration-200 text-text-primary hover:bg-surface-hover',
|
||||
'w-full flex gap-2 rounded p-2.5 text-sm cursor-pointer group items-center transition-colors duration-200 text-text-primary',
|
||||
className,
|
||||
{
|
||||
'opacity-50 pointer-events-none': disabled,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default function NavToggle({
|
|||
description={
|
||||
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
|
||||
}
|
||||
className="flex cursor-pointer items-center justify-center"
|
||||
className="flex items-center justify-center"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="" data-state="closed">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
|
|||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import { NewChatIcon } from '~/components/svg';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -96,15 +95,7 @@ export default function NewChat({
|
|||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex items-center" data-state="closed">
|
||||
<TooltipAnchor
|
||||
side="right"
|
||||
id="nav-new-chat-btn"
|
||||
aria-label="nav-new-chat-btn"
|
||||
description={localize('com_ui_new_chat')}
|
||||
className="text-text-primary"
|
||||
>
|
||||
<NewChatIcon className="size-5" />
|
||||
</TooltipAnchor>
|
||||
<NewChatIcon className="size-5" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
const setSearchQuery = useSetRecoilState(store.searchQuery);
|
||||
const [showClearIcon, setShowClearIcon] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const setIsSearching = useSetRecoilState(store.isSearching);
|
||||
const localize = useLocalize();
|
||||
|
||||
const clearText = useCallback(() => {
|
||||
|
|
@ -47,6 +48,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
},
|
||||
[queryClient, clearConvoState, setSearchQuery],
|
||||
);
|
||||
|
||||
// TODO: make the debounce time configurable via yaml
|
||||
const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]);
|
||||
|
||||
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
|
|
@ -54,6 +57,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
setShowClearIcon(value.length > 0);
|
||||
setText(value);
|
||||
debouncedSendRequest(value);
|
||||
setIsSearching(true);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -78,6 +82,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
aria-label={localize('com_nav_search_placeholder')}
|
||||
placeholder={localize('com_nav_search_placeholder')}
|
||||
onKeyUp={handleKeyUp}
|
||||
onFocus={() => setIsSearching(true)}
|
||||
onBlur={() => setIsSearching(true)}
|
||||
autoComplete="off"
|
||||
dir="auto"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { MessageSquare, Command } from 'lucide-react';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
|
|
@ -12,7 +12,8 @@ import { cn } from '~/utils';
|
|||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const localize = useLocalize();
|
||||
const [activeTab, setActiveTab] = React.useState(SettingsTabValues.GENERAL);
|
||||
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
|
||||
const tabRefs = useRef({});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const tabs = [
|
||||
|
|
@ -28,12 +29,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[(currentIndex + 1) % tabs.length]);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
|
||||
break;
|
||||
|
|
@ -48,6 +47,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as SettingsTabValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={open}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onOpenChange}>
|
||||
|
|
@ -55,7 +58,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
|
|
@ -70,15 +73,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 flex w-screen items-center justify-center p-4',
|
||||
isSmallScreen ? '' : '',
|
||||
)}
|
||||
>
|
||||
<div className={cn('fixed inset-0 flex w-screen items-center justify-center p-4')}>
|
||||
<DialogPanel
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl rounded-b-lg bg-surface-tertiary-alt pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-lg md:min-h-[373px] md:w-[680px]',
|
||||
'min-h-[600px] overflow-hidden rounded-xl rounded-b-lg bg-background pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-lg md:min-h-[373px] md:w-[680px]',
|
||||
)}
|
||||
>
|
||||
<DialogTitle
|
||||
|
|
@ -111,18 +109,18 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]">
|
||||
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
||||
<Tabs.Root
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => setActiveTab(value as SettingsTabValues)}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col gap-10 md:flex-row"
|
||||
orientation="horizontal"
|
||||
orientation="vertical"
|
||||
>
|
||||
<Tabs.List
|
||||
aria-label="Settings"
|
||||
className={cn(
|
||||
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
|
||||
isSmallScreen ? 'flex-row rounded-lg bg-surface-secondary' : '',
|
||||
'min-w-auto max-w-auto relative -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
|
||||
isSmallScreen ? 'flex-row rounded-xl bg-surface-secondary' : '',
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
|
|
@ -166,19 +164,20 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<Tabs.Trigger
|
||||
key={value}
|
||||
className={cn(
|
||||
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active',
|
||||
'group relative z-10 m-1 flex items-center justify-start gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary'
|
||||
: 'bg-surface-tertiary-alt',
|
||||
? 'flex-1 justify-center text-nowrap rounded-xl p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
|
||||
: 'rounded-md bg-transparent text-text-primary radix-state-active:bg-surface-tertiary',
|
||||
)}
|
||||
value={value}
|
||||
ref={(el) => (tabRefs.current[value] = el)}
|
||||
>
|
||||
{icon}
|
||||
{localize(label)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
|
||||
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
|
||||
<Tabs.Content value={SettingsTabValues.GENERAL}>
|
||||
<General />
|
||||
</Tabs.Content>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import CodeArtifacts from './CodeArtifacts';
|
|||
function Beta() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<CodeArtifacts />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,26 +11,26 @@ import SaveDraft from './SaveDraft';
|
|||
function Chat() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<FontSizeSelector />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<ChatDirection />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<SendMessageKeyEnter />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<ShowCodeSwitch />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<SaveDraft />
|
||||
</div>
|
||||
<ForkSettings />
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<ModularChat />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<LaTeXParsing />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from '~/components';
|
||||
import store from '~/store';
|
||||
|
||||
const ChatDirection = () => {
|
||||
|
|
@ -16,12 +17,11 @@ const ChatDirection = () => {
|
|||
<div className="flex items-center space-x-2">
|
||||
<span id="chat-direction-label">{localize('com_nav_chat_direction')}</span>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Toggle chat direction"
|
||||
onClick={toggleChatDirection}
|
||||
data-testid="chatDirection"
|
||||
className="btn btn-neutral relative ring-ring-primary"
|
||||
aria-labelledby="chat-direction-label chat-direction-status"
|
||||
aria-pressed={direction === 'RTL'}
|
||||
>
|
||||
<span aria-hidden="true">{direction.toLowerCase()}</span>
|
||||
<span id="chat-direction-status" className="sr-only">
|
||||
|
|
@ -29,7 +29,7 @@ const ChatDirection = () => {
|
|||
? localize('chat_direction_left_to_right')
|
||||
: localize('chat_direction_right_to_left')}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const ForkSettings = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{localize('com_ui_fork_change_default')}</div>
|
||||
|
|
@ -30,12 +30,11 @@ export const ForkSettings = () => {
|
|||
onChange={setForkSetting}
|
||||
options={forkOptions}
|
||||
sizeClasses="w-[200px]"
|
||||
anchor="bottom start"
|
||||
testId="fork-setting-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_ui_fork_default')} </div>
|
||||
<Switch
|
||||
|
|
@ -47,7 +46,7 @@ export const ForkSettings = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{localize('com_ui_fork_split_target_setting')}</div>
|
||||
|
|
|
|||
|
|
@ -28,16 +28,16 @@ function Commands() {
|
|||
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<AtCommandSwitch />
|
||||
</div>
|
||||
{hasAccessToMultiConvo === true && (
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<PlusCommandSwitch />
|
||||
</div>
|
||||
)}
|
||||
{hasAccessToPrompts === true && (
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<SlashCommandSwitch />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,45 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
|
||||
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
||||
import { Spinner, TooltipAnchor, TrashIcon } from '~/components';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TooltipAnchor,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
} from '~/components';
|
||||
|
||||
function SharedLinkDeleteButton({
|
||||
shareId,
|
||||
setIsDeleting,
|
||||
}: {
|
||||
shareId: string;
|
||||
setIsDeleting: (isDeleting: boolean) => void;
|
||||
}) {
|
||||
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const localize = useLocalize();
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const mutation = useDeleteSharedLinkMutation({
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
setIsDeleting(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
|
||||
if (mutation.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -38,67 +47,78 @@ function SharedLinkDeleteButton({
|
|||
await mutation.mutateAsync({ shareId });
|
||||
setIsDeleting(false);
|
||||
};
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
id="delete-shared-link"
|
||||
aria-label="Delete shared link"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</TooltipAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={sharedLink.conversationId}
|
||||
className="border-b border-gray-200 text-sm font-normal dark:border-white/10"
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
'flex items-center py-3 text-blue-800/70 dark:text-blue-500',
|
||||
isDeleting && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<Link to={`/share/${sharedLink.shareId}`} target="_blank" rel="noreferrer" className="flex">
|
||||
<LinkIcon className="mr-1 h-5 w-5" />
|
||||
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/share/${sharedLink.shareId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center text-blue-500 hover:underline"
|
||||
>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{sharedLink.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex justify-between">
|
||||
<div className={cn('flex justify-start dark:text-gray-200', isDeleting && 'opacity-50')}>
|
||||
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 text-gray-400',
|
||||
isDeleting && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{sharedLink.conversationId && (
|
||||
<div className={cn('cursor-pointer', !isDeleting && 'hover:text-gray-300')}>
|
||||
<SharedLinkDeleteButton
|
||||
shareId={sharedLink.shareId}
|
||||
setIsDeleting={setIsDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sharedLink.conversationId && (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
aria-label="Delete shared link"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_conversation')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label
|
||||
htmlFor="dialog-confirm-delete"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => confirmDelete(sharedLink.shareId),
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
export default function ShareLinkTable({ className }: { className?: string }) {
|
||||
|
||||
export default function ShareLinkTable({ className }) {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
|
@ -114,15 +134,28 @@ export default function ShareLinkTable({ className }: { className?: string }) {
|
|||
});
|
||||
|
||||
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
|
||||
const classProp: { className?: string } = {
|
||||
className: 'p-1 hover:text-black dark:hover:text-white',
|
||||
};
|
||||
if (className) {
|
||||
classProp.className = className;
|
||||
}
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
return (
|
||||
<div key={index} className="flex h-10 w-full items-center">
|
||||
<div className="flex w-[410px] items-center">
|
||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="mr-2 flex justify-end">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white" />;
|
||||
return <div className="text-gray-300">{skeletons}</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
|
|
@ -132,35 +165,34 @@ export default function ShareLinkTable({ className }: { className?: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
if (!sharedLinks || sharedLinks.length === 0) {
|
||||
|
||||
if (sharedLinks.length === 0) {
|
||||
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full gap-2',
|
||||
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
'max-h-[350px]',
|
||||
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
className,
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<table className="table-fixed text-left">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-700">
|
||||
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200">
|
||||
<th className="p-3">{localize('com_nav_shared_links_name')}</th>
|
||||
<th className="p-3">{localize('com_nav_shared_links_date_shared')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
|
||||
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
|
||||
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sharedLinks.map((sharedLink) => (
|
||||
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(isFetchingNextPage || showLoading) && (
|
||||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { OGDialog, OGDialogTrigger } from '~/components/ui';
|
||||
import { OGDialog, OGDialogTrigger, Button } from '~/components';
|
||||
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
|
||||
|
|
@ -12,9 +12,9 @@ export default function ArchivedChats() {
|
|||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<button className="btn btn-neutral relative ">
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_nav_archived_chats_manage')}
|
||||
</button>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_nav_archived_chats')}
|
||||
|
|
|
|||
|
|
@ -1,109 +1,252 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { MessageCircle, ArchiveRestore } from 'lucide-react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||
import { ConversationListResponse } from 'librechat-data-provider';
|
||||
import { useAuthContext, useLocalize, useNavScrolling, useArchiveHandler } from '~/hooks';
|
||||
import { DeleteButton } from '~/components/Conversations/ConvoOptions';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
TrashIcon,
|
||||
MessageCircle,
|
||||
ArchiveRestore,
|
||||
ChevronsRight,
|
||||
ChevronsLeft,
|
||||
} from 'lucide-react';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useAuthContext, useLocalize, useArchiveHandler } from '~/hooks';
|
||||
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
|
||||
import {
|
||||
TooltipAnchor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Button,
|
||||
Input,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
} from '~/components';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArchivedChatsTable() {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery(
|
||||
{ pageNumber: '1', isArchived: true },
|
||||
{ enabled: isAuthenticated },
|
||||
const { data, isLoading, refetch } = useConversationsInfiniteQuery(
|
||||
{ pageNumber: currentPage.toString(), limit: 10, isArchived: true },
|
||||
{ enabled: isAuthenticated && isOpened },
|
||||
);
|
||||
|
||||
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: hasNextPage,
|
||||
fetchNextPage: fetchNextPage,
|
||||
isFetchingNextPage: isFetchingNextPage,
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setTotalPages(Math.ceil(Number(data.pages)));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const archiveHandler = useArchiveHandler(conversationId ?? '', false, () => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
const conversations = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.conversations) || [],
|
||||
[data],
|
||||
);
|
||||
const handleChatClick = useCallback((conversationId) => {
|
||||
window.open(`/c/${conversationId}`, '_blank');
|
||||
}, []);
|
||||
|
||||
const archiveHandler = useArchiveHandler(conversationId ?? '', false, moveToTop);
|
||||
const handlePageChange = useCallback((newPage) => {
|
||||
setCurrentPage(newPage);
|
||||
}, []);
|
||||
|
||||
if (!data || conversations.length === 0) {
|
||||
const handleSearch = useCallback((query) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
return (
|
||||
<div key={index} className="flex h-10 w-full items-center">
|
||||
<div className="flex w-[410px] items-center">
|
||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="mr-2 flex justify-end">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-300">{skeletons}</div>;
|
||||
}
|
||||
|
||||
if (!data || data.pages.length === 0 || data.pages[0].conversations.length === 0) {
|
||||
return <div className="text-gray-300">{localize('com_nav_archived_chats_empty')}</div>;
|
||||
}
|
||||
|
||||
const conversations = data.pages.flatMap((page) => page.conversations);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full gap-2',
|
||||
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
'max-h-[350px]',
|
||||
'max-h-[629px]',
|
||||
)}
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setIsOpened(true)}
|
||||
>
|
||||
<table className="table-fixed text-left">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-700">
|
||||
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200">
|
||||
<th className="p-3">{localize('com_nav_archive_name')}</th>
|
||||
<th className="p-3">{localize('com_nav_archive_created_at')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conversations.map((conversation) => {
|
||||
if (!conversation.conversationId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={conversation.conversationId}
|
||||
className="border-b border-gray-200 text-sm font-normal dark:border-white/10"
|
||||
>
|
||||
<td className="flex items-center py-3 text-blue-800/70 dark:text-blue-500">
|
||||
<MessageCircle className="mr-1 h-5 w-5" />
|
||||
{conversation.title}
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-start dark:text-gray-200">
|
||||
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div className="ml-auto mr-4 flex items-center justify-end gap-1 text-gray-400">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
onClick={() => {
|
||||
setConversationId(conversation.conversationId);
|
||||
archiveHandler();
|
||||
}}
|
||||
className="cursor-pointer hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<ArchiveRestore className="size-4 hover:text-gray-300" />
|
||||
</TooltipAnchor>
|
||||
<div className="size-5 hover:text-gray-300">
|
||||
<DeleteButton
|
||||
conversationId={conversation.conversationId}
|
||||
retainView={moveToTop}
|
||||
title={conversation.title ?? ''}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Search className="size-4 text-text-secondary" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={localize('com_nav_search_placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full border-none"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
{conversations.length === 0 ? (
|
||||
<div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50%] p-4">{localize('com_nav_archive_name')}</TableHead>
|
||||
<TableHead className="w-[35%] p-1">
|
||||
{localize('com_nav_archive_created_at')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[15%] p-1 text-right">
|
||||
{localize('com_assistants_actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conversations.map((conversation: TConversation) => (
|
||||
<TableRow key={conversation.conversationId} className="hover:bg-transparent">
|
||||
<TableCell className="flex items-center py-3 text-text-primary">
|
||||
<button
|
||||
className="flex"
|
||||
aria-label="Open conversation in a new tab"
|
||||
onClick={() => handleChatClick(conversation.conversationId)}
|
||||
>
|
||||
<MessageCircle className="mr-1 h-5 w-5" />
|
||||
<u>{conversation.title}</u>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-start text-text-secondary">
|
||||
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(isFetchingNextPage || showLoading) && (
|
||||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
|
||||
</TableCell>
|
||||
<TableCell className="flex items-center justify-end gap-2 p-1">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unarchive')}
|
||||
render={
|
||||
<Button
|
||||
aria-label="Unarchive conversation"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => {
|
||||
setConversationId(conversation.conversationId);
|
||||
archiveHandler();
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
aria-label="Delete archived conversation"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</OGDialogTrigger>
|
||||
{DeleteConversationDialog({
|
||||
conversationId: conversation.conversationId ?? '',
|
||||
retainView: refetch,
|
||||
title: conversation.title ?? '',
|
||||
})}
|
||||
</OGDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-end gap-6 px-2 py-4">
|
||||
<div className="text-sm font-bold text-text-primary">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the previous 10 pages"
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the previous page"
|
||||
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the next page"
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Go to the next 10 pages"
|
||||
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronsRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export default function AutoScrollSwitch({
|
|||
<Switch
|
||||
id="autoScroll"
|
||||
checked={autoScroll}
|
||||
aria-label="Auto-Scroll to latest message on chat open"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4 mt-2 ring-ring-primary"
|
||||
data-testid="autoScroll"
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ export const ThemeSelector = ({
|
|||
value={theme}
|
||||
onChange={onChange}
|
||||
options={themeOptions}
|
||||
sizeClasses="w-[220px]"
|
||||
anchor="bottom start"
|
||||
sizeClasses="w-[180px]"
|
||||
testId="theme-selector"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -112,7 +111,6 @@ export const LangSelector = ({
|
|||
value={langcode}
|
||||
onChange={onChange}
|
||||
sizeClasses="[--anchor-max-height:256px]"
|
||||
anchor="bottom start"
|
||||
options={languageOptions}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -149,26 +147,24 @@ function General() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<ThemeSelector theme={theme} onChange={changeTheme} />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<LangSelector langcode={langcode} onChange={changeLang} />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<UserMsgMarkdownSwitch />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<AutoScrollSwitch />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<HideSidePanelSwitch />
|
||||
</div>
|
||||
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
|
||||
<div className="border-b border-border-light pb-3 last-of-type:border-b-0">
|
||||
<ArchivedChats />
|
||||
</div>
|
||||
{/* <div className="border-b pb-3 last-of-type:border-b-0 border-border-medium">
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export default function HideSidePanelSwitch({
|
|||
<Switch
|
||||
id="hideSidePanel"
|
||||
checked={hideSidePanel}
|
||||
aria-label="Hide right-most side panel"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4 mt-2"
|
||||
data-testid="hideSidePanel"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'test/matchMedia.mock';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { LangSelector } from './General';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
|
@ -18,14 +18,15 @@ describe('LangSelector', () => {
|
|||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
const { getByText } = render(
|
||||
const { getByText, getByRole } = render(
|
||||
<RecoilRoot>
|
||||
<LangSelector langcode="en-US" onChange={mockOnChange} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByText('Language')).toBeInTheDocument();
|
||||
expect(getByText('English')).toBeInTheDocument();
|
||||
const dropdownButton = getByRole('combobox');
|
||||
expect(dropdownButton).toHaveTextContent('English');
|
||||
});
|
||||
|
||||
it('calls onChange when the select value changes', async () => {
|
||||
|
|
@ -34,25 +35,23 @@ describe('LangSelector', () => {
|
|||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
const { getByText, getByTestId } = render(
|
||||
const { getByRole, getByTestId } = render(
|
||||
<RecoilRoot>
|
||||
<LangSelector langcode="en-US" onChange={mockOnChange} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByText('English')).toBeInTheDocument();
|
||||
expect(getByRole('combobox')).toHaveTextContent('English');
|
||||
|
||||
// Find the dropdown button by data-testid
|
||||
const dropdownButton = getByTestId('dropdown-menu');
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(dropdownButton);
|
||||
|
||||
// Find the option by text and click it
|
||||
const darkOption = getByText('Italiano');
|
||||
fireEvent.click(darkOption);
|
||||
const italianOption = getByRole('option', { name: 'Italiano' });
|
||||
fireEvent.click(italianOption);
|
||||
|
||||
// Ensure that the onChange is called with the expected value after a short delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await waitFor(() => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('it-IT');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ describe('ThemeSelector', () => {
|
|||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
const { getByText } = render(
|
||||
const { getByText, getByRole } = render(
|
||||
<RecoilRoot>
|
||||
<ThemeSelector theme="system" onChange={mockOnChange} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByText('Theme')).toBeInTheDocument();
|
||||
expect(getByText('System')).toBeInTheDocument();
|
||||
const dropdownButton = getByRole('combobox');
|
||||
expect(dropdownButton).toHaveTextContent('System');
|
||||
});
|
||||
|
||||
it('calls onChange when the select value changes', async () => {
|
||||
|
|
@ -44,17 +45,13 @@ describe('ThemeSelector', () => {
|
|||
|
||||
expect(getByText('Theme')).toBeInTheDocument();
|
||||
|
||||
// Find the dropdown button by data-testid
|
||||
const dropdownButton = getByTestId('theme-selector');
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(dropdownButton);
|
||||
|
||||
// Find the option by text and click it
|
||||
const darkOption = getByText('Dark');
|
||||
fireEvent.click(darkOption);
|
||||
|
||||
// Ensure that the onChange is called with the expected value
|
||||
await waitFor(() => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
onChange={handleSelect}
|
||||
options={endpointOptions}
|
||||
sizeClasses="w-[180px]"
|
||||
anchor="bottom start"
|
||||
testId="EngineSTTDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -146,14 +146,12 @@ function Speech() {
|
|||
value={advancedMode ? 'advanced' : 'simple'}
|
||||
>
|
||||
<div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700">
|
||||
<Tabs.List className="flex justify-center bg-white dark:bg-gray-700">
|
||||
<Tabs.List className="flex justify-center bg-background">
|
||||
<Tabs.Trigger
|
||||
onClick={() => setAdvancedMode(false)}
|
||||
className={cn(
|
||||
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
|
||||
isSmallScreen
|
||||
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
|
||||
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
|
||||
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
|
||||
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
|
||||
'w-full',
|
||||
)}
|
||||
value="simple"
|
||||
|
|
@ -165,10 +163,8 @@ function Speech() {
|
|||
<Tabs.Trigger
|
||||
onClick={() => setAdvancedMode(true)}
|
||||
className={cn(
|
||||
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
|
||||
isSmallScreen
|
||||
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
|
||||
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
|
||||
'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
|
||||
isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
|
||||
'w-full',
|
||||
)}
|
||||
value="advanced"
|
||||
|
|
@ -181,79 +177,53 @@ function Speech() {
|
|||
</div>
|
||||
|
||||
<Tabs.Content value={'simple'}>
|
||||
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<SpeechToTextSwitch />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<LanguageSTTDropdown />
|
||||
</div>
|
||||
<div className="h-px bg-black/20 bg-white/20" role="none" />
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<TextToSpeechSwitch />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<VoiceDropdown />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<SpeechToTextSwitch />
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
<LanguageSTTDropdown />
|
||||
<div className="h-px bg-border-medium" role="none" />
|
||||
<TextToSpeechSwitch />
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
<VoiceDropdown />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value={'advanced'}>
|
||||
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<ConversationModeSwitch />
|
||||
</div>
|
||||
<div className="h-px bg-black/20 bg-white/20" role="none" />
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<SpeechToTextSwitch />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<LanguageSTTDropdown />
|
||||
</div>
|
||||
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<ConversationModeSwitch />
|
||||
<div className="mt-2 h-px bg-border-medium" role="none" />
|
||||
<SpeechToTextSwitch />
|
||||
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
|
||||
<LanguageSTTDropdown />
|
||||
<div className="pb-2">
|
||||
<AutoTranscribeAudioSwitch />
|
||||
</div>
|
||||
{autoTranscribeAudio && (
|
||||
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="pb-2">
|
||||
<DecibelSelector />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="pb-2">
|
||||
<AutoSendTextSelector />
|
||||
</div>
|
||||
<div className="h-px bg-black/20 bg-white/20" role="none" />
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="h-px bg-border-medium" role="none" />
|
||||
<div className="pb-3">
|
||||
<TextToSpeechSwitch />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<AutomaticPlaybackSwitch />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<VoiceDropdown />
|
||||
</div>
|
||||
<AutomaticPlaybackSwitch />
|
||||
<EngineTTSDropdown external={ttsExternal} />
|
||||
<VoiceDropdown />
|
||||
{engineTTS === 'browser' && (
|
||||
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="pb-2">
|
||||
<CloudBrowserVoicesSwitch />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<div className="pb-2">
|
||||
<PlaybackRate />
|
||||
</div>
|
||||
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
|
||||
<CacheTTSSwitch />
|
||||
</div>
|
||||
<CacheTTSSwitch />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export * from './ExportConversation';
|
||||
export * from './SettingsTabs/';
|
||||
export { default as ClearConvos } from './ClearConvos';
|
||||
export { default as Logout } from './Logout';
|
||||
export { default as MobileNav } from './MobileNav';
|
||||
export { default as Nav } from './Nav';
|
||||
export { default as NavLink } from './NavLink';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue