🎛️ feat: Redesign Settings with Registry-Driven Dialog, Search, and Mobile Drill-In (#13722)

* i18n: add settings reorganization keys

* feat(settings): add tab/section types and tab metadata

* feat(settings): add useSettingsContext guard hook

* feat(settings): add pure settings search filter with tests

* feat(settings): extract selectors and add control wrappers

* feat(settings): add setting registry, memory and billing controls, integrity test

* feat(settings): add Section and Advanced disclosure with test

* feat(settings): add content pane with tab and search views

* feat(settings): add sidebar and dialog shell with tests

* refactor(settings): wire new dialog and remove superseded containers

* fix(settings): restore speech external engine option, escape-to-clear search, results a11y

- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
  instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx

* refactor(settings): reorganize categories, remove advanced disclosure, add About

- Re-categorize settings into logical groups (username display -> Chat/Messages,
  keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
  actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev

* feat(settings): move archived chats to the account menu

Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.

* feat(settings): polish About panel and use shared CopyButton

- Flatten the build-info into a single divided key/value list (drop the
  redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping

* fix(settings): set primary text color on setting rows for dark mode

Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.

* fix(settings): use visible icon for dialog close button

The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.

* fix(nav): drop focus ring on account menu items, use hover background only

The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.

* fix(settings): replace native search clear with a real X button

The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.

* fix(speech): disable dependent dropdowns and switches when STT/TTS is off

Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.

* feat(settings): mobile drill-in navigation for settings tabs

On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.

* chore(settings): remove orphaned i18n keys, fix import order and review notes

- Drop the i18n keys left unused after the refactor (old Commands/Balance/
  Personalization tab labels, the Speech simple/advanced labels, and the
  former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace

* feat(settings): searchable language dropdown

Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.

Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.

* fix(settings): restore guards dropped in dialog refactor

- Fall back to the General tab when the active tab becomes hidden
  (e.g. About when buildInfo is disabled) instead of rendering an
  empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
  browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.

* test(e2e): match agent-creation toast exactly to avoid SR-announce collision

The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.

* fix(settings): render the active panel as a tabpanel

Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
This commit is contained in:
Marco Beretta 2026-06-18 14:51:07 +02:00 committed by GitHub
parent d8474864e9
commit 9de3249e9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1808 additions and 1392 deletions

View file

@ -6,7 +6,7 @@ import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
import { logger } from '~/utils';
import store from '~/store';
export function BrowserVoiceDropdown() {
export function BrowserVoiceDropdown({ disabled = false }: { disabled?: boolean }) {
const localize = useLocalize();
const { voices = [] } = useTTSBrowser();
const [voice, setVoice] = useRecoilState(store.voice);
@ -33,12 +33,13 @@ export function BrowserVoiceDropdown() {
testId="BrowserVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
disabled={disabled}
/>
</div>
);
}
export function ExternalVoiceDropdown() {
export function ExternalVoiceDropdown({ disabled = false }: { disabled?: boolean }) {
const localize = useLocalize();
const { voices = [] } = useTTSExternal();
const [voice, setVoice] = useRecoilState(store.voice);
@ -65,6 +66,7 @@ export function ExternalVoiceDropdown() {
testId="ExternalVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
disabled={disabled}
/>
</div>
);

View file

@ -1,7 +1,8 @@
import { useState, memo, useRef } from 'react';
import * as Menu from '@ariakit/react/menu';
import { FileText, LogOut } from 'lucide-react';
import { FileText, Archive, LogOut } from 'lucide-react';
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import { ArchivedChatsModal } from '~/components/Nav/SettingsTabs/General/ArchivedChatsModal';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
@ -17,6 +18,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
});
const [showSettings, setShowSettings] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
return (
@ -72,6 +74,10 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
<FileText className="icon-md" aria-hidden="true" />
{localize('com_nav_my_files')}
</Menu.MenuItem>
<Menu.MenuItem onClick={() => setShowArchived(true)} className="select-item text-sm">
<Archive className="icon-md" aria-hidden="true" />
{localize('com_nav_archived_chats')}
</Menu.MenuItem>
{startupConfig?.helpAndFaqURL !== '/' && (
<Menu.MenuItem
onClick={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
@ -98,6 +104,13 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
triggerRef={accountSettingsButtonRef}
/>
)}
{showArchived && (
<ArchivedChatsModal
open={showArchived}
onOpenChange={setShowArchived}
triggerRef={accountSettingsButtonRef}
/>
)}
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
</Menu.MenuProvider>
);

View file

@ -1,85 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Settings from './Settings';
const mockUseGetStartupConfig = jest.fn();
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/hooks/usePersonalizationAccess', () => ({
__esModule: true,
default: () => ({
hasMemoryOptOut: false,
hasAnyPersonalizationFeature: false,
}),
}));
jest.mock('@librechat/client', () => ({
GearIcon: () => <span aria-hidden="true" />,
DataIcon: () => <span aria-hidden="true" />,
UserIcon: () => <span aria-hidden="true" />,
SpeechIcon: () => <span aria-hidden="true" />,
PersonalizationIcon: () => <span aria-hidden="true" />,
useMediaQuery: () => false,
}));
jest.mock('./SettingsTabs', () => ({
General: () => <div data-testid="general-panel" />,
Chat: () => <div data-testid="chat-panel" />,
Commands: () => <div data-testid="commands-panel" />,
Speech: () => <div data-testid="speech-panel" />,
Personalization: () => <div data-testid="personalization-panel" />,
Data: () => <div data-testid="data-panel" />,
Balance: () => <div data-testid="balance-panel" />,
Account: () => <div data-testid="account-panel" />,
About: () => <div data-testid="about-panel" />,
}));
function renderSettings() {
return render(<Settings open={true} onOpenChange={jest.fn()} />);
}
beforeEach(() => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
});
describe('Settings', () => {
it('shows the About tab while startup config is loading', () => {
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
renderSettings();
expect(screen.getByText('com_nav_setting_about')).toBeInTheDocument();
});
it('hides the About tab only when buildInfo is explicitly disabled', () => {
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
renderSettings();
expect(screen.queryByText('com_nav_setting_about')).not.toBeInTheDocument();
});
it('resets the active tab when loaded config disables About', async () => {
const user = userEvent.setup();
const { rerender } = renderSettings();
await user.click(screen.getByText('com_nav_setting_about'));
expect(screen.getByTestId('about-panel')).toBeInTheDocument();
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
rerender(<Settings open={true} onOpenChange={jest.fn()} />);
await waitFor(() => {
expect(screen.queryByTestId('about-panel')).not.toBeInTheDocument();
});
expect(screen.getByTestId('general-panel')).toBeInTheDocument();
});
});

View file

@ -1,286 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign, Info } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import {
GearIcon,
DataIcon,
UserIcon,
SpeechIcon,
useMediaQuery,
PersonalizationIcon,
} from '@librechat/client';
import type { TDialogProps } from '~/common';
import {
General,
Chat,
Commands,
Speech,
Personalization,
Data,
Balance,
Account,
About,
} from './SettingsTabs';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { cn } from '~/utils';
import { SettingsDialog } from './Settings/index';
export default function Settings({ open, onOpenChange }: TDialogProps) {
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { data: startupConfig } = useGetStartupConfig();
const localize = useLocalize();
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
useEffect(() => {
if (!aboutEnabled && activeTab === SettingsTabValues.ABOUT) {
setActiveTab(SettingsTabValues.GENERAL);
}
}, [aboutEnabled, activeTab]);
const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs: SettingsTabValues[] = [
SettingsTabValues.GENERAL,
SettingsTabValues.CHAT,
SettingsTabValues.COMMANDS,
SettingsTabValues.SPEECH,
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
SettingsTabValues.ACCOUNT,
...(aboutEnabled ? [SettingsTabValues.ABOUT] : []),
];
const currentIndex = tabs.indexOf(activeTab);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveTab(tabs[(currentIndex + 1) % tabs.length]);
break;
case 'ArrowUp':
event.preventDefault();
setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
break;
case 'Home':
event.preventDefault();
setActiveTab(tabs[0]);
break;
case 'End':
event.preventDefault();
setActiveTab(tabs[tabs.length - 1]);
break;
}
};
const settingsTabs: {
value: SettingsTabValues;
icon: React.JSX.Element;
label: TranslationKeys;
}[] = [
{
value: SettingsTabValues.GENERAL,
icon: <GearIcon />,
label: 'com_nav_setting_general',
},
{
value: SettingsTabValues.CHAT,
icon: <MessageSquare className="icon-sm" aria-hidden="true" />,
label: 'com_nav_setting_chat',
},
{
value: SettingsTabValues.COMMANDS,
icon: <Command className="icon-sm" aria-hidden="true" />,
label: 'com_nav_commands',
},
{
value: SettingsTabValues.SPEECH,
icon: <SpeechIcon className="icon-sm" aria-hidden="true" />,
label: 'com_nav_setting_speech',
},
...(hasAnyPersonalizationFeature
? [
{
value: SettingsTabValues.PERSONALIZATION,
icon: <PersonalizationIcon />,
label: 'com_nav_setting_personalization' as TranslationKeys,
},
]
: []),
{
value: SettingsTabValues.DATA,
icon: <DataIcon />,
label: 'com_nav_setting_data',
},
...(startupConfig?.balance?.enabled
? [
{
value: SettingsTabValues.BALANCE,
icon: <DollarSign size={18} />,
label: 'com_nav_setting_balance' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
{
value: SettingsTabValues.ACCOUNT,
icon: <UserIcon />,
label: 'com_nav_setting_account',
},
...(aboutEnabled
? [
{
value: SettingsTabValues.ABOUT,
icon: <Info className="icon-sm" aria-hidden="true" />,
label: 'com_nav_setting_about' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
];
const handleTabChange = (value: string) => {
setActiveTab(value as SettingsTabValues);
};
return (
<Transition appear show={open}>
<Dialog as="div" className="relative z-50" onClose={onOpenChange}>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black opacity-50 dark:opacity-80" aria-hidden="true" />
</TransitionChild>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
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')}>
<DialogPanel
className={cn(
'max-h-[90vh] overflow-hidden rounded-xl rounded-b-lg bg-background pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-2xl md:w-[680px]',
)}
>
<DialogTitle
className="mb-1 flex items-center justify-between p-6 pb-5 text-left"
as="div"
>
<h2 className="text-lg font-medium leading-6 text-text-primary">
{localize('com_nav_settings')}
</h2>
<button
type="button"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-surface-primary dark:focus:ring-offset-surface-primary"
onClick={() => onOpenChange(false)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5 text-text-primary"
>
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
<span className="sr-only">{localize('com_ui_close_settings')}</span>
</button>
</DialogTitle>
<div className="max-h-[calc(90vh-120px)] overflow-auto px-6 md:w-[680px]">
<Tabs.Root
value={activeTab}
onValueChange={handleTabChange}
className="flex flex-col gap-10 md:flex-row"
orientation="vertical"
>
<Tabs.List
aria-label="Settings"
className={cn(
'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'
: 'sticky top-0 h-full',
)}
onKeyDown={handleKeyDown}
>
{settingsTabs.map(({ value, icon, label }) => (
<Tabs.Trigger
key={value}
className={cn(
'group relative z-10 m-1 flex items-center justify-start gap-2 rounded-xl px-2 py-1.5 transition-all duration-200 ease-in-out',
isSmallScreen
? 'flex-1 justify-center text-nowrap p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
: 'bg-transparent text-text-secondary radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
)}
value={value}
ref={(el) => (tabRefs.current[value] = el)}
>
{icon}
{localize(label)}
</Tabs.Trigger>
))}
</Tabs.List>
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<Tabs.Content value={SettingsTabValues.GENERAL} tabIndex={-1}>
<General />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
<Chat />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
<Commands />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
<Speech />
</Tabs.Content>
{hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization
hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/>
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
<Data />
</Tabs.Content>
{startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
<Balance />
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
{aboutEnabled && (
<Tabs.Content value={SettingsTabValues.ABOUT} tabIndex={-1}>
<About />
</Tabs.Content>
)}
</div>
</Tabs.Root>
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
export default function Settings(props: TDialogProps) {
return <SettingsDialog {...props} />;
}

View file

@ -0,0 +1,61 @@
import type { TBalanceResponse } from 'librechat-data-provider';
import AutoRefillSettings from '../SettingsTabs/Balance/AutoRefillSettings';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import TokenCreditsItem from '../SettingsTabs/Balance/TokenCreditsItem';
import { useAuthContext, useLocalize } from '~/hooks';
function useBalance(): Partial<TBalanceResponse> {
const { isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const balanceQuery = useGetUserBalance({
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
});
return balanceQuery.data ?? {};
}
export function TokenCredits() {
const { tokenCredits = 0 } = useBalance();
return <TokenCreditsItem tokenCredits={tokenCredits} />;
}
export function AutoRefill() {
const localize = useLocalize();
const {
autoRefillEnabled = false,
lastRefill,
refillAmount,
refillIntervalUnit,
refillIntervalValue,
} = useBalance();
const hasValidRefillSettings =
lastRefill !== undefined &&
refillAmount !== undefined &&
refillIntervalUnit !== undefined &&
refillIntervalValue !== undefined;
if (!autoRefillEnabled) {
return (
<div className="text-sm text-text-secondary">
{localize('com_nav_balance_auto_refill_disabled')}
</div>
);
}
if (!hasValidRefillSettings) {
return (
<div className="text-sm text-red-500">{localize('com_nav_balance_auto_refill_error')}</div>
);
}
return (
<AutoRefillSettings
lastRefill={lastRefill}
refillAmount={refillAmount}
refillIntervalUnit={refillIntervalUnit}
refillIntervalValue={refillIntervalValue}
/>
);
}

View file

@ -0,0 +1,81 @@
import { useMemo } from 'react';
import type { SettingsContextValue, SettingsTab, SettingEntry } from './types';
import { filterSettings } from './search';
import { useLocalize } from '~/hooks';
import { registry } from './registry';
import Section from './Section';
import { TABS } from './types';
interface ContentProps {
activeTab: SettingsTab;
query: string;
ctx: SettingsContextValue;
}
function visible(entry: SettingEntry, ctx: SettingsContextValue): boolean {
return !entry.show || entry.show(ctx);
}
export default function Content({ activeTab, query, ctx }: ContentProps) {
const localize = useLocalize();
const tab = TABS.find((t) => t.id === activeTab)!;
const results = useMemo(
() => (query.trim() ? filterSettings(registry, query, ctx, localize) : null),
[query, ctx, localize],
);
if (results !== null) {
return (
<div aria-label={localize('com_ui_settings_results_aria')} aria-live="polite">
{results.length === 0 ? (
<p className="p-2 text-sm text-text-secondary">
{localize('com_ui_settings_no_results')}
</p>
) : (
<div className="divide-y divide-border-light overflow-hidden rounded-xl border border-border-light text-sm text-text-primary">
{results.map(({ entry, label }) => {
const Cmp = entry.Component;
const tabMeta = TABS.find((t) => t.id === entry.tab)!;
const sectionMeta = tabMeta.sections.find((s) => s.id === entry.section);
return (
<div key={entry.id} className="px-4 py-3">
<div className="mb-1.5 text-xs text-text-tertiary">
{localize(tabMeta.labelKey)} {' '}
{sectionMeta ? localize(sectionMeta.labelKey) : label}
</div>
<Cmp />
</div>
);
})}
</div>
)}
</div>
);
}
return (
<div>
{tab.sections.map((section) => {
const entries = registry.filter(
(e) => e.tab === activeTab && e.section === section.id && visible(e, ctx),
);
if (entries.length === 0) {
return null;
}
return (
<Section key={section.id} heading={localize(section.labelKey)} danger={section.danger}>
{entries.map((e) => {
const Cmp = e.Component;
return (
<div key={e.id} className="px-4 py-3">
<Cmp />
</div>
);
})}
</Section>
);
})}
</div>
);
}

View file

@ -0,0 +1,138 @@
import { useState } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { X, ChevronLeft } from 'lucide-react';
import { useMediaQuery } from '@librechat/client';
import { SettingsTabValues } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import type { TDialogProps } from '~/common';
import type { SettingsTab } from './types';
import { useSettingsContext } from './context';
import { useLocalize } from '~/hooks';
import Sidebar from './Sidebar';
import Content from './Content';
import { TABS } from './types';
import { cn } from '~/utils';
export default function SettingsDialog({ open, onOpenChange }: TDialogProps) {
const localize = useLocalize();
const ctx = useSettingsContext();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const [activeTab, setActiveTab] = useState<SettingsTab>(SettingsTabValues.GENERAL);
const [query, setQuery] = useState('');
const [mobileDetail, setMobileDetail] = useState(false);
const searching = query.trim().length > 0;
const inDetail = isSmallScreen && mobileDetail && !searching;
const showSidebar = !isSmallScreen || !inDetail;
const showContent = !isSmallScreen || inDetail || searching;
const hideTabs = isSmallScreen && searching;
const visibleTabs = TABS.filter((t) => !t.show || t.show(ctx));
const effectiveTab = visibleTabs.some((t) => t.id === activeTab)
? activeTab
: SettingsTabValues.GENERAL;
const activeMeta = TABS.find((t) => t.id === effectiveTab);
const selectTab = (tab: SettingsTab) => {
setActiveTab(tab);
if (isSmallScreen) {
setMobileDetail(true);
}
};
return (
<Transition appear show={open}>
<Dialog as="div" className="relative z-50" onClose={() => onOpenChange(false)}>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black opacity-50 dark:opacity-80" aria-hidden="true" />
</TransitionChild>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<DialogPanel
className={cn(
'flex max-h-[85vh] w-full flex-col overflow-hidden rounded-2xl bg-background shadow-2xl',
'md:h-[85vh] md:w-[900px]',
)}
>
<DialogTitle
as="div"
className="flex items-center justify-between border-b border-border-light p-5"
>
{inDetail ? (
<button
type="button"
onClick={() => setMobileDetail(false)}
className="-ml-1 flex items-center gap-1 rounded-lg p-1 text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-border-xheavy"
aria-label={localize('com_ui_back')}
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
<span className="text-lg font-medium">
{activeMeta ? localize(activeMeta.labelKey) : localize('com_nav_settings')}
</span>
</button>
) : (
<h2 className="text-lg font-medium text-text-primary">
{localize('com_nav_settings')}
</h2>
)}
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-lg p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
>
<X className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_close_settings')}</span>
</button>
</DialogTitle>
<Tabs.Root
value={effectiveTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
orientation="vertical"
className="flex flex-1 flex-col gap-4 overflow-hidden p-5 md:flex-row md:gap-6"
>
{showSidebar && (
<Sidebar
ctx={ctx}
query={query}
onQueryChange={setQuery}
onSelectTab={selectTab}
showChevron={isSmallScreen}
hideTabs={hideTabs}
/>
)}
{showContent && (
<div className="flex-1 overflow-y-auto md:pr-1">
{searching ? (
<Content activeTab={effectiveTab} query={query} ctx={ctx} />
) : (
<Tabs.Content
value={effectiveTab}
tabIndex={-1}
className="focus:outline-none"
>
<Content activeTab={effectiveTab} query={query} ctx={ctx} />
</Tabs.Content>
)}
</div>
)}
</Tabs.Root>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { Switch, useToastContext } from '@librechat/client';
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
import { useLocalize } from '~/hooks';
export default function MemoryToggle() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: user } = useGetUserQuery();
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const mutation = useUpdateMemoryPreferencesMutation({
onSuccess: () =>
showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }),
onError: () => {
showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' });
setReferenceSavedMemories((prev) => !prev);
},
});
useEffect(() => {
if (user?.personalization?.memories !== undefined) {
setReferenceSavedMemories(user.personalization.memories);
}
}, [user?.personalization?.memories]);
const onToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
mutation.mutate({ memories: checked });
};
return (
<div className="flex items-center justify-between">
<div>
<div id="reference-saved-memories-label">{localize('com_ui_reference_saved_memories')}</div>
<div id="reference-saved-memories-description" className="mt-1 text-xs text-text-secondary">
{localize('com_ui_reference_saved_memories_description')}
</div>
</div>
<Switch
checked={referenceSavedMemories}
onCheckedChange={onToggle}
disabled={mutation.isLoading}
aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/>
</div>
);
}

View file

@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { cn } from '~/utils';
interface SectionProps {
heading: string;
danger?: boolean;
children: ReactNode;
}
export default function Section({ heading, danger, children }: SectionProps) {
return (
<section className="mb-7">
<h3
className={cn(
'mb-2 px-1 text-xs font-semibold uppercase tracking-wide',
danger ? 'text-red-500' : 'text-text-secondary',
)}
>
{heading}
</h3>
<div
className={cn(
'divide-y divide-border-light overflow-hidden rounded-xl border text-sm text-text-primary',
danger ? 'border-red-500/30' : 'border-border-light',
)}
>
{children}
</div>
</section>
);
}

View file

@ -0,0 +1,92 @@
import * as Tabs from '@radix-ui/react-tabs';
import { Search, X, ChevronRight } from 'lucide-react';
import type { SettingsContextValue, SettingsTab } from './types';
import { useLocalize } from '~/hooks';
import { TABS } from './types';
import { cn } from '~/utils';
interface SidebarProps {
ctx: SettingsContextValue;
query: string;
onQueryChange: (q: string) => void;
onSelectTab: (tab: SettingsTab) => void;
showChevron?: boolean;
hideTabs?: boolean;
}
export default function Sidebar({
ctx,
query,
onQueryChange,
onSelectTab,
showChevron = false,
hideTabs = false,
}: SidebarProps) {
const localize = useLocalize();
const tabs = TABS.filter((t) => !t.show || t.show(ctx));
return (
<div className="flex w-full flex-col gap-3 md:w-[230px]">
<div className="relative">
<Search
className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
aria-hidden="true"
/>
<input
type="text"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && query.length > 0) {
e.preventDefault();
e.stopPropagation();
onQueryChange('');
}
}}
placeholder={localize('com_ui_settings_search_placeholder')}
aria-label={localize('com_ui_settings_search_placeholder')}
className="w-full rounded-lg bg-surface-secondary py-2 pl-8 pr-8 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => onQueryChange('')}
aria-label={localize('com_ui_clear_search')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
{!hideTabs && (
<Tabs.List
aria-label={localize('com_nav_settings')}
className="flex flex-col gap-1 overflow-visible"
>
{tabs.map((tab) => (
<Tabs.Trigger
key={tab.id}
value={tab.id}
onClick={() => onSelectTab(tab.id)}
className={cn(
'flex items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm text-text-secondary transition-colors hover:bg-surface-hover md:py-2',
'radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary',
)}
>
<span className="flex items-center gap-2">
{tab.icon}
<span className="whitespace-nowrap">{localize(tab.labelKey)}</span>
</span>
{showChevron && (
<ChevronRight
className="h-4 w-4 flex-shrink-0 text-text-tertiary"
aria-hidden="true"
/>
)}
</Tabs.Trigger>
))}
</Tabs.List>
)}
</div>
);
}

View file

@ -0,0 +1,13 @@
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import { EngineSTTDropdown } from '../SettingsTabs/Speech/STT';
import { EngineTTSDropdown } from '../SettingsTabs/Speech/TTS';
export function EngineSTTSetting() {
const { data } = useGetCustomConfigSpeechQuery();
return <EngineSTTDropdown external={Boolean(data?.sttExternal)} />;
}
export function EngineTTSSetting() {
const { data } = useGetCustomConfigSpeechQuery();
return <EngineTTSDropdown external={Boolean(data?.ttsExternal)} />;
}

View file

@ -0,0 +1,38 @@
/* eslint-disable i18next/no-literal-string */
import userEvent from '@testing-library/user-event';
import { render, screen } from 'test/layout-test-utils';
import { SettingsDialog } from '../index';
jest.mock('../Content', () =>
jest.fn(({ query }: { activeTab: string; query: string; ctx: unknown }) =>
query.trim() ? (
<div aria-label="Search results" aria-live="polite" />
) : (
<div>
<h3>Appearance</h3>
</div>
),
),
);
describe('SettingsDialog', () => {
it('renders the General tab by default with its sections', () => {
render(<SettingsDialog open onOpenChange={jest.fn()} />);
expect(screen.getByRole('heading', { name: /settings/i, level: 2 })).toBeInTheDocument();
expect(screen.getByText('Appearance')).toBeInTheDocument();
});
it('renders the active panel as a tabpanel wired to its trigger', () => {
render(<SettingsDialog open onOpenChange={jest.fn()} />);
const panel = screen.getByRole('tabpanel');
const trigger = screen.getByRole('tab', { selected: true });
expect(trigger).toHaveAttribute('aria-controls', panel.id);
expect(panel).toHaveAttribute('aria-labelledby', trigger.id);
});
it('switches to search results when typing a query', async () => {
render(<SettingsDialog open onOpenChange={jest.fn()} />);
await userEvent.type(screen.getByRole('textbox'), 'language');
expect(await screen.findByLabelText('Search results')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,80 @@
import * as Tabs from '@radix-ui/react-tabs';
import userEvent from '@testing-library/user-event';
import { SettingsTabValues } from 'librechat-data-provider';
import type { SettingsContextValue } from '../types';
import { render, screen } from 'test/layout-test-utils';
import Sidebar from '../Sidebar';
const ctx: SettingsContextValue = {
balanceEnabled: false,
hasAnyPersonalizationFeature: false,
hasMemoryOptOut: false,
hasRemoteAgents: false,
hasMultiConvo: false,
hasPrompts: false,
isLocalProvider: true,
twoFactorEnabled: false,
allowAccountDeletion: true,
aboutEnabled: false,
engineTTS: 'browser',
};
function setup(extra: Partial<SettingsContextValue> = {}, query = '') {
const onQueryChange = jest.fn();
render(
<Tabs.Root value={SettingsTabValues.GENERAL}>
<Sidebar
ctx={{ ...ctx, ...extra }}
query={query}
onQueryChange={onQueryChange}
onSelectTab={jest.fn()}
/>
</Tabs.Root>,
);
return { onQueryChange };
}
describe('Sidebar', () => {
it('hides the About tab when build info is disabled', () => {
setup();
expect(screen.queryByText('About')).not.toBeInTheDocument();
});
it('shows the About tab when build info is enabled', () => {
setup({ aboutEnabled: true });
expect(screen.getByText('About')).toBeInTheDocument();
});
it('forwards typing to onQueryChange', async () => {
const { onQueryChange } = setup();
await userEvent.type(screen.getByRole('textbox'), 'theme');
expect(onQueryChange).toHaveBeenCalled();
});
it('Escape clears search when query is non-empty', async () => {
const { onQueryChange } = setup({}, 'theme');
const input = screen.getByRole('textbox');
input.focus();
await userEvent.keyboard('{Escape}');
expect(onQueryChange).toHaveBeenCalledWith('');
});
it('Escape does not call onQueryChange when query is empty', async () => {
const { onQueryChange } = setup({}, '');
const input = screen.getByRole('textbox');
input.focus();
await userEvent.keyboard('{Escape}');
expect(onQueryChange).not.toHaveBeenCalled();
});
it('shows a clear button that clears the query when there is text', async () => {
const { onQueryChange } = setup({}, 'theme');
await userEvent.click(screen.getByRole('button', { name: /clear search/i }));
expect(onQueryChange).toHaveBeenCalledWith('');
});
it('hides the clear button when the query is empty', () => {
setup({}, '');
expect(screen.queryByRole('button', { name: /clear search/i })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,33 @@
import { isValidElementType } from 'react-is';
import en from '~/locales/en/translation.json';
import { registry } from '../registry';
import { TABS } from '../types';
const validTabSections = new Map(TABS.map((t) => [t.id, new Set(t.sections.map((s) => s.id))]));
describe('settings registry', () => {
it('has unique ids', () => {
const ids = registry.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('references a valid tab and section for every entry', () => {
for (const entry of registry) {
const sections = validTabSections.get(entry.tab);
expect(sections).toBeDefined();
expect(sections!.has(entry.section)).toBe(true);
}
});
it('uses label keys that exist in the English locale', () => {
for (const entry of registry) {
expect(en).toHaveProperty(entry.labelKey);
}
});
it('has a renderable Component for every entry', () => {
for (const entry of registry) {
expect(isValidElementType(entry.Component)).toBe(true);
}
});
});

View file

@ -0,0 +1,30 @@
import { normalize, matchesQuery } from '../search';
describe('normalize', () => {
it('lowercases and strips diacritics', () => {
expect(normalize('Café')).toBe('cafe');
expect(normalize(' Théme ')).toBe('theme');
});
});
describe('matchesQuery', () => {
const haystack = { label: 'Theme', keywords: ['dark', 'light', 'appearance'] };
it('matches the label case-insensitively', () => {
expect(matchesQuery('the', haystack)).toBe(true);
expect(matchesQuery('THEME', haystack)).toBe(true);
});
it('matches a keyword synonym', () => {
expect(matchesQuery('dark', haystack)).toBe(true);
});
it('returns false when nothing matches', () => {
expect(matchesQuery('zzz', haystack)).toBe(false);
});
it('matches everything on empty query', () => {
expect(matchesQuery('', haystack)).toBe(true);
expect(matchesQuery(' ', haystack)).toBe(true);
});
});

View file

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { SettingsContextValue } from './types';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { useHasAccess, useAuthContext } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import store from '~/store';
export function useSettingsContext(): SettingsContextValue {
const { user } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const hasRemoteAgents = useHasAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permission: Permissions.USE,
});
const hasMultiConvo = useHasAccess({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});
const hasPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
const balanceEnabled = startupConfig?.balance?.enabled === true;
const isLocalProvider = user?.provider === 'local';
const twoFactorEnabled = user?.twoFactorEnabled === true;
const allowAccountDeletion = startupConfig?.allowAccountDeletion !== false;
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
const hasRemoteAgentsBool = hasRemoteAgents === true;
const hasMultiConvoBool = hasMultiConvo === true;
const hasPromptsBool = hasPrompts === true;
const engineTTS = useRecoilValue<string>(store.engineTTS);
return useMemo(
() => ({
balanceEnabled,
hasAnyPersonalizationFeature,
hasMemoryOptOut,
hasRemoteAgents: hasRemoteAgentsBool,
hasMultiConvo: hasMultiConvoBool,
hasPrompts: hasPromptsBool,
isLocalProvider,
twoFactorEnabled,
allowAccountDeletion,
aboutEnabled,
engineTTS,
}),
[
balanceEnabled,
hasAnyPersonalizationFeature,
hasMemoryOptOut,
hasRemoteAgentsBool,
hasMultiConvoBool,
hasPromptsBool,
isLocalProvider,
twoFactorEnabled,
allowAccountDeletion,
aboutEnabled,
engineTTS,
],
);
}

View file

@ -0,0 +1,50 @@
import { useContext, useCallback } from 'react';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { ThemeContext } from '@librechat/client';
import type { ComponentType } from 'react';
import type { TranslationKeys } from '~/hooks';
import { ThemeSelector, LangSelector } from '../SettingsTabs/General/Selectors';
import ToggleSwitch from '../SettingsTabs/ToggleSwitch';
import store from '~/store';
export function toggleControl(opts: {
stateAtom: Parameters<typeof ToggleSwitch>[0]['stateAtom'];
localizationKey: TranslationKeys;
switchId: string;
hoverCardText?: TranslationKeys;
}): ComponentType {
const Control = () => (
<ToggleSwitch
stateAtom={opts.stateAtom}
localizationKey={opts.localizationKey}
switchId={opts.switchId}
hoverCardText={opts.hoverCardText}
/>
);
Control.displayName = `Toggle(${opts.switchId})`;
return Control;
}
export function ThemeSetting() {
const { theme, setTheme } = useContext(ThemeContext);
const onChange = useCallback((value: string) => setTheme(value), [setTheme]);
return <ThemeSelector theme={theme} onChange={onChange} />;
}
export function LangSetting() {
const [langcode, setLangcode] = useRecoilState(store.lang);
const onChange = useCallback(
(value: string) => {
const userLang =
value === 'auto' ? navigator.language || navigator.languages?.[0] || 'en-US' : value;
requestAnimationFrame(() => {
document.documentElement.lang = userLang;
});
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},
[setLangcode],
);
return <LangSelector langcode={langcode} onChange={onChange} />;
}

View file

@ -0,0 +1 @@
export { default as SettingsDialog } from './Dialog';

View file

@ -0,0 +1,562 @@
import { SettingsTabValues } from 'librechat-data-provider';
import type { SettingEntry } from './types';
import {
TextToSpeechSwitch,
VoiceDropdown,
CacheTTSSwitch,
AutomaticPlaybackSwitch,
CloudBrowserVoicesSwitch,
PlaybackRate,
} from '../SettingsTabs/Speech/TTS';
import {
SpeechToTextSwitch,
LanguageSTTDropdown,
AutoTranscribeAudioSwitch,
AutoSendTextSelector,
DecibelSelector,
} from '../SettingsTabs/Speech/STT';
import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMessages';
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
import { toggleControl, ThemeSetting, LangSetting } from './controls';
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
import { EngineSTTSetting, EngineTTSSetting } from './SpeechControls';
import FontSizeSelector from '../SettingsTabs/Chat/FontSizeSelector';
import AdvancedPrompts from '../SettingsTabs/Chat/AdvancedPrompts';
import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings';
import { AgentApiKeys } from '../SettingsTabs/Data/AgentApiKeys';
import ChatDirection from '../SettingsTabs/Chat/ChatDirection';
import { DeleteCache } from '../SettingsTabs/Data/DeleteCache';
import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys';
import { ClearChats } from '../SettingsTabs/Data/ClearChats';
import { TokenCredits, AutoRefill } from './BillingControls';
import SharedLinks from '../SettingsTabs/Data/SharedLinks';
import { showThinkingAtom } from '~/store/showThinking';
import Avatar from '../SettingsTabs/Account/Avatar';
import About from '../SettingsTabs/About/About';
import MemoryToggle from './MemoryToggle';
import { TTSEndpoints } from '~/common';
import store from '~/store';
const { GENERAL, CHAT, SPEECH, DATA, ACCOUNT, ABOUT } = SettingsTabValues;
export const registry: SettingEntry[] = [
// General · Appearance
{
id: 'theme',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_theme',
keywords: ['dark', 'light', 'appearance', 'color'],
Component: ThemeSetting,
},
{
id: 'language',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_language',
keywords: ['locale', 'translation'],
Component: LangSetting,
},
{
id: 'fontSize',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_font_size',
keywords: ['text', 'zoom'],
Component: FontSizeSelector,
},
{
id: 'chatDirection',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_chat_direction',
keywords: ['rtl', 'ltr'],
Component: ChatDirection,
},
// General · Layout
{
id: 'maximizeChatSpace',
tab: GENERAL,
section: 'layout',
labelKey: 'com_nav_maximize_chat_space',
Component: toggleControl({
stateAtom: store.maximizeChatSpace,
localizationKey: 'com_nav_maximize_chat_space',
switchId: 'maximizeChatSpace',
}),
},
{
id: 'centerFormOnLanding',
tab: GENERAL,
section: 'layout',
labelKey: 'com_nav_center_chat_input',
Component: toggleControl({
stateAtom: store.centerFormOnLanding,
localizationKey: 'com_nav_center_chat_input',
switchId: 'centerFormOnLanding',
}),
},
{
id: 'showScrollButton',
tab: GENERAL,
section: 'layout',
labelKey: 'com_nav_scroll_button',
Component: toggleControl({
stateAtom: store.showScrollButton,
localizationKey: 'com_nav_scroll_button',
switchId: 'showScrollButton',
}),
},
// General · Accessibility
{
id: 'keepScreenAwake',
tab: GENERAL,
section: 'accessibility',
labelKey: 'com_nav_keep_screen_awake',
Component: toggleControl({
stateAtom: store.keepScreenAwake,
localizationKey: 'com_nav_keep_screen_awake',
switchId: 'keepScreenAwake',
}),
},
// Chat · Sending
{
id: 'enterToSend',
tab: CHAT,
section: 'sending',
labelKey: 'com_nav_enter_to_send',
keywords: ['return', 'newline'],
Component: toggleControl({
stateAtom: store.enterToSend,
localizationKey: 'com_nav_enter_to_send',
switchId: 'enterToSend',
hoverCardText: 'com_nav_info_enter_to_send',
}),
},
{
id: 'saveDrafts',
tab: CHAT,
section: 'sending',
labelKey: 'com_nav_save_drafts',
Component: toggleControl({
stateAtom: store.saveDrafts,
localizationKey: 'com_nav_save_drafts',
switchId: 'saveDrafts',
hoverCardText: 'com_nav_info_save_draft',
}),
},
{
id: 'saveBadgesState',
tab: CHAT,
section: 'sending',
labelKey: 'com_nav_save_badges_state',
Component: toggleControl({
stateAtom: store.saveBadgesState,
localizationKey: 'com_nav_save_badges_state',
switchId: 'showBadges',
hoverCardText: 'com_nav_info_save_badges_state',
}),
},
// Chat · Commands
{
id: 'atCommand',
tab: CHAT,
section: 'commands',
labelKey: 'com_nav_at_command_description',
Component: toggleControl({
stateAtom: store.atCommand,
localizationKey: 'com_nav_at_command_description',
switchId: 'atCommand',
}),
},
{
id: 'plusCommand',
tab: CHAT,
section: 'commands',
labelKey: 'com_nav_plus_command_description',
show: (ctx) => ctx.hasMultiConvo,
Component: toggleControl({
stateAtom: store.plusCommand,
localizationKey: 'com_nav_plus_command_description',
switchId: 'plusCommand',
}),
},
{
id: 'slashCommand',
tab: CHAT,
section: 'commands',
labelKey: 'com_nav_slash_command_description',
show: (ctx) => ctx.hasPrompts,
Component: toggleControl({
stateAtom: store.slashCommand,
localizationKey: 'com_nav_slash_command_description',
switchId: 'slashCommand',
}),
},
// Chat · Messages
{
id: 'enableUserMsgMarkdown',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_user_msg_markdown',
Component: toggleControl({
stateAtom: store.enableUserMsgMarkdown,
localizationKey: 'com_nav_user_msg_markdown',
switchId: 'enableUserMsgMarkdown',
}),
},
{
id: 'usernameDisplay',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_user_name_display',
keywords: ['username', 'name'],
Component: DisplayUsernameMessages,
},
{
id: 'latexParsing',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_latex_parsing',
Component: toggleControl({
stateAtom: store.LaTeXParsing,
localizationKey: 'com_nav_latex_parsing',
switchId: 'latexParsing',
hoverCardText: 'com_nav_info_latex_parsing',
}),
},
{
id: 'showThinking',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_show_thinking',
Component: toggleControl({
stateAtom: showThinkingAtom,
localizationKey: 'com_nav_show_thinking',
switchId: 'showThinking',
}),
},
{
id: 'autoExpandTools',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_auto_expand_tools',
Component: toggleControl({
stateAtom: store.autoExpandTools,
localizationKey: 'com_nav_auto_expand_tools',
switchId: 'autoExpandTools',
}),
},
// Chat · Conversations
{
id: 'newChatSwitchToHistory',
tab: CHAT,
section: 'conversations',
labelKey: 'com_nav_new_chat_switch_to_history',
Component: toggleControl({
stateAtom: store.newChatSwitchToHistory,
localizationKey: 'com_nav_new_chat_switch_to_history',
switchId: 'newChatSwitchToHistory',
}),
},
{
id: 'autoScroll',
tab: CHAT,
section: 'conversations',
labelKey: 'com_nav_auto_scroll',
Component: toggleControl({
stateAtom: store.autoScroll,
localizationKey: 'com_nav_auto_scroll',
switchId: 'autoScroll',
}),
},
{
id: 'modularChat',
tab: CHAT,
section: 'conversations',
labelKey: 'com_nav_modular_chat',
Component: toggleControl({
stateAtom: store.modularChat,
localizationKey: 'com_nav_modular_chat',
switchId: 'modularChat',
}),
},
{
id: 'defaultTemporaryChat',
tab: CHAT,
section: 'conversations',
labelKey: 'com_nav_default_temporary_chat',
Component: toggleControl({
stateAtom: store.defaultTemporaryChat,
localizationKey: 'com_nav_default_temporary_chat',
switchId: 'defaultTemporaryChat',
hoverCardText: 'com_nav_info_default_temporary_chat',
}),
},
{
id: 'forkSettings',
tab: CHAT,
section: 'conversations',
labelKey: 'com_ui_fork_default',
keywords: ['fork', 'branch', 'split'],
Component: ForkSettings,
},
// Chat · Prompts
{
id: 'advancedPrompts',
tab: CHAT,
section: 'prompts',
labelKey: 'com_nav_advanced_prompts',
keywords: ['prompt'],
Component: AdvancedPrompts,
},
{
id: 'alwaysMakeProd',
tab: CHAT,
section: 'prompts',
labelKey: 'com_nav_always_make_prod',
Component: toggleControl({
stateAtom: store.alwaysMakeProd,
localizationKey: 'com_nav_always_make_prod',
switchId: 'alwaysMakeProd',
}),
},
{
id: 'autoSendPrompts',
tab: CHAT,
section: 'prompts',
labelKey: 'com_nav_auto_send_prompts',
Component: toggleControl({
stateAtom: store.autoSendPrompts,
localizationKey: 'com_nav_auto_send_prompts',
switchId: 'autoSendPrompts',
hoverCardText: 'com_nav_auto_send_prompts_desc',
}),
},
// Speech · Speech-to-text
{
id: 'speechToText',
tab: SPEECH,
section: 'stt',
labelKey: 'com_nav_speech_to_text',
Component: SpeechToTextSwitch,
},
{
id: 'engineSTT',
tab: SPEECH,
section: 'stt',
labelKey: 'com_ui_settings_label_engine_stt',
Component: EngineSTTSetting,
},
{
id: 'languageSTT',
tab: SPEECH,
section: 'stt',
labelKey: 'com_ui_settings_label_language_stt',
Component: LanguageSTTDropdown,
},
{
id: 'autoTranscribeAudio',
tab: SPEECH,
section: 'stt',
labelKey: 'com_nav_auto_transcribe_audio',
Component: AutoTranscribeAudioSwitch,
},
{
id: 'decibelValue',
tab: SPEECH,
section: 'stt',
labelKey: 'com_ui_settings_label_decibel',
Component: DecibelSelector,
},
{
id: 'autoSendText',
tab: SPEECH,
section: 'stt',
labelKey: 'com_nav_auto_send_text',
Component: AutoSendTextSelector,
},
// Speech · Text-to-speech
{
id: 'textToSpeech',
tab: SPEECH,
section: 'tts',
labelKey: 'com_nav_text_to_speech',
Component: TextToSpeechSwitch,
},
{
id: 'engineTTS',
tab: SPEECH,
section: 'tts',
labelKey: 'com_ui_settings_label_engine_tts',
Component: EngineTTSSetting,
},
{
id: 'voice',
tab: SPEECH,
section: 'tts',
labelKey: 'com_ui_settings_label_voice',
Component: VoiceDropdown,
},
{
id: 'conversationMode',
tab: SPEECH,
section: 'tts',
labelKey: 'com_ui_settings_label_conversation_mode',
Component: ConversationModeSwitch,
},
{
id: 'automaticPlayback',
tab: SPEECH,
section: 'tts',
labelKey: 'com_nav_automatic_playback',
Component: AutomaticPlaybackSwitch,
},
{
id: 'cloudBrowserVoices',
tab: SPEECH,
section: 'tts',
labelKey: 'com_nav_enable_cloud_browser_voice',
show: (ctx) => ctx.engineTTS === TTSEndpoints.browser,
Component: CloudBrowserVoicesSwitch,
},
{
id: 'playbackRate',
tab: SPEECH,
section: 'tts',
labelKey: 'com_ui_settings_label_playback_rate',
Component: PlaybackRate,
},
{
id: 'cacheTTS',
tab: SPEECH,
section: 'tts',
labelKey: 'com_nav_enable_cache_tts',
Component: CacheTTSSwitch,
},
// Data controls · Memory
{
id: 'referenceSavedMemories',
tab: DATA,
section: 'memory',
labelKey: 'com_ui_reference_saved_memories',
keywords: ['memory', 'personalization'],
show: (ctx) => ctx.hasMemoryOptOut,
Component: MemoryToggle,
},
// Data controls · Your data
{
id: 'importConversations',
tab: DATA,
section: 'data',
labelKey: 'com_ui_settings_label_import',
Component: ImportConversations,
},
{
id: 'sharedLinks',
tab: DATA,
section: 'data',
labelKey: 'com_ui_settings_label_shared_links',
Component: SharedLinks,
},
// Data controls · API keys
{
id: 'agentApiKeys',
tab: DATA,
section: 'apiKeys',
labelKey: 'com_ui_settings_label_agent_api_keys',
show: (ctx) => ctx.hasRemoteAgents,
Component: AgentApiKeys,
},
{
id: 'revokeKeys',
tab: DATA,
section: 'apiKeys',
labelKey: 'com_ui_settings_label_revoke_keys',
Component: RevokeKeys,
},
// Data controls · Danger zone
{
id: 'deleteCache',
tab: DATA,
section: 'danger',
labelKey: 'com_ui_settings_label_delete_cache',
Component: DeleteCache,
},
{
id: 'clearChats',
tab: DATA,
section: 'danger',
labelKey: 'com_ui_settings_label_clear_chats',
Component: ClearChats,
},
// Account · Profile
{
id: 'avatar',
tab: ACCOUNT,
section: 'profile',
labelKey: 'com_ui_settings_label_avatar',
Component: Avatar,
},
// Account · Security
{
id: 'twoFactor',
tab: ACCOUNT,
section: 'security',
labelKey: 'com_ui_settings_label_2fa',
show: (ctx) => ctx.isLocalProvider,
Component: EnableTwoFactorItem,
},
{
id: 'backupCodes',
tab: ACCOUNT,
section: 'security',
labelKey: 'com_ui_settings_label_backup_codes',
show: (ctx) => ctx.isLocalProvider && ctx.twoFactorEnabled,
Component: BackupCodesItem,
},
// Account · Billing
{
id: 'tokenCredits',
tab: ACCOUNT,
section: 'billing',
labelKey: 'com_ui_settings_label_credits',
show: (ctx) => ctx.balanceEnabled,
Component: TokenCredits,
},
{
id: 'autoRefill',
tab: ACCOUNT,
section: 'billing',
labelKey: 'com_ui_settings_label_auto_refill',
show: (ctx) => ctx.balanceEnabled,
Component: AutoRefill,
},
// Account · Danger zone
{
id: 'deleteAccount',
tab: ACCOUNT,
section: 'danger',
labelKey: 'com_ui_settings_label_delete_account',
show: (ctx) => ctx.allowAccountDeletion,
Component: DeleteAccount,
},
// About
{
id: 'about',
tab: ABOUT,
section: 'about',
labelKey: 'com_nav_setting_about',
keywords: ['version', 'build', 'diagnostics'],
show: (ctx) => ctx.aboutEnabled,
Component: About,
},
];

View file

@ -0,0 +1,43 @@
import type { SettingEntry, SettingsContextValue } from './types';
export function normalize(value: string): string {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase();
}
export function matchesQuery(
query: string,
haystack: { label: string; keywords?: string[] },
): boolean {
const q = normalize(query);
if (q.length === 0) {
return true;
}
if (normalize(haystack.label).includes(q)) {
return true;
}
return (haystack.keywords ?? []).some((k) => normalize(k).includes(q));
}
export interface SearchResult {
entry: SettingEntry;
label: string;
}
export function filterSettings(
entries: SettingEntry[],
query: string,
ctx: SettingsContextValue,
localize: (key: SettingEntry['labelKey']) => string,
): SearchResult[] {
const results: SearchResult[] = [];
for (const entry of entries) {
if (entry.show && !entry.show(ctx)) {
continue;
}
const label = localize(entry.labelKey);
if (matchesQuery(query, { label, keywords: entry.keywords })) {
results.push({ entry, label });
}
}
return results;
}

View file

@ -0,0 +1,135 @@
import { createElement } from 'react';
import { MessageSquare, Info } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import { GearIcon, DataIcon, UserIcon, SpeechIcon } from '@librechat/client';
import type { ComponentType, ReactNode } from 'react';
import type { TranslationKeys } from '~/hooks';
export type SettingsTab =
| SettingsTabValues.GENERAL
| SettingsTabValues.CHAT
| SettingsTabValues.SPEECH
| SettingsTabValues.DATA
| SettingsTabValues.ACCOUNT
| SettingsTabValues.ABOUT;
export type SectionId =
| 'appearance'
| 'layout'
| 'accessibility'
| 'sending'
| 'commands'
| 'messages'
| 'conversations'
| 'prompts'
| 'stt'
| 'tts'
| 'memory'
| 'data'
| 'apiKeys'
| 'danger'
| 'profile'
| 'security'
| 'billing'
| 'about';
export interface SettingsContextValue {
balanceEnabled: boolean;
hasAnyPersonalizationFeature: boolean;
hasMemoryOptOut: boolean;
hasRemoteAgents: boolean;
hasMultiConvo: boolean;
hasPrompts: boolean;
isLocalProvider: boolean;
twoFactorEnabled: boolean;
allowAccountDeletion: boolean;
aboutEnabled: boolean;
engineTTS: string;
}
export interface SettingEntry {
id: string;
tab: SettingsTab;
section: SectionId;
labelKey: TranslationKeys;
keywords?: string[];
Component: ComponentType;
show?: (ctx: SettingsContextValue) => boolean;
}
export interface SectionMeta {
id: SectionId;
labelKey: TranslationKeys;
danger?: boolean;
}
export interface TabMeta {
id: SettingsTab;
labelKey: TranslationKeys;
icon: ReactNode;
sections: SectionMeta[];
show?: (ctx: SettingsContextValue) => boolean;
}
export const TABS: TabMeta[] = [
{
id: SettingsTabValues.GENERAL,
labelKey: 'com_nav_setting_general',
icon: createElement(GearIcon),
sections: [
{ id: 'appearance', labelKey: 'com_ui_settings_section_appearance' },
{ id: 'layout', labelKey: 'com_ui_settings_section_layout' },
{ id: 'accessibility', labelKey: 'com_ui_settings_section_accessibility' },
],
},
{
id: SettingsTabValues.CHAT,
labelKey: 'com_nav_setting_chat',
icon: createElement(MessageSquare, { className: 'icon-sm', 'aria-hidden': true }),
sections: [
{ id: 'sending', labelKey: 'com_ui_settings_section_sending' },
{ id: 'commands', labelKey: 'com_ui_settings_section_commands' },
{ id: 'messages', labelKey: 'com_ui_settings_section_messages' },
{ id: 'conversations', labelKey: 'com_ui_settings_section_conversations' },
{ id: 'prompts', labelKey: 'com_ui_settings_section_prompts' },
],
},
{
id: SettingsTabValues.SPEECH,
labelKey: 'com_nav_setting_speech',
icon: createElement(SpeechIcon, { className: 'icon-sm' }),
sections: [
{ id: 'stt', labelKey: 'com_ui_settings_section_stt' },
{ id: 'tts', labelKey: 'com_ui_settings_section_tts' },
],
},
{
id: SettingsTabValues.DATA,
labelKey: 'com_ui_settings_tab_data',
icon: createElement(DataIcon),
sections: [
{ id: 'memory', labelKey: 'com_ui_settings_section_memory' },
{ id: 'data', labelKey: 'com_ui_settings_section_data' },
{ id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' },
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
],
},
{
id: SettingsTabValues.ACCOUNT,
labelKey: 'com_nav_setting_account',
icon: createElement(UserIcon),
sections: [
{ id: 'profile', labelKey: 'com_ui_settings_section_profile' },
{ id: 'security', labelKey: 'com_ui_settings_section_security' },
{ id: 'billing', labelKey: 'com_ui_settings_section_billing' },
{ id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true },
],
},
{
id: SettingsTabValues.ABOUT,
labelKey: 'com_nav_setting_about',
icon: createElement(Info, { className: 'icon-sm', 'aria-hidden': true }),
sections: [{ id: 'about', labelKey: 'com_nav_setting_about' }],
show: (ctx) => ctx.aboutEnabled,
},
];

View file

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import copy from 'copy-to-clipboard';
import { Check, Copy } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import type { TStartupConfig } from 'librechat-data-provider';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
@ -38,9 +38,9 @@ function buildDiagnosticsBlob(
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5">
<div className="text-text-secondary">{label}</div>
<div className="break-all text-right font-mono text-xs text-text-primary">{value}</div>
<div className="flex items-center justify-between gap-4 py-2.5 first:pt-0 last:pb-0">
<dt className="text-text-secondary">{label}</dt>
<dd className="break-all text-right font-mono text-xs text-text-primary">{value}</dd>
</div>
);
}
@ -48,7 +48,7 @@ function Row({ label, value }: { label: string; value: string }) {
function About() {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const [copied, setCopied] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buildInfo = startupConfig?.buildInfo;
@ -68,71 +68,53 @@ function About() {
[],
);
const onCopy = useCallback(() => {
const handleCopy = useCallback(() => {
const succeeded = copy(diagnosticsBlob, { format: 'text/plain' });
if (!succeeded) {
return;
}
setCopied(true);
setIsCopied(true);
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = setTimeout(() => setCopied(false), 2000);
copyResetTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
}, [diagnosticsBlob]);
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<section aria-labelledby="about-version-heading" className="flex flex-col">
<h3 id="about-version-heading" className="mb-2 text-sm font-medium text-text-primary">
{localize('com_nav_about_version_heading')}
</h3>
<div className="rounded-lg border border-border-light bg-surface-secondary p-3">
<Row label={localize('com_nav_about_version')} value={version} />
<Row
label={localize('com_nav_about_commit')}
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_branch')}
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_build_date')}
value={formatBuildDate(buildInfo?.buildDate)}
/>
</div>
</section>
<div className="flex flex-col text-sm text-text-primary">
<dl className="flex flex-col divide-y divide-border-light">
<Row label={localize('com_nav_about_version')} value={version} />
<Row
label={localize('com_nav_about_commit')}
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_branch')}
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_build_date')}
value={formatBuildDate(buildInfo?.buildDate)}
/>
</dl>
<section aria-labelledby="about-diagnostics-heading" className="flex flex-col">
<h3 id="about-diagnostics-heading" className="mb-2 text-sm font-medium text-text-primary">
{localize('com_nav_about_diagnostics_heading')}
</h3>
<p className="mb-2 text-xs text-text-secondary">
<div className="mt-4 flex flex-col items-start gap-3 border-t border-border-light pt-4">
<p className="text-xs text-text-secondary">
{localize('com_nav_about_diagnostics_description')}
</p>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center justify-center gap-2 self-start rounded-md border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
>
{copied ? (
<>
<Check className="h-4 w-4" aria-hidden="true" />
{localize('com_nav_about_diagnostics_copied')}
</>
) : (
<>
<Copy className="h-4 w-4" aria-hidden="true" />
{localize('com_nav_about_diagnostics_copy')}
</>
)}
</button>
<CopyButton
isCopied={isCopied}
onClick={handleCopy}
label={localize('com_nav_about_diagnostics_copy')}
copiedLabel={localize('com_nav_about_diagnostics_copied')}
className="ml-0 gap-2 self-start rounded-lg border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
/>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{copied ? localize('com_nav_about_diagnostics_copied') : ''}
{isCopied ? localize('com_nav_about_diagnostics_copied') : ''}
</span>
</section>
</div>
</div>
);
}
export default React.memo(About);
export default memo(About);

View file

@ -1,64 +0,0 @@
import React from 'react';
import { SystemRoles } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { TUser } from 'librechat-data-provider';
import Account from './Account';
jest.mock('./DisplayUsernameMessages', () => () => <div data-testid="display-username" />);
jest.mock('./Avatar', () => () => <div data-testid="avatar" />);
jest.mock('./TwoFactorAuthentication', () => () => <div data-testid="two-factor" />);
jest.mock('./BackupCodesItem', () => () => <div data-testid="backup-codes" />);
jest.mock('./DeleteAccount', () => () => <div data-testid="delete-account" />);
const mockUseAuthContext = jest.fn();
const mockUseGetStartupConfig = jest.fn();
jest.mock('~/hooks', () => ({
useAuthContext: () => mockUseAuthContext(),
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
const baseUser: TUser = {
id: 'user-123',
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: '',
role: SystemRoles.USER,
provider: 'local',
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
};
beforeEach(() => {
mockUseAuthContext.mockReturnValue({ user: baseUser });
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: true } });
});
afterEach(() => {
jest.resetAllMocks();
});
describe('Account', () => {
describe('DeleteAccount visibility', () => {
it('renders DeleteAccount when allowAccountDeletion is true', () => {
render(<Account />);
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
});
it('hides DeleteAccount when allowAccountDeletion is false', () => {
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: false } });
render(<Account />);
expect(screen.queryByTestId('delete-account')).not.toBeInTheDocument();
});
it('shows DeleteAccount when startup config is still loading', () => {
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
render(<Account />);
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
});
});
});

View file

@ -1,43 +0,0 @@
import React from 'react';
import DisplayUsernameMessages from './DisplayUsernameMessages';
import DeleteAccount from './DeleteAccount';
import Avatar from './Avatar';
import EnableTwoFactorItem from './TwoFactorAuthentication';
import BackupCodesItem from './BackupCodesItem';
import { useGetStartupConfig } from '~/data-provider';
import { useAuthContext } from '~/hooks';
function Account() {
const { user } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<DisplayUsernameMessages />
</div>
<div className="pb-3">
<Avatar />
</div>
{user?.provider === 'local' && (
<>
<div className="pb-3">
<EnableTwoFactorItem />
</div>
{user?.twoFactorEnabled && (
<div className="pb-3">
<BackupCodesItem />
</div>
)}
</>
)}
{startupConfig?.allowAccountDeletion !== false && (
<div className="pb-3">
<DeleteAccount />
</div>
)}
</div>
);
}
export default React.memo(Account);

View file

@ -1,68 +0,0 @@
import React from 'react';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import AutoRefillSettings from './AutoRefillSettings';
import TokenCreditsItem from './TokenCreditsItem';
function Balance() {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
const balanceQuery = useGetUserBalance({
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
});
const balanceData = balanceQuery.data;
// Pull out all the fields we need, with safe defaults
const {
tokenCredits = 0,
autoRefillEnabled = false,
lastRefill,
refillAmount,
refillIntervalUnit,
refillIntervalValue,
} = balanceData ?? {};
// Check that all auto-refill props are present
const hasValidRefillSettings =
lastRefill !== undefined &&
refillAmount !== undefined &&
refillIntervalUnit !== undefined &&
refillIntervalValue !== undefined;
const renderAutoRefill = () => {
if (!autoRefillEnabled) {
return (
<div className="text-sm text-gray-600">
{localize('com_nav_balance_auto_refill_disabled')}
</div>
);
}
if (!hasValidRefillSettings) {
return (
<div className="text-sm text-red-600">{localize('com_nav_balance_auto_refill_error')}</div>
);
}
return (
<AutoRefillSettings
lastRefill={lastRefill}
refillAmount={refillAmount}
refillIntervalUnit={refillIntervalUnit}
refillIntervalValue={refillIntervalValue}
/>
);
};
return (
<div className="flex flex-col gap-4 p-4 text-sm text-text-primary">
{/* Token credits display */}
<TokenCreditsItem tokenCredits={tokenCredits} />
{/* Auto-refill display */}
{renderAutoRefill()}
</div>
);
}
export default React.memo(Balance);

View file

@ -1,131 +0,0 @@
import { memo } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import AdvancedPrompts from './AdvancedPrompts';
import FontSizeSelector from './FontSizeSelector';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.alwaysMakeProd,
localizationKey: 'com_nav_always_make_prod' as const,
switchId: 'alwaysMakeProd',
hoverCardText: undefined,
key: 'alwaysMakeProd',
},
{
stateAtom: store.autoSendPrompts,
localizationKey: 'com_nav_auto_send_prompts' as const,
switchId: 'autoSendPrompts',
hoverCardText: 'com_nav_auto_send_prompts_desc' as const,
key: 'autoSendPrompts',
},
{
stateAtom: store.enterToSend,
localizationKey: 'com_nav_enter_to_send' as const,
switchId: 'enterToSend',
hoverCardText: 'com_nav_info_enter_to_send' as const,
key: 'enterToSend',
},
{
stateAtom: store.maximizeChatSpace,
localizationKey: 'com_nav_maximize_chat_space' as const,
switchId: 'maximizeChatSpace',
hoverCardText: undefined,
key: 'maximizeChatSpace',
},
{
stateAtom: store.centerFormOnLanding,
localizationKey: 'com_nav_center_chat_input' as const,
switchId: 'centerFormOnLanding',
hoverCardText: undefined,
key: 'centerFormOnLanding',
},
{
stateAtom: showThinkingAtom,
localizationKey: 'com_nav_show_thinking' as const,
switchId: 'showThinking',
hoverCardText: undefined,
key: 'showThinking',
},
{
stateAtom: store.autoExpandTools,
localizationKey: 'com_nav_auto_expand_tools' as const,
switchId: 'autoExpandTools',
hoverCardText: undefined,
key: 'autoExpandTools',
},
{
stateAtom: store.LaTeXParsing,
localizationKey: 'com_nav_latex_parsing' as const,
switchId: 'latexParsing',
hoverCardText: 'com_nav_info_latex_parsing' as const,
key: 'latexParsing',
},
{
stateAtom: store.saveDrafts,
localizationKey: 'com_nav_save_drafts' as const,
switchId: 'saveDrafts',
hoverCardText: 'com_nav_info_save_draft' as const,
key: 'saveDrafts',
},
{
stateAtom: store.showScrollButton,
localizationKey: 'com_nav_scroll_button' as const,
switchId: 'showScrollButton',
hoverCardText: undefined,
key: 'showScrollButton',
},
{
stateAtom: store.saveBadgesState,
localizationKey: 'com_nav_save_badges_state' as const,
switchId: 'showBadges',
hoverCardText: 'com_nav_info_save_badges_state' as const,
key: 'showBadges',
},
{
stateAtom: store.modularChat,
localizationKey: 'com_nav_modular_chat' as const,
switchId: 'modularChat',
hoverCardText: undefined,
key: 'modularChat',
},
{
stateAtom: store.defaultTemporaryChat,
localizationKey: 'com_nav_default_temporary_chat' as const,
switchId: 'defaultTemporaryChat',
hoverCardText: 'com_nav_info_default_temporary_chat' as const,
key: 'defaultTemporaryChat',
},
];
function Chat() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<FontSizeSelector />
</div>
<div className="pb-3">
<ChatDirection />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<div className="pb-3">
<AdvancedPrompts />
</div>
<ForkSettings />
</div>
);
}
export default memo(Chat);

View file

@ -1,82 +0,0 @@
import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const commandSwitchConfigs = [
{
stateAtom: store.atCommand,
localizationKey: 'com_nav_at_command_description' as const,
switchId: 'atCommand',
key: 'atCommand',
permissionType: undefined,
},
{
stateAtom: store.plusCommand,
localizationKey: 'com_nav_plus_command_description' as const,
switchId: 'plusCommand',
key: 'plusCommand',
permissionType: PermissionTypes.MULTI_CONVO,
},
{
stateAtom: store.slashCommand,
localizationKey: 'com_nav_slash_command_description' as const,
switchId: 'slashCommand',
key: 'slashCommand',
permissionType: PermissionTypes.PROMPTS,
},
] as const;
function Commands() {
const localize = useLocalize();
const hasAccessToPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
const hasAccessToMultiConvo = useHasAccess({
permissionType: PermissionTypes.MULTI_CONVO,
permission: Permissions.USE,
});
const getShowSwitch = (permissionType?: PermissionTypes) => {
if (!permissionType) {
return true;
}
if (permissionType === PermissionTypes.MULTI_CONVO) {
return hasAccessToMultiConvo === true;
}
if (permissionType === PermissionTypes.PROMPTS) {
return hasAccessToPrompts === true;
}
return true;
};
return (
<div className="space-y-4 p-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-text-primary">
{localize('com_nav_chat_commands')}
</h3>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
{commandSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
switchId={config.switchId}
showSwitch={getShowSwitch(config.permissionType)}
/>
</div>
))}
</div>
</div>
);
}
export default memo(Commands);

View file

@ -1,47 +0,0 @@
import React, { useState, useRef } from 'react';
import { useOnClickOutside } from '@librechat/client';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import ImportConversations from './ImportConversations';
import { AgentApiKeys } from './AgentApiKeys';
import { DeleteCache } from './DeleteCache';
import { RevokeKeys } from './RevokeKeys';
import { ClearChats } from './ClearChats';
import SharedLinks from './SharedLinks';
import { useHasAccess } from '~/hooks';
function Data() {
const dataTabRef = useRef(null);
const [confirmClearConvos, setConfirmClearConvos] = useState(false);
useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []);
const hasAccessToApiKeys = useHasAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permission: Permissions.USE,
});
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<ImportConversations />
</div>
<div className="pb-3">
<SharedLinks />
</div>
{hasAccessToApiKeys && (
<div className="pb-3">
<AgentApiKeys />
</div>
)}
<div className="pb-3">
<RevokeKeys />
</div>
<div className="pb-3">
<DeleteCache />
</div>
<div className="pb-3">
<ClearChats />
</div>
</div>
);
}
export default React.memo(Data);

View file

@ -1,28 +0,0 @@
import { useState } from 'react';
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
import ArchivedChatsTable from './ArchivedChatsTable';
import { useLocalize } from '~/hooks';
export default function ArchivedChats() {
const localize = useLocalize();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_archived_chats')}</div>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild>
<Button variant="outline" aria-label="Archived chats">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
title={localize('com_nav_archived_chats')}
className="max-w-[1000px]"
showCancelButton={false}
main={<ArchivedChatsTable onOpenChange={setIsOpen} />}
/>
</OGDialog>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
import type { RefObject } from 'react';
import ArchivedChatsTable from './ArchivedChatsTable';
import { useLocalize } from '~/hooks';
export function ArchivedChatsModal({
open,
onOpenChange,
triggerRef,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef?: RefObject<HTMLButtonElement | HTMLDivElement | null>;
}) {
const localize = useLocalize();
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogContent
title={localize('com_nav_archived_chats')}
className="w-11/12 max-w-[1000px] bg-background text-text-primary shadow-2xl"
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
</OGDialogHeader>
<ArchivedChatsTable onOpenChange={onOpenChange} />
</OGDialogContent>
</OGDialog>
);
}

View file

@ -3,7 +3,7 @@ import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { RecoilRoot } from 'recoil';
import { LangSelector } from './General';
import { LangSelector } from './Selectors';
import store from '~/store';
describe('LangSelector', () => {

View file

@ -1,43 +1,8 @@
import React, { useContext, useCallback } from 'react';
import Cookies from 'js-cookie';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Dropdown, Spinner, ThemeContext } from '@librechat/client';
import ArchivedChats from './ArchivedChats';
import ToggleSwitch from '../ToggleSwitch';
import { useRecoilValue } from 'recoil';
import { Dropdown, Spinner } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enableUserMsgMarkdown,
localizationKey: 'com_nav_user_msg_markdown' as const,
switchId: 'enableUserMsgMarkdown',
hoverCardText: undefined,
key: 'enableUserMsgMarkdown',
},
{
stateAtom: store.autoScroll,
localizationKey: 'com_nav_auto_scroll' as const,
switchId: 'autoScroll',
hoverCardText: undefined,
key: 'autoScroll',
},
{
stateAtom: store.keepScreenAwake,
localizationKey: 'com_nav_keep_screen_awake' as const,
switchId: 'keepScreenAwake',
hoverCardText: undefined,
key: 'keepScreenAwake',
},
{
stateAtom: store.newChatSwitchToHistory,
localizationKey: 'com_nav_new_chat_switch_to_history' as const,
switchId: 'newChatSwitchToHistory',
hoverCardText: undefined,
key: 'newChatSwitchToHistory',
},
];
export const ThemeSelector = ({
theme,
onChange,
@ -151,65 +116,16 @@ export const LangSelector = ({
<Dropdown
value={langcode}
onChange={onChange}
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
sizeClasses="[--anchor-max-height:256px] max-h-[60vh] w-[220px]"
options={languageOptions}
className="z-50"
aria-labelledby={labelId}
portal={portal}
searchable
searchPlaceholder={localize('com_ui_search_language')}
searchEmptyText={localize('com_ui_no_results_found')}
/>
</div>
</div>
);
};
function General() {
const { theme, setTheme } = useContext(ThemeContext);
const [langcode, setLangcode] = useRecoilState(store.lang);
const changeTheme = useCallback(
(value: string) => {
setTheme(value);
},
[setTheme],
);
const changeLang = useCallback(
(value: string) => {
let userLang = value;
if (value === 'auto') {
userLang = navigator.language || navigator.languages[0];
}
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},
[setLangcode],
);
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<ThemeSelector theme={theme} onChange={changeTheme} />
</div>
<div className="pb-3">
<LangSelector langcode={langcode} onChange={changeLang} />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<div className="pb-3">
<ArchivedChats />
</div>
</div>
);
}
export default React.memo(General);

View file

@ -1,11 +1,10 @@
// ThemeSelector.spec.tsx
import 'test/matchMedia.mock';
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ThemeSelector } from './General';
import { RecoilRoot } from 'recoil';
import { ThemeSelector } from './Selectors';
describe('ThemeSelector', () => {
let mockOnChange;

View file

@ -1,90 +0,0 @@
import { useState, useEffect } from 'react';
import { Switch, useToastContext } from '@librechat/client';
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
import { useLocalize } from '~/hooks';
interface PersonalizationProps {
hasMemoryOptOut: boolean;
hasAnyPersonalizationFeature: boolean;
}
export default function Personalization({
hasMemoryOptOut,
hasAnyPersonalizationFeature,
}: PersonalizationProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: user } = useGetUserQuery();
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_preferences_updated'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_error_updating_preferences'),
status: 'error',
});
// Revert the toggle on error
setReferenceSavedMemories((prev) => !prev);
},
});
// Initialize state from user data
useEffect(() => {
if (user?.personalization?.memories !== undefined) {
setReferenceSavedMemories(user.personalization.memories);
}
}, [user?.personalization?.memories]);
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
if (!hasAnyPersonalizationFeature) {
return (
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="text-text-secondary">{localize('com_ui_no_personalization_available')}</div>
</div>
);
}
return (
<div className="flex flex-col gap-3 text-sm text-text-primary">
{/* Memory Settings Section */}
{hasMemoryOptOut && (
<>
<div className="border-b border-border-medium pb-3">
<div className="text-base font-semibold">{localize('com_ui_memory')}</div>
</div>
<div className="flex items-center justify-between">
<div>
<div id="reference-saved-memories-label" className="flex items-center gap-2">
{localize('com_ui_reference_saved_memories')}
</div>
<div
id="reference-saved-memories-description"
className="mt-1 text-xs text-text-secondary"
>
{localize('com_ui_reference_saved_memories_description')}
</div>
</div>
<Switch
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading}
aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/>
</div>
</>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Dropdown } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -11,6 +11,7 @@ interface EngineSTTDropdownProps {
const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
const localize = useLocalize();
const [engineSTT, setEngineSTT] = useRecoilState<string>(store.engineSTT);
const speechToText = useRecoilValue(store.speechToText);
const endpointOptions = external
? [
@ -36,6 +37,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
testId="EngineSTTDropdown"
className="z-50"
aria-labelledby={labelId}
disabled={!speechToText}
/>
</div>
);

View file

@ -1,11 +1,12 @@
import { useRecoilState } from 'recoil';
import { Dropdown } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function LanguageSTTDropdown() {
const localize = useLocalize();
const [languageSTT, setLanguageSTT] = useRecoilState<string>(store.languageSTT);
const speechToText = useRecoilValue(store.speechToText);
const languageOptions = [
{ value: 'af', label: 'Afrikaans' },
@ -107,6 +108,7 @@ export default function LanguageSTTDropdown() {
testId="LanguageSTTDropdown"
className="z-50"
aria-labelledby={labelId}
disabled={!speechToText}
/>
</div>
);

View file

@ -1,243 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { Lightbulb, Cog } from 'lucide-react';
import { useOnClickOutside, useMediaQuery } from '@librechat/client';
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import {
CloudBrowserVoicesSwitch,
AutomaticPlaybackSwitch,
TextToSpeechSwitch,
EngineTTSDropdown,
CacheTTSSwitch,
VoiceDropdown,
PlaybackRate,
} from './TTS';
import {
AutoTranscribeAudioSwitch,
LanguageSTTDropdown,
SpeechToTextSwitch,
AutoSendTextSelector,
EngineSTTDropdown,
DecibelSelector,
} from './STT';
import ConversationModeSwitch from './ConversationModeSwitch';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
function Speech() {
const localize = useLocalize();
const [confirmClear, setConfirmClear] = useState(false);
const { data } = useGetCustomConfigSpeechQuery();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const [sttExternal, setSttExternal] = useState(false);
const [ttsExternal, setTtsExternal] = useState(false);
const [advancedMode, setAdvancedMode] = useRecoilState(store.advancedMode);
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState(store.autoTranscribeAudio);
const [conversationMode, setConversationMode] = useRecoilState(store.conversationMode);
const [speechToText, setSpeechToText] = useRecoilState(store.speechToText);
const [textToSpeech, setTextToSpeech] = useRecoilState(store.textToSpeech);
const [cacheTTS, setCacheTTS] = useRecoilState(store.cacheTTS);
const [engineSTT, setEngineSTT] = useRecoilState<string>(store.engineSTT);
const [languageSTT, setLanguageSTT] = useRecoilState<string>(store.languageSTT);
const [decibelValue, setDecibelValue] = useRecoilState(store.decibelValue);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
const [voice, setVoice] = useRecoilState(store.voice);
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
store.cloudBrowserVoices,
);
const [languageTTS, setLanguageTTS] = useRecoilState<string>(store.languageTTS);
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
const [playbackRate, setPlaybackRate] = useRecoilState(store.playbackRate);
const updateSetting = useCallback(
(key: string, newValue: string | number) => {
const settings = {
sttExternal: { value: sttExternal, setFunc: setSttExternal },
ttsExternal: { value: ttsExternal, setFunc: setTtsExternal },
conversationMode: { value: conversationMode, setFunc: setConversationMode },
advancedMode: { value: advancedMode, setFunc: setAdvancedMode },
speechToText: { value: speechToText, setFunc: setSpeechToText },
textToSpeech: { value: textToSpeech, setFunc: setTextToSpeech },
cacheTTS: { value: cacheTTS, setFunc: setCacheTTS },
engineSTT: { value: engineSTT, setFunc: setEngineSTT },
languageSTT: { value: languageSTT, setFunc: setLanguageSTT },
autoTranscribeAudio: { value: autoTranscribeAudio, setFunc: setAutoTranscribeAudio },
decibelValue: { value: decibelValue, setFunc: setDecibelValue },
autoSendText: { value: autoSendText, setFunc: setAutoSendText },
engineTTS: { value: engineTTS, setFunc: setEngineTTS },
voice: { value: voice, setFunc: setVoice },
cloudBrowserVoices: { value: cloudBrowserVoices, setFunc: setCloudBrowserVoices },
languageTTS: { value: languageTTS, setFunc: setLanguageTTS },
automaticPlayback: { value: automaticPlayback, setFunc: setAutomaticPlayback },
playbackRate: { value: playbackRate, setFunc: setPlaybackRate },
};
const setting = settings[key];
if (setting) {
setting.setFunc(newValue);
}
},
[
sttExternal,
ttsExternal,
conversationMode,
advancedMode,
speechToText,
textToSpeech,
cacheTTS,
engineSTT,
languageSTT,
autoTranscribeAudio,
decibelValue,
autoSendText,
engineTTS,
voice,
cloudBrowserVoices,
languageTTS,
automaticPlayback,
playbackRate,
setSttExternal,
setTtsExternal,
setConversationMode,
setAdvancedMode,
setSpeechToText,
setTextToSpeech,
setCacheTTS,
setEngineSTT,
setLanguageSTT,
setAutoTranscribeAudio,
setDecibelValue,
setAutoSendText,
setEngineTTS,
setVoice,
setCloudBrowserVoices,
setLanguageTTS,
setAutomaticPlayback,
setPlaybackRate,
],
);
useEffect(() => {
if (data && data.message !== 'not_found') {
Object.entries(data).forEach(([key, value]) => {
// Only apply config values as defaults if no user preference exists in localStorage
const existingValue = localStorage.getItem(key);
if (existingValue === null && key !== 'sttExternal' && key !== 'ttsExternal') {
updateSetting(key, value);
} else if (key === 'sttExternal' || key === 'ttsExternal') {
updateSetting(key, value);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Reset engineTTS if it is set to a removed/invalid value (e.g., 'edge')
// TODO: remove this once the 'edge' engine is fully deprecated
useEffect(() => {
const validEngines = ['browser', 'external'];
if (!validEngines.includes(engineTTS)) {
setEngineTTS('browser');
}
}, [engineTTS, setEngineTTS]);
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
return (
<Tabs.Root
defaultValue={'simple'}
orientation="horizontal"
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-background">
<Tabs.Trigger
onClick={() => setAdvancedMode(false)}
className={cn(
'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"
style={{ userSelect: 'none' }}
>
<Lightbulb aria-hidden="true" />
{localize('com_ui_simple')}
</Tabs.Trigger>
<Tabs.Trigger
onClick={() => setAdvancedMode(true)}
className={cn(
'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"
style={{ userSelect: 'none' }}
>
<Cog aria-hidden="true" />
{localize('com_ui_advanced')}
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value={'simple'} tabIndex={-1}>
<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'} tabIndex={-1}>
<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="pb-2">
<DecibelSelector />
</div>
)}
<div className="pb-2">
<AutoSendTextSelector />
</div>
<div className="h-px bg-border-medium" role="none" />
<div className="pb-3">
<TextToSpeechSwitch />
</div>
<AutomaticPlaybackSwitch />
<EngineTTSDropdown external={ttsExternal} />
<VoiceDropdown />
{engineTTS === 'browser' && (
<div className="pb-2">
<CloudBrowserVoicesSwitch />
</div>
)}
<div className="pb-2">
<PlaybackRate />
</div>
<CacheTTSSwitch />
</div>
</Tabs.Content>
</Tabs.Root>
);
}
export default React.memo(Speech);

View file

@ -1,3 +1,4 @@
import { useRecoilValue } from 'recoil';
import ToggleSwitch from '../../ToggleSwitch';
import store from '~/store';
@ -6,12 +7,14 @@ export default function AutomaticPlaybackSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const textToSpeech = useRecoilValue(store.textToSpeech);
return (
<ToggleSwitch
stateAtom={store.automaticPlayback}
localizationKey={'com_nav_automatic_playback' as const}
switchId="AutomaticPlayback"
onCheckedChange={onCheckedChange}
disabled={!textToSpeech}
/>
);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Dropdown } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -11,6 +11,7 @@ interface EngineTTSDropdownProps {
const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
const localize = useLocalize();
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
const textToSpeech = useRecoilValue(store.textToSpeech);
const endpointOptions = external
? [
@ -36,6 +37,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
testId="EngineTTSDropdown"
className="z-50"
aria-labelledby={labelId}
disabled={!textToSpeech}
/>
</div>
);

View file

@ -10,11 +10,12 @@ const voiceDropdownComponentsMap = {
export default function VoiceDropdown() {
const engineTTS = useRecoilValue<string>(store.engineTTS);
const textToSpeech = useRecoilValue(store.textToSpeech);
const VoiceDropdownComponent = voiceDropdownComponentsMap[engineTTS];
if (!VoiceDropdownComponent) {
return null;
}
return <VoiceDropdownComponent />;
return <VoiceDropdownComponent disabled={!textToSpeech} />;
}

View file

@ -1,9 +1,2 @@
export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization';
export { default as About } from './About/About';
export { ThemeSelector, LangSelector } from './General/Selectors';

View file

@ -16,7 +16,7 @@ import {
OGDialogContent,
OGDialogTrigger,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/Selectors';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';

View file

@ -1,15 +1,19 @@
import { useEffect, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
import { TTSEndpoints } from '~/common';
import { logger } from '~/utils';
import store from '~/store';
const VALID_TTS_ENGINES: string[] = [TTSEndpoints.browser, TTSEndpoints.external];
/**
* Initializes speech-related Recoil values from the server-side custom
* configuration on first load (only when the user is authenticated)
*/
export default function useSpeechSettingsInit(isAuthenticated: boolean) {
const { data } = useGetCustomConfigSpeechQuery({ enabled: isAuthenticated });
const [engineTTS, setEngineTTS] = useRecoilState<string>(store.engineTTS);
const setters = useRef({
conversationMode: useSetRecoilState(store.conversationMode),
@ -22,7 +26,7 @@ export default function useSpeechSettingsInit(isAuthenticated: boolean) {
autoTranscribeAudio: useSetRecoilState(store.autoTranscribeAudio),
decibelValue: useSetRecoilState(store.decibelValue),
autoSendText: useSetRecoilState(store.autoSendText),
engineTTS: useSetRecoilState(store.engineTTS),
engineTTS: setEngineTTS,
voice: useSetRecoilState(store.voice),
cloudBrowserVoices: useSetRecoilState(store.cloudBrowserVoices),
languageTTS: useSetRecoilState(store.languageTTS),
@ -47,4 +51,10 @@ export default function useSpeechSettingsInit(isAuthenticated: boolean) {
}
});
}, [isAuthenticated, data, setters]);
useEffect(() => {
if (VALID_TTS_ENGINES.includes(engineTTS)) return;
logger.log(`Resetting invalid TTS engine "${engineTTS}" to ${TTSEndpoints.browser}`);
setEngineTTS(TTSEndpoints.browser);
}, [engineTTS, setEngineTTS]);
}

View file

@ -13,7 +13,7 @@ To add a new language to LibreChat, follow these steps:
### 2. Update the Language Selector Component
Edit `client/src/components/Nav/SettingsTabs/General/General.tsx` and add your new language option to the `languageOptions` array:
Edit `client/src/components/Nav/SettingsTabs/General/Selectors.tsx` and add your new language option to the `languageOptions` array:
```typescript
{ value: 'language-code', label: localize('com_nav_lang_language_name') },

View file

@ -408,12 +408,10 @@
"com_nav_about_branch": "Branch",
"com_nav_about_build_date": "Built",
"com_nav_about_commit": "Commit",
"com_nav_about_diagnostics_copied": "Copied to clipboard",
"com_nav_about_diagnostics_copied": "Copied",
"com_nav_about_diagnostics_copy": "Copy diagnostics",
"com_nav_about_diagnostics_description": "Copy this block when opening a support issue so maintainers can identify the exact build you're running.",
"com_nav_about_diagnostics_heading": "Diagnostics",
"com_nav_about_version": "Version",
"com_nav_about_version_heading": "Build information",
"com_nav_account_settings": "Account Settings",
"com_nav_advanced_prompts": "Advanced prompts editor",
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
@ -457,8 +455,6 @@
"com_nav_browser": "Browser",
"com_nav_center_chat_input": "Center Chat Input on Welcome Screen",
"com_nav_change_picture": "Change picture",
"com_nav_chat_commands": "Chat Commands",
"com_nav_chat_commands_info": "These commands are activated by typing specific characters at the beginning of your message. Each command is triggered by its designated prefix. You can disable them if you frequently use these characters to start messages.",
"com_nav_chat_direction": "Chat direction",
"com_nav_chat_direction_selected": "Chat direction: {{direction}}",
"com_nav_clear_all_chats": "Clear all chats",
@ -466,7 +462,6 @@
"com_nav_clear_conversation": "Clear conversations",
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
"com_nav_close_sidebar": "Close sidebar",
"com_nav_commands": "Commands",
"com_nav_confirm_clear": "Confirm Clear",
"com_nav_control_panel": "Control Panel",
"com_nav_conversation_mode": "Conversation Mode",
@ -594,13 +589,10 @@
"com_nav_send_message": "Send message",
"com_nav_setting_about": "About",
"com_nav_setting_account": "Account",
"com_nav_setting_balance": "Balance",
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls",
"com_nav_setting_delay": "Delay (s)",
"com_nav_setting_general": "General",
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
"com_nav_setting_speech": "Speech",
"com_nav_settings": "Settings",
"com_nav_shared_links": "Shared links",
@ -1317,7 +1309,6 @@
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_memories_title": "No memories yet",
"com_ui_no_personalization_available": "No personalization options are currently available",
"com_ui_no_project_chats": "No chats yet",
"com_ui_no_projects": "No projects yet",
"com_ui_no_prompts_title": "No prompts yet",
@ -1510,6 +1501,7 @@
"com_ui_search_above_to_add_people": "Search above to add people",
"com_ui_search_agent_category": "Search categories...",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_search_language": "Search languages...",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
"com_ui_search_projects": "Search projects",
"com_ui_search_result_count": "{{count}} result found",
@ -1534,6 +1526,46 @@
"com_ui_select_search_provider": "Search provider by name",
"com_ui_select_search_region": "Search region by name",
"com_ui_set": "Set",
"com_ui_settings_label_2fa": "Two-factor authentication",
"com_ui_settings_label_agent_api_keys": "Agent API keys",
"com_ui_settings_label_auto_refill": "Auto-refill",
"com_ui_settings_label_avatar": "Avatar",
"com_ui_settings_label_backup_codes": "Backup codes",
"com_ui_settings_label_clear_chats": "Clear all chats",
"com_ui_settings_label_conversation_mode": "Conversation mode",
"com_ui_settings_label_credits": "Token balance",
"com_ui_settings_label_decibel": "Decibel threshold",
"com_ui_settings_label_delete_account": "Delete account",
"com_ui_settings_label_delete_cache": "Delete cache",
"com_ui_settings_label_engine_stt": "Speech-to-text engine",
"com_ui_settings_label_engine_tts": "Text-to-speech engine",
"com_ui_settings_label_import": "Import conversations",
"com_ui_settings_label_language_stt": "Speech-to-text language",
"com_ui_settings_label_playback_rate": "Playback rate",
"com_ui_settings_label_revoke_keys": "Revoke keys",
"com_ui_settings_label_shared_links": "Shared links",
"com_ui_settings_label_voice": "Voice",
"com_ui_settings_no_results": "No settings match your search",
"com_ui_settings_results_aria": "Search results",
"com_ui_settings_search_placeholder": "Search settings",
"com_ui_settings_section_accessibility": "Accessibility",
"com_ui_settings_section_api_keys": "API keys",
"com_ui_settings_section_appearance": "Appearance",
"com_ui_settings_section_billing": "Billing",
"com_ui_settings_section_commands": "Commands",
"com_ui_settings_section_conversations": "Conversations",
"com_ui_settings_section_danger_zone": "Danger zone",
"com_ui_settings_section_data": "Your data",
"com_ui_settings_section_layout": "Layout",
"com_ui_settings_section_memory": "Memory",
"com_ui_settings_section_messages": "Messages",
"com_ui_settings_section_profile": "Profile",
"com_ui_settings_section_prompts": "Prompts",
"com_ui_settings_section_security": "Security",
"com_ui_settings_section_sending": "Sending",
"com_ui_settings_section_stt": "Speech to text",
"com_ui_settings_section_tts": "Text to speech",
"com_ui_settings_tab_data": "Data & Privacy",
"com_ui_share": "Share",
"com_ui_share_create_message": "Your name and any messages you add after sharing stay private.",
"com_ui_share_delete_error": "There was an error deleting the shared link",
@ -1559,7 +1591,6 @@
"com_ui_show_n_files": "Show {{0}} files",
"com_ui_show_qr": "Show QR Code",
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
"com_ui_simple": "Simple",
"com_ui_size": "Size",
"com_ui_size_sort": "Sort by Size",
"com_ui_skill": "Skill",

View file

@ -2703,10 +2703,6 @@ html {
color: var(--text-primary);
}
.account-settings-popover .select-item[data-active-item] {
box-shadow: 0 0 0 2px hsl(var(--ring));
}
.popover-ui[data-enter] {
opacity: 1;
scale: 1;

View file

@ -157,7 +157,9 @@ test.describe('agent builder', () => {
const createdAgent = (await createResponse.json()) as AgentDetail;
createdAgentId = createdAgent.id;
await expect(page.getByText(`Successfully created ${agentName}`)).toBeVisible();
await expect(
page.getByText(`Successfully created ${agentName}`, { exact: true }),
).toBeVisible();
const persistedAgent = await waitForPersistedAgent(page, agentName, DESCRIPTION);
expect(persistedAgent).toMatchObject({

View file

@ -1,5 +1,8 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { matchSorter } from 'match-sorter';
import * as Select from '@ariakit/react/select';
import * as Combobox from '@ariakit/react/combobox';
import type { Option } from '~/common';
import { cn } from '~/utils/';
import './Dropdown.css';
@ -18,6 +21,10 @@ interface DropdownProps {
ariaLabel?: string;
'aria-labelledby'?: string;
portal?: boolean;
disabled?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
searchEmptyText?: string;
}
const isDivider = (item: string | Option | { divider: true }): item is { divider: true } =>
@ -26,6 +33,9 @@ const isDivider = (item: string | Option | { divider: true }): item is { divider
const isOption = (item: string | Option | { divider: true }): item is Option =>
typeof item === 'object' && 'value' in item && 'label' in item;
const normalizeOption = (item: string | Option): Option =>
typeof item === 'string' ? { value: item, label: item } : item;
const Dropdown: React.FC<DropdownProps> = ({
value: selectedValue,
label = '',
@ -40,12 +50,25 @@ const Dropdown: React.FC<DropdownProps> = ({
ariaLabel,
'aria-labelledby': ariaLabelledBy,
portal = true,
disabled = false,
searchable = false,
searchPlaceholder,
searchEmptyText,
}) => {
const [searchValue, setSearchValue] = useState('');
const handleChange = (value: string) => {
onChange(value);
};
const comboboxStore = Combobox.useComboboxStore({
resetValueOnHide: true,
value: searchValue,
setValue: setSearchValue,
});
const selectProps = Select.useSelectStore({
combobox: searchable ? comboboxStore : undefined,
value: selectedValue,
setValue: handleChange,
});
@ -56,8 +79,8 @@ const Dropdown: React.FC<DropdownProps> = ({
}
return options
.filter((o) => !isDivider(o))
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
.find((o) => isOption(o) && o.value === val) as Option | undefined;
.map((o) => normalizeOption(o as string | Option))
.find((o) => o.value === val);
};
const getOptionLabel = (currentValue: string | undefined) => {
@ -68,12 +91,54 @@ const Dropdown: React.FC<DropdownProps> = ({
return option ? option.label : currentValue;
};
const matches = useMemo(() => {
if (!searchable) {
return [];
}
const optionItems = options
.filter((o) => !isDivider(o))
.map((o) => normalizeOption(o as string | Option));
return matchSorter(optionItems, searchValue, {
keys: ['label', 'value'],
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
});
}, [searchable, options, searchValue]);
const renderOptionContent = (option: Option) => (
<div className="flex w-full items-center gap-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span className="block truncate">{option.label}</span>
{selectedValue === option.value && (
<span className="ml-auto pl-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block group-hover:hidden"
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</span>
)}
</div>
);
return (
<div className={cn('relative', className)}>
<Select.Select
store={selectProps}
disabled={disabled}
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background disabled:hover:text-text-primary',
iconOnly ? 'size-10' : 'w-fit gap-2',
className,
)}
@ -109,49 +174,74 @@ const Dropdown: React.FC<DropdownProps> = ({
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
)}
>
{options.map((item, index) => {
if (isDivider(item)) {
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
}
const option = typeof item === 'string' ? { value: item, label: item } : item;
if (!isOption(option)) {
return null;
}
return (
<Select.SelectItem
key={`option-${index}`}
value={String(option.value)}
className="select-item"
data-theme={option.value}
>
<div className="flex w-full items-center gap-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span className="block truncate">{option.label}</span>
{selectedValue === option.value && (
<span className="ml-auto pl-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block group-hover:hidden"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</span>
)}
{searchable ? (
<>
<div className="sticky -top-2 z-10 -mx-2 -mt-2 mb-1 bg-surface-primary px-2 pb-1.5 pt-2 dark:bg-surface-secondary">
<div className="relative">
<Search
className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
aria-hidden="true"
/>
<Combobox.Combobox
store={comboboxStore}
autoSelect
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="w-full rounded-lg bg-transparent py-1.5 pl-8 pr-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-border-xheavy"
/>
</div>
</Select.SelectItem>
);
})}
</div>
<Combobox.ComboboxList
store={comboboxStore}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className="flex flex-col"
>
{matches.map((option) => (
<Combobox.ComboboxItem
key={`option-${String(option.value)}`}
value={String(option.value)}
className="select-item"
render={
<Select.SelectItem value={String(option.value)} data-theme={option.value} />
}
>
{renderOptionContent(option)}
</Combobox.ComboboxItem>
))}
</Combobox.ComboboxList>
{matches.length === 0 && (
<div className="px-2 py-6 text-center text-sm text-text-secondary" aria-hidden="true">
{searchEmptyText}
</div>
)}
<div role="status" aria-live="polite" className="sr-only">
{matches.length === 0 ? searchEmptyText : ''}
</div>
</>
) : (
options.map((item, index) => {
if (isDivider(item)) {
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
}
const option = normalizeOption(item);
if (!isOption(option)) {
return null;
}
return (
<Select.SelectItem
key={`option-${index}`}
value={String(option.value)}
className="select-item"
data-theme={option.value}
>
{renderOptionContent(option)}
</Select.SelectItem>
);
})
)}
</Select.SelectPopover>
</div>
);