diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index 7fda84602a..9634569cf5 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -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: | diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 454b872e0f..16a73ce600 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -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({
- + {!isNewChat && }
diff --git a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx index 06db64662c..1190a113de 100644 --- a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx +++ b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx @@ -42,9 +42,12 @@ export const CustomMenu = React.forwardRef(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 = ( @@ -172,7 +175,11 @@ export const CustomMenuItem = React.forwardRef !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 ( - - + + {localize('com_ui_edit_preset_title', { title: preset?.title })} -
- {/* Header section with preset name and endpoint */} -
-
- - -
-
- - -
+ {/* 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. */} +
+
+ +
+
+ + +
+
+ {/* Only this region scrolls, so the title, the fields above and the actions stay put */} +
{/* PopoverButtons section */}
- {/* Settings section */} -
+ {/* 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. */} +
+
- {/* Action buttons */} -
- - -
+ {/* Action buttons */} +
+ +
diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index 98f14f90e2..b7f426e1a0 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -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; 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(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(null); + + const handleImportChange = (event: ChangeEvent) => { + 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:
-
- {hasPresets && ( - - - - - - - {localize('com_ui_clear_presets')} - - {localize('com_endpoint_presets_clear_warning')} - - - - {localize('com_ui_cancel')} - - {localize('com_ui_clear')} - - - - - )} - -
+ +
+ + + + + { + 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" + > + + {localize('com_ui_clear_presets')} + + {localize('com_endpoint_presets_clear_warning')} + + + + {localize('com_ui_cancel')} + + {localize('com_ui_clear')} + + + + {presets && presets.length === 0 && (
@@ -152,11 +234,11 @@ const PresetItems: FC<{
-
+
@@ -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]" > {
- {isExpanded && ( +
    {conversations.map((convo) => ( @@ -65,7 +66,7 @@ const PinnedSection = ({ conversations, toggleNav }: PinnedSectionProps) => { ))}
- )} +
); }; diff --git a/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx b/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx index b7460df8b3..304bd18377 100644 --- a/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx +++ b/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx @@ -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', () => { diff --git a/client/src/components/Endpoints/SaveAsPresetDialog.tsx b/client/src/components/Endpoints/SaveAsPresetDialog.tsx index 61d246a4fa..fa9acd6503 100644 --- a/client/src/components/Endpoints/SaveAsPresetDialog.tsx +++ b/client/src/components/Endpoints/SaveAsPresetDialog.tsx @@ -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" />
diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx index 78041c1ce3..85588b76f6 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx @@ -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 && (
- {localize('com_ui_agent_id')} + + {localize('com_ui_agent_id')} + void; - localize: ReturnType; -}) { - return ( -
- - -
- ); -} - 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 */}

- +
{/* Frontmatter metadata */} diff --git a/client/src/components/Skills/display/SkillFileViewer.tsx b/client/src/components/Skills/display/SkillFileViewer.tsx index fd195eb920..281c14fb25 100644 --- a/client/src/components/Skills/display/SkillFileViewer.tsx +++ b/client/src/components/Skills/display/SkillFileViewer.tsx @@ -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 && ( -
- - -
- )} + {isMarkdown && isText && }
diff --git a/client/src/components/Skills/display/ViewToggle.tsx b/client/src/components/Skills/display/ViewToggle.tsx new file mode 100644 index 0000000000..f635400609 --- /dev/null +++ b/client/src/components/Skills/display/ViewToggle.tsx @@ -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 ( +
+
+ ); +} diff --git a/client/src/components/Skills/lists/SkillList.tsx b/client/src/components/Skills/lists/SkillList.tsx index 745589e4bc..a7523b766c 100644 --- a/client/src/components/Skills/lists/SkillList.tsx +++ b/client/src/components/Skills/lists/SkillList.tsx @@ -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({
{/* Skill items */} - {sectionOpen && ( +
{skills.length === 0 ? (

@@ -66,7 +67,7 @@ export default function SkillList({ )) )}

- )} +
); } diff --git a/client/src/components/Skills/lists/SkillListItem.tsx b/client/src/components/Skills/lists/SkillListItem.tsx index 22eaa0f205..bf97bfc7a9 100644 --- a/client/src/components/Skills/lists/SkillListItem.tsx +++ b/client/src/components/Skills/lists/SkillListItem.tsx @@ -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({ - {skill.name} + {skill.name} {skill.alwaysApply === true && ( {/* Inline file tree */} -
+ -
+
); } diff --git a/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx b/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx index d9bb2c390b..7d8f4788f6 100644 --- a/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx +++ b/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx @@ -41,7 +41,12 @@ jest.mock('~/components/ui', () => { const PanelContent = ReactModule.forwardRef( ({ children }, ref) =>
{children}
, ); - 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 }) => ( +
{children}
+ ); + return { PanelContent, Collapse }; }); jest.mock('../FilterSkills', () => ({ diff --git a/packages/client/src/components/Button.spec.tsx b/packages/client/src/components/Button.spec.tsx index 74cd664fa8..67cfe5b0c4 100644 --- a/packages/client/src/components/Button.spec.tsx +++ b/packages/client/src/components/Button.spec.tsx @@ -21,8 +21,10 @@ describe('Button', () => { it('renders the header-action toggle from semantic tokens', () => { render(); + /** 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', diff --git a/packages/client/src/components/Button.tsx b/packages/client/src/components/Button.tsx index abde7d0689..49a8e082e9 100644 --- a/packages/client/src/components/Button.tsx +++ b/packages/client/src/components/Button.tsx @@ -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', diff --git a/packages/client/src/components/Label.spec.tsx b/packages/client/src/components/Label.spec.tsx new file mode 100644 index 0000000000..d70a971e44 --- /dev/null +++ b/packages/client/src/components/Label.spec.tsx @@ -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(); + 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( + , + ); + 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'); + }); +}); diff --git a/packages/client/src/components/Label.tsx b/packages/client/src/components/Label.tsx index 26c350b109..04bb94e52b 100644 --- a/packages/client/src/components/Label.tsx +++ b/packages/client/src/components/Label.tsx @@ -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, 'ref'> & { className?: string; - } & React.RefAttributes + } & VariantProps & + React.RefAttributes > = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { className?: string; - } ->(({ className = '', ...props }, ref) => ( + } & VariantProps +>(({ className = '', variant, ...props }, ref) => (