mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
✨ 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:
parent
f431b01d1f
commit
16e4d14191
22 changed files with 544 additions and 227 deletions
24
.github/workflows/cache-integration-tests.yml
vendored
24
.github/workflows/cache-integration-tests.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
63
client/src/components/Skills/display/ViewToggle.tsx
Normal file
63
client/src/components/Skills/display/ViewToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
49
packages/client/src/components/Label.spec.tsx
Normal file
49
packages/client/src/components/Label.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue