refactor: Presets, Skills Motion and Model Selector Polish (#14953)

* refactor: presets, skills motion and model selector polish

Four surfaces that had drifted from the rest of the app, plus the CI
fragility that surfaced while getting them green.

Two were functional bugs rather than styling:

Keyboard focus was invisible in the model selector. The highlight rule
existed and the background was painted, but it used surface-secondary and
the menu sits on bg-presentation, which resolve to the same value in dark
and to within 3/255 in light, so only the thin indicator bar ever showed.
Keyboard focus now uses the same surface a pointer gets.

Importing a malformed preset raised com_ui_upload_invalid, which talks
about image size limits, and FileUpload's JSON.parse had nothing catching
it at that call site. The overflow menu owns the input and reports the
existing preset import error instead.

The rest is polish: preset surfaces use the theme radius roles rather than
raw values; the edit dialog stops nesting a fixed 350px scroll box inside
an already scrolling dialog and pins its title and actions, with the
endpoint picker moved to ControlCombobox and kept out of any clipping
ancestor; Clear all and Import move into a three-dots menu matching the
conversation row; the Skills sections and pinned chats adopt the Collapse
that Projects already used; the rendered/source toggle slides between
states, is extracted rather than duplicated, and gains the accessible name
and RTL mirroring it lacked; the header toggle loses its fill and the
mobile new chat button hides when you are already in a new chat.

The CI changes are unrelated to the UI but blocked it: the MCP and Redis
cache jobs installed Redis with a bare apt-get and lost a race against the
runner's own apt-daily work, failing four times and once hanging for 30
minutes. They now stop that background work and wait for the lock.
DPkg::Lock::Timeout alone does not help, since it covers the dpkg frontend
lock and not the lists lock.

* refactor: move the section label appearance into the Label primitive

The preset dialog reached into the agent panel's private `Advanced/ui` for
its field eyebrow, so an agent-only refactor could change the dialog.

Give the shared `Label` a `section` variant and export the recipe for the
agent id row, which heads its value on a span and must not inherit the
label's block layout. Each variant carries its own size, leading and color:
the recipe output reaches that span unmerged, and a font size declared after
`leading-none` drops it.

* fix: derive the mobile new chat action from the route

The context conversation still holds the previous chat for a render after a
history or link navigation, a lag ChatView already guards against, so the
action could show on /c/new or hide while an existing chat loaded.

* style: sort imports in the touched files

* fix: return focus to the menu item after the clear dialog

The dialog is controlled and has no trigger, so Radix restored focus to
whatever held it when the content mounted, the menu's own focus trap, and a
keyboard user was left on the document. The menu stays open behind the
dialog, so the invoking item is still there to take focus back.

* fix: fall back to the trigger when clearing removes the invoking item

Confirming empties the presets optimistically, so React commits the removed
menu item together with the dialog close and the saved invoker is already
disconnected when focus is handed back.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-08-19 21:49:47 +02:00 committed by GitHub
parent f431b01d1f
commit 16e4d14191
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 544 additions and 227 deletions

View file

@ -45,9 +45,29 @@ jobs:
node-version: '24.16.0'
- name: Install Redis tools
timeout-minutes: 10
run: |
sudo apt-get update
sudo apt-get install -y redis-server redis-tools
# Same runner apt contention that broke the MCP job in
# playwright-mock.yml: apt-daily/unattended-upgrades hold
# /var/lib/apt/lists/lock at boot. Without a step timeout this hung
# until the job-level one fired, taking the whole leg with it.
sudo systemctl stop apt-daily.service apt-daily-upgrade.service \
unattended-upgrades.service 2>/dev/null || true
sudo systemctl kill --kill-who=all apt-daily.service \
apt-daily-upgrade.service 2>/dev/null || true
apt_with_lock_wait() {
for attempt in $(seq 1 30); do
if sudo apt-get -o DPkg::Lock::Timeout=60 "$@"; then
return 0
fi
echo "apt-get $1 could not take the lock (attempt ${attempt}/30), retrying"
sleep 10
done
return 1
}
apt_with_lock_wait update
apt_with_lock_wait install -y redis-server redis-tools
- name: Start Single Redis Instance
run: |

View file

@ -1,6 +1,12 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
import { useParams } from 'react-router-dom';
import {
getConfigDefaults,
Constants,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { useGetStartupConfig } from '~/data-provider';
@ -31,6 +37,13 @@ function Header({
const { data: startupConfig } = useGetStartupConfig();
const navVisible = useRecoilValue(store.sidebarExpanded);
/** The mobile row only offers a new chat when there is one to leave. Read
* from the route rather than the context conversation, which still holds the
* previous chat for a render after a history or link navigation. An unsaved
* conversation has no id in the route yet, so absence counts as new too. */
const { conversationId: routeConversationId } = useParams();
const isNewChat = routeConversationId == null || routeConversationId === Constants.NEW_CONVO;
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
@ -90,7 +103,7 @@ function Header({
</div>
<div className={cn('flex flex-shrink-0 items-center gap-2', hiddenBehindNav)}>
<NewChat className="md:hidden" />
{!isNewChat && <NewChat className="md:hidden" />}
<HeaderMenu startupConfig={startupConfig} className="md:hidden" />
<div className="hidden items-center gap-2 md:flex">
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />

View file

@ -42,9 +42,12 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
const rootMenuStateClass = isOpen
? 'bg-surface-active-alt hover:bg-surface-active-alt'
: 'bg-presentation hover:bg-surface-active-alt';
/** Nested triggers sit on the popover, whose bg-presentation resolves to the
* same value as surface-secondary in dark and within 3/255 of it in light,
* so highlighting with it leaves keyboard focus invisible. */
const nestedMenuStateClass = isOpen
? 'bg-surface-secondary hover:bg-surface-hover data-[active-item]:bg-surface-secondary data-[active-item]:hover:bg-surface-hover'
: 'hover:bg-surface-hover data-[active-item]:bg-surface-secondary data-[active-item]:hover:bg-surface-hover';
? 'bg-surface-hover'
: 'hover:bg-surface-hover data-[active-item]:bg-surface-hover';
const element = (
<Ariakit.MenuProvider store={menuStore} values={values} setValues={onValuesChange}>
@ -172,7 +175,11 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
blurOnHoverEnd: false,
...props,
className: cn(
'relative flex w-full min-w-0 cursor-default scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] items-center gap-2 rounded-lg px-2 py-1 outline-none! hover:bg-surface-hover aria-disabled:opacity-25 aria-selected:bg-surface-secondary data-[active-item]:bg-surface-secondary data-[active-item]:text-text-primary data-[active-item]:hover:bg-surface-hover sm:text-sm before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-transparent data-[active-item]:before:bg-text-primary',
/** Keyboard focus uses the hover surface: the menu sits on
* bg-presentation, which resolves to the same value as
* surface-secondary in dark and within 3/255 of it in light, so an
* active item styled that way cannot render against its own popover. */
'relative flex w-full min-w-0 cursor-default scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] items-center gap-2 rounded-lg px-2 py-1 outline-none! hover:bg-surface-hover aria-disabled:opacity-25 aria-selected:bg-surface-hover data-[active-item]:bg-surface-hover data-[active-item]:text-text-primary sm:text-sm before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-transparent data-[active-item]:before:bg-text-primary',
props.className,
),
};

View file

@ -1,26 +1,20 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, isAgentsEndpoint } from 'librechat-data-provider';
import { QueryKeys, alternateName, isAgentsEndpoint } from 'librechat-data-provider';
import {
Input,
Label,
Button,
OGDialog,
OGDialogTitle,
SelectDropDown,
ControlCombobox,
OGDialogContent,
} from '@librechat/client';
import type { TModelsConfig, TEndpointsConfig } from 'librechat-data-provider';
import {
cn,
defaultTextProps,
removeFocusOutlines,
mapEndpoints,
getConvoSwitchLogic,
} from '~/utils';
import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks';
import PopoverButtons from '~/components/Chat/Input/PopoverButtons';
import { mapEndpoints, getConvoSwitchLogic } from '~/utils';
import { EndpointSettings } from '~/components/Endpoints';
import { useGetEndpointsQuery } from '~/data-provider';
import { useChatContext } from '~/Providers';
@ -54,6 +48,15 @@ const EditPresetDialog = ({
return _endpoints.filter((endpoint) => !isAgentsEndpoint(endpoint));
}, [_endpoints]);
const endpointItems = useMemo(
() =>
availableEndpoints.map((value) => ({
value,
label: alternateName[value] ?? value,
})),
[availableEndpoints],
);
useEffect(() => {
if (!preset) {
return;
@ -133,45 +136,51 @@ const EditPresetDialog = ({
return (
<OGDialog open={presetModalVisible} onOpenChange={handleOpenChange} triggerRef={triggerRef}>
<OGDialogContent className="h-[100dvh] max-h-[100dvh] w-full max-w-full overflow-y-auto bg-surface-dialog md:h-auto md:max-h-[90vh] md:max-w-[75vw] md:rounded-lg lg:max-w-[950px]">
<OGDialogTitle>
<OGDialogContent className="flex h-[100dvh] max-h-[100dvh] w-full max-w-full flex-col overflow-y-visible bg-surface-dialog md:h-auto md:max-h-[90vh] md:max-w-[75vw] md:rounded-theme-surface lg:max-w-[950px]">
<OGDialogTitle className="shrink-0">
{localize('com_ui_edit_preset_title', { title: preset?.title })}
</OGDialogTitle>
<div className="flex w-full flex-col gap-2 px-1 pb-4 md:gap-4">
{/* Header section with preset name and endpoint */}
<div className="grid w-full gap-2 md:grid-cols-2 md:gap-4">
<div className="flex w-full flex-col">
<Label htmlFor="preset-name" className="mb-1 text-left text-sm font-medium">
{localize('com_endpoint_preset_name')}
</Label>
<Input
id="preset-name"
value={(title as string | undefined) ?? ''}
onChange={onTitleChange}
placeholder={localize('com_endpoint_set_custom_name')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
/>
</div>
<div className="flex w-full flex-col">
<Label htmlFor="endpoint" className="mb-1 text-left text-sm font-medium">
{localize('com_endpoint')}
</Label>
<SelectDropDown
value={endpoint || ''}
setValue={switchEndpoint}
showLabel={false}
emptyTitle={true}
searchPlaceholder={localize('com_endpoint_search')}
availableValues={availableEndpoints}
/>
</div>
{/* Pinned above the scroller, and the dialog itself is overflow-visible:
ControlCombobox renders its popover in place (portal={false} for the
dialog's focus trap), so no ancestor may clip it. The flex column
still bounds the dialog because the settings region below owns the
only scroll. */}
<div className="grid w-full shrink-0 gap-3 md:grid-cols-2 md:gap-4">
<div className="flex w-full flex-col">
<Label htmlFor="preset-name" variant="section">
{localize('com_endpoint_preset_name')}
</Label>
<Input
id="preset-name"
value={(title as string | undefined) ?? ''}
onChange={onTitleChange}
placeholder={localize('com_endpoint_set_custom_name')}
className="h-9 w-full rounded-theme-control border-border-medium px-3 py-2"
/>
</div>
<div className="flex w-full flex-col">
<Label htmlFor="endpoint" variant="section">
{localize('com_endpoint')}
</Label>
<ControlCombobox
selectedValue={endpoint || ''}
displayValue={alternateName[endpoint ?? ''] ?? endpoint ?? ''}
items={endpointItems}
setValue={switchEndpoint}
ariaLabel={localize('com_endpoint')}
searchPlaceholder={localize('com_endpoint_search')}
selectPlaceholder={localize('com_endpoint')}
isCollapsed={false}
showCarat={true}
/** The dialog traps focus and clips a portaled popover */
portal={false}
/>
</div>
</div>
{/* Only this region scrolls, so the title, the fields above and the actions stay put */}
<div className="flex min-h-0 w-full flex-1 flex-col gap-3 overflow-y-auto px-1 md:gap-4">
{/* PopoverButtons section */}
<div className="flex w-full">
<PopoverButtons
@ -186,25 +195,27 @@ const EditPresetDialog = ({
{/* Separator */}
<div className="w-full border-t border-border-medium" />
{/* Settings section */}
<div className="w-full flex-1">
{/* Settings section. The shared component ships a fixed-height scroll
box; overriding it to auto lets the dialog own the single scroll
rather than nesting one inside another. */}
<div className="w-full">
<EndpointSettings
conversation={preset}
setOption={setOption}
isPreset={true}
className="text-text-primary"
className="h-auto overflow-visible text-text-primary md:h-auto"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 border-t border-border-medium pt-2 md:pt-4">
<Button variant="outline" onClick={exportPreset}>
{localize('com_endpoint_export')}
</Button>
<Button variant="submit" onClick={submitPreset}>
{localize('com_ui_save')}
</Button>
</div>
{/* Action buttons */}
<div className="flex shrink-0 justify-end gap-2 border-t border-border-medium pt-3">
<Button variant="outline" onClick={exportPreset}>
{localize('com_endpoint_export')}
</Button>
<Button variant="submit" onClick={submitPreset}>
{localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
</OGDialog>

View file

@ -1,14 +1,18 @@
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { Close } from '@radix-ui/react-popover';
import { BookCopy, FileX2 } from 'lucide-react';
import { Flipper, Flipped } from 'react-flip-toolkit';
import { getEndpointField } from 'librechat-data-provider';
import { BookCopy, FileUp, FileX2, Ellipsis } from 'lucide-react';
import {
Button,
PinIcon,
EditIcon,
TrashIcon,
DropdownPopup,
TooltipAnchor,
useToastContext,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
@ -17,11 +21,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@librechat/client';
import type { MenuItemProps } from '@librechat/client';
import type { TPreset } from 'librechat-data-provider';
import type { FC } from 'react';
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
import type { ChangeEvent, FC } from 'react';
import { useGetEndpointsQuery } from '~/data-provider';
import { getPresetTitle, getIconKey } from '~/utils';
import { icons } from '~/hooks/Endpoint/Icons';
@ -30,6 +33,9 @@ import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/** Shared by the trigger and the clear dialog's focus fallback. */
const PRESET_MENU_ID = 'preset-options-button';
const PresetItems: FC<{
presets?: Array<TPreset | undefined>;
onSetDefaultPreset: (preset: TPreset, remove?: boolean) => void;
@ -52,7 +58,54 @@ const PresetItems: FC<{
const { data: endpointsConfig } = useGetEndpointsQuery();
const defaultPreset = useRecoilValue(store.defaultPreset);
const localize = useLocalize();
const { showToast } = useToastContext();
const hasPresets = (presets?.length ?? 0) > 0;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isClearDialogOpen, setIsClearDialogOpen] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
/** Radix restores focus to whatever held it when the dialog mounted, which by
* then is the menu's own focus trap rather than the item that opened it. */
const clearInvokerRef = useRef<HTMLElement | null>(null);
const handleImportChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
/** Cleared so re-picking the same file still fires a change event */
event.target.value = '';
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
onFileSelected(JSON.parse(e.target?.result as string));
} catch {
showToast({ message: localize('com_endpoint_preset_import_error'), status: 'error' });
}
};
reader.readAsText(file);
};
const menuItems: MenuItemProps[] = [
{
label: localize('com_ui_import'),
onClick: () => importInputRef.current?.click(),
icon: <FileUp className="icon-sm text-text-primary" aria-hidden="true" />,
},
{
label: localize('com_ui_clear_all'),
onClick: () => {
clearInvokerRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setIsClearDialogOpen(true);
},
icon: <FileX2 className="icon-sm" aria-hidden="true" />,
className: 'text-text-destructive',
show: hasPresets,
ariaHasPopup: 'dialog' as const,
hideOnClick: false,
},
];
return (
<>
@ -69,47 +122,76 @@ const PresetItems: FC<{
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{hasPresets && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
type="button"
className="h-8 px-2 text-xs font-normal text-text-secondary hover:text-text-destructive"
aria-label={localize('com_ui_clear_all')}
>
<FileX2 className="size-4" aria-hidden="true" />
{localize('com_ui_clear_all')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="w-11/12 max-w-md rounded-lg">
<AlertDialogHeader>
<AlertDialogTitle>{localize('com_ui_clear_presets')}</AlertDialogTitle>
<AlertDialogDescription>
{localize('com_endpoint_presets_clear_warning')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{localize('com_ui_cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={clearAllPresets}
className="bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover"
>
{localize('com_ui_clear')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<FileUpload
id="preset-import"
onFileSelected={onFileSelected}
containerClassName="mr-0 h-8 hover:text-text-primary"
/>
</div>
<DropdownPopup
portal={true}
menuId="preset-options-menu"
focusLoop={true}
className="z-[125]"
unmountOnHide={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Ariakit.MenuButton
id={PRESET_MENU_ID}
aria-label={localize('com_ui_more_options')}
aria-expanded={isMenuOpen}
className={cn(
'inline-flex size-8 shrink-0 items-center justify-center rounded-theme-control transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
isMenuOpen ? 'bg-surface-hover text-text-primary' : 'text-text-secondary',
)}
>
<Ellipsis className="icon-md" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
</div>
<input
ref={importInputRef}
type="file"
accept=".json"
className="hidden"
tabIndex={-1}
onChange={handleImportChange}
/>
<AlertDialog open={isClearDialogOpen} onOpenChange={setIsClearDialogOpen}>
<AlertDialogContent
/** The menu stays open behind the dialog (`hideOnClick: false`), so
* the item is still there to take focus back. */
onCloseAutoFocus={(event) => {
const saved = clearInvokerRef.current;
clearInvokerRef.current = null;
/** Confirming removes the item itself, since it only shows while
* presets exist, so fall back to the trigger that opened the menu. */
const invoker =
saved?.isConnected === true ? saved : document.getElementById(PRESET_MENU_ID);
if (invoker == null) {
return;
}
event.preventDefault();
invoker.focus();
}}
className="w-11/12 max-w-md rounded-theme-surface sm:rounded-theme-surface"
>
<AlertDialogHeader>
<AlertDialogTitle>{localize('com_ui_clear_presets')}</AlertDialogTitle>
<AlertDialogDescription>
{localize('com_endpoint_presets_clear_warning')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{localize('com_ui_cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={clearAllPresets}
className="bg-surface-destructive text-text-on-status hover:bg-surface-destructive-hover"
>
{localize('com_ui_clear')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{presets && presets.length === 0 && (
<div className="flex min-h-40 flex-col items-center justify-center gap-3 px-6 py-8 text-center">
<div className="rounded-full bg-surface-secondary p-2.5 text-text-secondary">
@ -152,11 +234,11 @@ const PresetItems: FC<{
<Close asChild key={`preset-${presetId}`}>
<div key={`preset-${presetId}`}>
<Flipped flipId={presetId}>
<div className="group m-1.5 flex items-center gap-2 rounded px-3 py-1.5 text-sm hover:bg-surface-hover">
<div className="group m-1.5 flex items-center gap-2 rounded-theme-control px-3 py-1.5 text-sm hover:bg-surface-hover">
<Button
variant="ghost"
type="button"
className="h-auto min-w-0 flex-1 justify-start gap-1 bg-transparent p-2 text-left text-xs font-normal hover:bg-transparent focus-visible:ring-offset-0"
className="h-auto min-w-0 flex-1 justify-start gap-1 rounded-theme-control bg-transparent p-2 text-left text-xs font-normal hover:bg-transparent focus-visible:ring-offset-0"
onClick={() => onSelectPreset(preset)}
aria-label={presetTitle}
data-testid={`preset-item-${presetId}`}
@ -187,7 +269,7 @@ const PresetItems: FC<{
<Button
variant="ghost"
className={cn(
'm-0 h-full rounded-md bg-transparent p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary',
'm-0 h-full rounded-theme-control-round bg-transparent p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary',
defaultPreset?.presetId === presetId
? ''
: // opacity keeps buttons in the tab order; pointer-events-none
@ -210,7 +292,7 @@ const PresetItems: FC<{
render={
<Button
variant="ghost"
className="m-0 h-full rounded-md p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
className="m-0 h-full rounded-theme-control-round p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -227,7 +309,7 @@ const PresetItems: FC<{
render={
<Button
variant="ghost"
className="m-0 h-full rounded-md p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
className="m-0 h-full rounded-theme-control-round p-2 text-text-tertiary hover:text-text-primary focus:text-text-primary sm:pointer-events-none sm:opacity-0 sm:transition-opacity sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View file

@ -0,0 +1,118 @@
import { useState } from 'react';
import { RecoilRoot } from 'recoil';
import * as Popover from '@radix-ui/react-popover';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
import type { TPreset } from 'librechat-data-provider';
import PresetItems from '../PresetItems';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: {} }),
}));
jest.mock('~/hooks/Endpoint/Icons', () => ({
icons: {},
}));
const preset = {
presetId: 'preset-1',
title: 'A preset',
endpoint: 'openAI',
} as TPreset;
/**
* The real clear is an optimistic query update, so React commits the emptied
* list and the dialog close together: the item that opened the dialog is
* already gone when focus is handed back.
*/
function Harness({ onClear }: { onClear: () => void }) {
const [presets, setPresets] = useState<TPreset[]>([preset]);
return (
<RecoilRoot>
{/* Each row wraps itself in a popover `Close`, which needs the context. */}
<Popover.Root open={true}>
<PresetItems
presets={presets}
onSetDefaultPreset={jest.fn()}
onSelectPreset={jest.fn()}
onChangePreset={jest.fn()}
onDeletePreset={jest.fn()}
clearAllPresets={() => {
setPresets([]);
onClear();
}}
onFileSelected={jest.fn()}
/>
</Popover.Root>
</RecoilRoot>
);
}
function setup() {
const onClear = jest.fn();
render(<Harness onClear={onClear} />);
return { onClear };
}
describe('PresetItems clear-all dialog', () => {
/**
* The dialog is controlled and has no trigger, and the menu item that opens
* it sets `hideOnClick: false` so the menu stays open behind it. Focus has to
* come back to that item, or a keyboard user is left on the document.
*/
it('returns focus to the invoking menu item when the dialog is dismissed', async () => {
const user = userEvent.setup();
setup();
await user.click(screen.getByRole('button', { name: 'com_ui_more_options' }));
const clearItem = await screen.findByRole('menuitem', { name: 'com_ui_clear_all' });
await user.click(clearItem);
const cancel = await screen.findByRole('button', { name: 'com_ui_cancel' });
await user.click(cancel);
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(document.activeElement).toBe(clearItem);
});
});
it('clears the presets when the dialog is confirmed', async () => {
const user = userEvent.setup();
const { onClear } = setup();
await user.click(screen.getByRole('button', { name: 'com_ui_more_options' }));
await user.click(await screen.findByRole('menuitem', { name: 'com_ui_clear_all' }));
await user.click(await screen.findByRole('button', { name: 'com_ui_clear' }));
expect(onClear).toHaveBeenCalled();
});
/**
* Confirming empties the list optimistically, and the item only shows while
* presets exist, so the element that opened the dialog is gone by the time
* focus is handed back.
*/
it('falls back to the options trigger when confirming removed the item', async () => {
const user = userEvent.setup();
setup();
const trigger = screen.getByRole('button', { name: 'com_ui_more_options' });
await user.click(trigger);
await user.click(await screen.findByRole('menuitem', { name: 'com_ui_clear_all' }));
await user.click(await screen.findByRole('button', { name: 'com_ui_clear' }));
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(document.activeElement).toBe(trigger);
});
});
});

View file

@ -74,7 +74,7 @@ const PresetsMenu: FC = () => {
id="presets-button"
data-testid="presets-button"
aria-label={localize('com_endpoint_examples')}
className="h-9 w-9 shrink-0 rounded-xl bg-presentation duration-0 hover:bg-surface-hover radix-state-open:bg-surface-active-alt"
className="h-9 w-9 shrink-0 rounded-theme-control bg-presentation duration-0 hover:bg-surface-hover radix-state-open:bg-surface-active-alt"
>
<BookCopy className="icon-md" aria-hidden="true" />
</Button>
@ -88,7 +88,7 @@ const PresetsMenu: FC = () => {
sideOffset={8}
collisionPadding={16}
aria-label={localize('com_endpoint_examples')}
className="z-50 max-h-[495px] overflow-x-hidden rounded-lg border border-border-light bg-presentation text-text-primary shadow-lg md:min-w-[400px]"
className="z-50 max-h-[495px] overflow-x-hidden rounded-theme-surface border border-border-light bg-presentation text-text-primary shadow-lg md:min-w-[400px]"
>
<PresetItems
presets={presetsQuery.data}

View file

@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
import { useLocalize, useLocalStorage } from '~/hooks';
import { useActiveJobs } from '~/data-provider';
import { Collapse } from '~/components/ui';
import { cn } from '~/utils';
import Convo from './Convo';
@ -50,7 +51,7 @@ const PinnedSection = ({ conversations, toggleNav }: PinnedSectionProps) => {
</button>
</div>
{isExpanded && (
<Collapse open={isExpanded}>
<div className="scrollbar-gutter-stable max-h-[30vh] overflow-y-auto">
<ul className="m-0 list-none p-0">
{conversations.map((convo) => (
@ -65,7 +66,7 @@ const PinnedSection = ({ conversations, toggleNav }: PinnedSectionProps) => {
))}
</ul>
</div>
)}
</Collapse>
</div>
);
};

View file

@ -74,7 +74,9 @@ describe('PinnedSection', () => {
'aria-expanded',
'false',
);
expect(screen.queryByText('Pinned Chat')).not.toBeInTheDocument();
/** Collapse keeps children mounted so the height can tween, and hides them
* from assistive tech instead, the same as ProjectsSection above it. */
expect(screen.getByText('Pinned Chat').closest('[aria-hidden="true"]')).not.toBeNull();
});
it('toggles the section when the header is clicked', () => {

View file

@ -77,7 +77,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
value={title || ''}
onChange={(e) => setTitle(e.target.value || '')}
placeholder={localize('com_endpoint_preset_custom_name_placeholder')}
className="flex h-10 max-h-10 w-full resize-none border-border-medium px-3 py-2"
className="flex h-10 max-h-10 w-full resize-none rounded-theme-control border-border-medium px-3 py-2"
/>
</div>
</form>

View file

@ -2,13 +2,13 @@ import { useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { ChevronLeft, Check, Copy } from 'lucide-react';
import { AgentCapabilities } from 'librechat-data-provider';
import { Button, TooltipAnchor, useToastContext } from '@librechat/client';
import { Button, TooltipAnchor, labelVariants, useToastContext } from '@librechat/client';
import type { AgentForm } from '~/common';
import { sectionLabelClass, groupHeadingClass } from './ui';
import { useAgentPanelContext } from '~/Providers';
import StatefulSessions from './StatefulSessions';
import OrchestrationHub from './OrchestrationHub';
import MaxAgentSteps from './MaxAgentSteps';
import { groupHeadingClass } from './ui';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
@ -66,7 +66,9 @@ export default function AdvancedPanel() {
{currentAgentId && (
<div className="flex items-center justify-between gap-2 border-t border-border-light pt-3">
<span className={sectionLabelClass}>{localize('com_ui_agent_id')}</span>
<span className={labelVariants({ variant: 'section' })}>
{localize('com_ui_agent_id')}
</span>
<TooltipAnchor
description={currentAgentId}
render={

View file

@ -11,9 +11,6 @@ import type { ReactNode } from 'react';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
export const sectionLabelClass =
'text-[11px] font-medium uppercase tracking-wide text-text-secondary';
/** Prominent heading for a top-level settings group (Essentials, Orchestration). */
export const groupHeadingClass = 'text-sm font-semibold text-text-primary';

View file

@ -1,14 +1,14 @@
import React, { useState, useMemo } from 'react';
import { format } from 'date-fns';
import { Button, TooltipAnchor } from '@librechat/client';
import { Eye, Code, User, Pencil, Calendar, EarthIcon } from 'lucide-react';
import { User, Pencil, Calendar, EarthIcon } from 'lucide-react';
import type { TSkill } from 'librechat-data-provider';
import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { ShareSkill, SkillToggle } from '../buttons';
import DeleteSkill from '../dialogs/DeleteSkill';
import { parseFrontmatter } from '../utils';
import { cn } from '~/utils';
import ViewToggle from './ViewToggle';
interface SkillDetailProps {
skill: TSkill;
@ -18,52 +18,6 @@ interface SkillDetailProps {
const SKIP_KEYS = new Set(['name', 'description']);
function ViewToggle({
viewMode,
setViewMode,
localize,
}: {
viewMode: 'rendered' | 'source';
setViewMode: (mode: 'rendered' | 'source') => void;
localize: ReturnType<typeof useLocalize>;
}) {
return (
<div
role="group"
className="inline-flex h-7 rounded-lg bg-surface-tertiary p-0.5 text-sm font-medium"
>
<button
type="button"
onClick={() => setViewMode('rendered')}
className={cn(
'flex items-center justify-center rounded-md px-1.5 transition-colors',
viewMode === 'rendered'
? 'bg-surface-primary text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary',
)}
aria-label={localize('com_ui_skill_view_rendered')}
aria-pressed={viewMode === 'rendered'}
>
<Eye className="size-4" />
</button>
<button
type="button"
onClick={() => setViewMode('source')}
className={cn(
'flex items-center justify-center rounded-md px-1.5 transition-colors',
viewMode === 'source'
? 'bg-surface-primary text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary',
)}
aria-label={localize('com_ui_skill_view_source')}
aria-pressed={viewMode === 'source'}
>
<Code className="size-4" />
</button>
</div>
);
}
export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProps) {
const localize = useLocalize();
const { user } = useAuthContext();
@ -159,7 +113,7 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
{/* Divider with view toggle */}
<div className="flex items-center gap-3 py-1">
<hr className="flex-1 border-border-medium" />
<ViewToggle viewMode={viewMode} setViewMode={setViewMode} localize={localize} />
<ViewToggle viewMode={viewMode} setViewMode={setViewMode} />
</div>
{/* Frontmatter metadata */}

View file

@ -2,12 +2,12 @@ import React, { memo, useMemo, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiBaseUrl } from 'librechat-data-provider';
import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import { ArrowLeft, Eye, Code, Copy, Check, FileText, FileQuestion } from 'lucide-react';
import { ArrowLeft, Copy, Check, FileText, FileQuestion } from 'lucide-react';
import { useGetSkillFileContentQuery } from '~/data-provider';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { parseFrontmatter } from '../utils';
import ViewToggle from './ViewToggle';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface SkillFileViewerProps {
skillId: string;
@ -97,39 +97,7 @@ function SkillFileViewer({ skillId, relativePath }: SkillFileViewerProps) {
)}
{/* View toggle (markdown only) */}
{isMarkdown && isText && (
<div
role="group"
className="inline-flex h-7 rounded-lg bg-surface-tertiary p-0.5 text-sm font-medium"
>
<button
type="button"
onClick={() => setViewMode('rendered')}
className={cn(
'flex items-center justify-center rounded-md px-1.5 transition-colors',
viewMode === 'rendered'
? 'bg-surface-primary text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary',
)}
aria-pressed={viewMode === 'rendered'}
>
<Eye className="size-4" />
</button>
<button
type="button"
onClick={() => setViewMode('source')}
className={cn(
'flex items-center justify-center rounded-md px-1.5 transition-colors',
viewMode === 'source'
? 'bg-surface-primary text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary',
)}
aria-pressed={viewMode === 'source'}
>
<Code className="size-4" />
</button>
</div>
)}
{isMarkdown && isText && <ViewToggle viewMode={viewMode} setViewMode={setViewMode} />}
</div>
</div>

View file

@ -0,0 +1,63 @@
import { Eye, Code } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { TranslationKeys } from '~/hooks';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export type SkillViewMode = 'rendered' | 'source';
interface ViewToggleProps {
viewMode: SkillViewMode;
setViewMode: (mode: SkillViewMode) => void;
}
const MODES: ReadonlyArray<{ mode: SkillViewMode; Icon: LucideIcon; labelKey: TranslationKeys }> = [
{ mode: 'rendered', Icon: Eye, labelKey: 'com_ui_skill_view_rendered' },
{ mode: 'source', Icon: Code, labelKey: 'com_ui_skill_view_source' },
];
/**
* Segmented control for the rendered/source swap.
*
* The active state is one thumb that slides between the options rather than a
* background appearing on one button as it disappears from the other, so the
* change reads as a single movement. Option widths are fixed so the thumb can
* travel by exactly one option without measuring.
*/
export default function ViewToggle({ viewMode, setViewMode }: ViewToggleProps) {
const localize = useLocalize();
return (
<div
role="group"
aria-label={`${localize('com_ui_skill_view_rendered')} / ${localize('com_ui_skill_view_source')}`}
className="relative inline-flex h-7 rounded-lg bg-surface-tertiary p-0.5 text-sm font-medium"
>
<span
aria-hidden="true"
className={cn(
/** Logical inset plus a mirrored translation: under RTL flex puts the
* first option on the right, so a physically-left thumb would sit
* under the wrong option in both states. */
'absolute start-0.5 top-0.5 h-6 w-7 rounded-md bg-surface-primary shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none',
viewMode === 'source' && 'translate-x-7 rtl:-translate-x-7',
)}
/>
{MODES.map(({ mode, Icon, labelKey }) => (
<button
key={mode}
type="button"
onClick={() => setViewMode(mode)}
className={cn(
'relative flex w-7 items-center justify-center rounded-md transition-colors',
viewMode === mode ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
)}
aria-label={localize(labelKey)}
aria-pressed={viewMode === mode}
>
<Icon className="size-4" aria-hidden="true" />
</button>
))}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { ChevronRight } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import type { TSkillSummary } from 'librechat-data-provider';
import SkillListItem from './SkillListItem';
import { Collapse } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -47,7 +48,7 @@ export default function SkillList({
</div>
{/* Skill items */}
{sectionOpen && (
<Collapse open={sectionOpen}>
<div className="flex flex-col gap-px">
{skills.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-text-secondary">
@ -66,7 +67,7 @@ export default function SkillList({
))
)}
</div>
)}
</Collapse>
</div>
);
}

View file

@ -5,6 +5,7 @@ import { ScrollText, ChevronDown, ChevronRight, Folder, Pin } from 'lucide-react
import type { FixedSizeNodeData, TreeWalkerValue, TreeWalker } from 'react-vtree';
import type { TSkillSummary, TSkillFile } from 'librechat-data-provider';
import { useListSkillFilesQuery } from '~/data-provider';
import { Collapse } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -323,7 +324,7 @@ function SkillListItem({
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className={cn('truncate', isActive && 'font-semibold')}>{skill.name}</span>
<span className="truncate">{skill.name}</span>
{skill.alwaysApply === true && (
<Pin
className="size-3 shrink-0 text-cyan-500"
@ -351,16 +352,9 @@ function SkillListItem({
</div>
{/* Inline file tree */}
<div
className={cn(
'ml-5 overflow-hidden transition-all duration-200 ease-in-out',
expanded && hasFiles ? 'opacity-100' : 'max-h-0 opacity-0',
)}
style={expanded && hasFiles ? { maxHeight: `${MAX_HEIGHT}px` } : undefined}
inert={!expanded ? '' : undefined}
>
<Collapse open={expanded && hasFiles} className="ml-5">
<InlineFileTree files={files} activeFile={activeFile} onFileClick={handleFileClick} />
</div>
</Collapse>
</div>
);
}

View file

@ -41,7 +41,12 @@ jest.mock('~/components/ui', () => {
const PanelContent = ReactModule.forwardRef<HTMLDivElement, { children?: React.ReactNode }>(
({ children }, ref) => <div ref={ref}>{children}</div>,
);
return { PanelContent };
/** SkillList renders its body through Collapse, which keeps children mounted
* and marks them hidden when closed rather than unmounting them. */
const Collapse = ({ open, children }: { open: boolean; children?: React.ReactNode }) => (
<div aria-hidden={!open || undefined}>{children}</div>
);
return { PanelContent, Collapse };
});
jest.mock('../FilterSkills', () => ({

View file

@ -21,8 +21,10 @@ describe('Button', () => {
it('renders the header-action toggle from semantic tokens', () => {
render(<Button variant="header-action">Toggle</Button>);
/** Transparent so the toggle reads as an icon on the header rather than a
* raised control; the border and hover still mark it as hit-able. */
expect(screen.getByRole('button', { name: 'Toggle' })).toHaveClass(
'bg-presentation',
'bg-transparent',
'border-border-light',
'rounded-xl',
'duration-0',

View file

@ -70,7 +70,7 @@ const buttonVariantRecipe = cva(
* lag rather than polish.
*/
'header-action':
'rounded-xl border border-border-light bg-presentation text-text-primary duration-0 hover:bg-surface-active-alt hover:text-text-primary',
'rounded-xl border border-border-light bg-transparent text-text-primary duration-0 hover:bg-surface-active-alt hover:text-text-primary',
},
size: {
default: 'h-10 px-4 py-2',

View file

@ -0,0 +1,49 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { Label, labelVariants } from './Label';
describe('Label', () => {
it('keeps the default appearance when no variant is selected', () => {
render(<Label htmlFor="field">Name</Label>);
const label = screen.getByText('Name');
expect(label).toHaveClass('block', 'w-full', 'break-all', 'leading-none', 'text-sm');
expect(label).toHaveClass('text-text-primary', 'peer-disabled:opacity-70');
});
it('renders the section eyebrow from the shared variant', () => {
render(
<Label htmlFor="field" variant="section">
Endpoint
</Label>,
);
const label = screen.getByText('Endpoint');
expect(label).toHaveClass(
'text-[11px]',
'font-medium',
'uppercase',
'tracking-wide',
'text-text-secondary',
);
/** The variant owns size, leading and color outright: an arbitrary font size
* also clears `leading-none`, which is what the label read before. */
expect(label).not.toHaveClass('text-sm', 'text-text-primary', 'leading-none');
});
/**
* A settings row heads its value with this appearance on a non-label element,
* so the recipe has to stay free of the label's block layout: `block w-full`
* would break the row's `justify-between`.
*/
it('exposes the eyebrow to non-label elements without layout', () => {
const section = labelVariants({ variant: 'section' });
expect(section).toContain('text-[11px]');
expect(section).toContain('text-text-secondary');
expect(section).not.toContain('block');
expect(section).not.toContain('w-full');
/** Unmerged recipe output, so a conflicting base color would survive it. */
expect(section).not.toContain('text-text-primary');
});
});

View file

@ -1,23 +1,51 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { ClassProp } from 'class-variance-authority/types';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/utils';
type LabelVariantOptions =
| ({ variant?: 'default' | 'section' | null | undefined } & ClassProp)
| undefined;
/**
* Typography only, so a non-label element that heads a settings row can reuse a
* variant without inheriting the label's block layout. Each variant carries its
* own size, leading and color rather than overriding a shared base: the raw
* recipe output is not merged for those consumers, and a font size declared
* after `leading-none` would drop it.
*/
const labelVariants: (props?: LabelVariantOptions) => string = cva('', {
variants: {
variant: {
default: 'text-sm leading-none text-text-primary',
/** Eyebrow above a field or settings group. */
section: 'text-[11px] font-medium uppercase tracking-wide text-text-secondary',
},
},
defaultVariants: {
variant: 'default',
},
});
const Label: React.ForwardRefExoticComponent<
Omit<LabelPrimitive.LabelProps & React.RefAttributes<HTMLLabelElement>, 'ref'> & {
className?: string;
} & React.RefAttributes<HTMLLabelElement>
} & VariantProps<typeof labelVariants> &
React.RefAttributes<HTMLLabelElement>
> = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & {
className?: string;
}
>(({ className = '', ...props }, ref) => (
} & VariantProps<typeof labelVariants>
>(({ className = '', variant, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
{...props}
{...{
className: cn(
'block w-full break-all text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-text-primary',
'block w-full break-all peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
labelVariants({ variant }),
className,
),
}}
@ -25,4 +53,4 @@ const Label: React.ForwardRefExoticComponent<
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
export { Label, labelVariants };