diff --git a/client/jest.config.cjs b/client/jest.config.cjs index c306c0783e..c12bcc1cfd 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -33,6 +33,10 @@ module.exports = { '/../node_modules/librechat-data-provider/src/react-query', }, maxWorkers: '50%', + /** Coverage maps accumulate for the life of a worker, so a long run can push + * a worker past a gigabyte and get it killed by the OS, which fails whatever + * suite it was holding. Recycling bloated workers also avoids swap thrash. */ + workerIdleMemoryLimit: '800MB', restoreMocks: true, testResultsProcessor: 'jest-junit', coverageReporters: ['text', 'cobertura', 'lcov'], diff --git a/client/src/components/Agents/tests/AgentCard.spec.tsx b/client/src/components/Agents/tests/AgentCard.spec.tsx index 99395d80cb..0906920515 100644 --- a/client/src/components/Agents/tests/AgentCard.spec.tsx +++ b/client/src/components/Agents/tests/AgentCard.spec.tsx @@ -91,52 +91,45 @@ jest.mock('~/Providers', () => ({ })); // Mock @librechat/client with proper Dialog behavior -jest.mock( - '@librechat/client', - () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require('react'); - return { - useToastContext: jest.fn(() => ({ - showToast: jest.fn(), - })), - OGDialog: ({ children, open, onOpenChange }: any) => { - // Store onOpenChange in context for trigger to call - return ( -
- {React.Children.map(children, (child: any) => { - if ( - child?.type?.displayName === 'OGDialogTrigger' || - child?.props?.['data-trigger'] - ) { - return React.cloneElement(child, { onOpenChange }); - } - // Only render content when open - if (child?.type?.displayName === 'OGDialogContent' && !open) { - return null; - } - return child; - })} -
- ); - }, - OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => { - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement, { - onClick: (e: any) => { - (children as any).props?.onClick?.(e); - onOpenChange?.(true); - }, - }); - } - return
onOpenChange?.(true)}>{children}
; - }, - OGDialogContent: ({ children }: any) =>
{children}
, - Label: ({ children, className }: any) => {children}, - }; - }, - { virtual: true }, -); +jest.mock('@librechat/client', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require('react'); + return { + useToastContext: jest.fn(() => ({ + showToast: jest.fn(), + })), + OGDialog: ({ children, open, onOpenChange }: any) => { + // Store onOpenChange in context for trigger to call + return ( +
+ {React.Children.map(children, (child: any) => { + if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) { + return React.cloneElement(child, { onOpenChange }); + } + // Only render content when open + if (child?.type?.displayName === 'OGDialogContent' && !open) { + return null; + } + return child; + })} +
+ ); + }, + OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => { + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children as React.ReactElement, { + onClick: (e: any) => { + (children as any).props?.onClick?.(e); + onOpenChange?.(true); + }, + }); + } + return
onOpenChange?.(true)}>{children}
; + }, + OGDialogContent: ({ children }: any) =>
{children}
, + Label: ({ children, className }: any) => {children}, + }; +}); // Create wrapper with QueryClient const createWrapper = () => { diff --git a/client/src/components/Agents/tests/AgentDetailContent.spec.tsx b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx index 130e039d4c..75d4d490b3 100644 --- a/client/src/components/Agents/tests/AgentDetailContent.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx @@ -23,22 +23,18 @@ jest.mock('librechat-data-provider', () => ({ }, })); -jest.mock( - '@librechat/client', - () => ({ - OGDialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( - - ), - TooltipAnchor: ({ render }: { render: React.ReactNode }) => render, - useToastContext: () => ({ - showToast: jest.fn(), - }), +jest.mock('@librechat/client', () => ({ + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + TooltipAnchor: ({ render }: { render: React.ReactNode }) => render, + useToastContext: () => ({ + showToast: jest.fn(), }), - { virtual: true }, -); +})); jest.mock('~/hooks', () => ({ useDefaultConvo: () => jest.fn((value) => value.conversation), diff --git a/client/src/components/Chat/Input/Artifacts.tsx b/client/src/components/Chat/Input/Artifacts.tsx index ddc36eb7ee..910d3882bc 100644 --- a/client/src/components/Chat/Input/Artifacts.tsx +++ b/client/src/components/Chat/Input/Artifacts.tsx @@ -5,6 +5,7 @@ import { WandSparkles, ChevronDown } from 'lucide-react'; import { ArtifactModes, defaultAgentCapabilities } from 'librechat-data-provider'; import { useLocalize, useAgentCapabilities } from '~/hooks'; import { useBadgeRowContext } from '~/Providers'; +import { badgeAccents } from './accents'; import { cn } from '~/utils'; interface ArtifactsToggleState { @@ -88,11 +89,11 @@ function Artifacts() { return (
); } diff --git a/client/src/components/Prompts/sidebar/PanelNavigation.tsx b/client/src/components/Prompts/sidebar/PanelNavigation.tsx deleted file mode 100644 index 93353e37d8..0000000000 --- a/client/src/components/Prompts/sidebar/PanelNavigation.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { memo } from 'react'; -import { Button } from '@librechat/client'; -import { useLocalize } from '~/hooks'; - -function PanelNavigation({ - onPrevious, - onNext, - hasNextPage, - hasPreviousPage, - isLoading, - children, -}: { - onPrevious: () => void; - onNext: () => void; - hasNextPage: boolean; - hasPreviousPage: boolean; - isLoading?: boolean; - isChatRoute: boolean; - children?: React.ReactNode; -}) { - const localize = useLocalize(); - - return ( -
-
{children}
- -
- ); -} - -export default memo(PanelNavigation); diff --git a/client/src/components/Prompts/sidebar/PromptsAccordion.tsx b/client/src/components/Prompts/sidebar/PromptsAccordion.tsx index de3cedc2a1..4cc51cf106 100644 --- a/client/src/components/Prompts/sidebar/PromptsAccordion.tsx +++ b/client/src/components/Prompts/sidebar/PromptsAccordion.tsx @@ -1,16 +1,25 @@ import { SystemRoles } from 'librechat-data-provider'; -import { useAuthContext } from '~/hooks'; -import { AdminSettings } from '~/components/Prompts'; import AutoSendPrompt from '../buttons/AutoSendPrompt'; +import { AdminSettings } from '~/components/Prompts'; import PromptSidePanel from './GroupSidePanel'; +import { PanelFooter } from '~/components/ui'; import FilterPrompts from './FilterPrompts'; +import { useAuthContext } from '~/hooks'; export default function PromptsAccordion() { const { user } = useAuthContext(); return ( - + + + + ) : null + } + > - {user?.role === SystemRoles.ADMIN && } ); diff --git a/client/src/components/Prompts/sidebar/index.ts b/client/src/components/Prompts/sidebar/index.ts index 3ef99e291b..97fba86232 100644 --- a/client/src/components/Prompts/sidebar/index.ts +++ b/client/src/components/Prompts/sidebar/index.ts @@ -1,4 +1,3 @@ export { default as FilterPrompts } from './FilterPrompts'; export { default as GroupSidePanel } from './GroupSidePanel'; -export { default as PanelNavigation } from './PanelNavigation'; export { default as PromptsAccordion } from './PromptsAccordion'; diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkCardSkeleton.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkCardSkeleton.tsx new file mode 100644 index 0000000000..6b255b7c39 --- /dev/null +++ b/client/src/components/SidePanel/Bookmarks/BookmarkCardSkeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from '@librechat/client'; + +/** Mirrors BookmarkCard: small icon, title, then the conversation count pill */ +export default function BookmarkCardSkeleton({ count = 6 }: { count?: number }) { + return ( + + ); +} diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx index 3e3947bdf6..83434f1f79 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx @@ -1,14 +1,14 @@ -import { useConversationTagsQuery } from '~/data-provider'; import { BookmarkContext } from '~/Providers/BookmarkContext'; +import { useConversationTagsQuery } from '~/data-provider'; import BookmarkTable from './BookmarkTable'; const BookmarkPanel = () => { - const { data } = useConversationTagsQuery(); + const { data, isLoading } = useConversationTagsQuery(); return ( -
+
- +
); diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx index 1320e859a2..d25b8f4a4e 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx @@ -4,11 +4,11 @@ import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/ import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider'; import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkEditDialog } from '~/components/Bookmarks'; +import BookmarkCardSkeleton from './BookmarkCardSkeleton'; +import { PanelContent } from '~/components/ui'; import BookmarkList from './BookmarkList'; import { useLocalize } from '~/hooks'; -const pageSize = 10; - const removeDuplicates = (bookmarks: TConversationTag[]) => { const seen = new Set(); return bookmarks.filter((bookmark) => { @@ -18,10 +18,9 @@ const removeDuplicates = (bookmarks: TConversationTag[]) => { }); }; -const BookmarkTable = () => { +const BookmarkTable = ({ isLoading = false }: { isLoading?: boolean }) => { const localize = useLocalize(); const [rows, setRows] = useState([]); - const [pageIndex, setPageIndex] = useState(0); const [searchQuery, setSearchQuery] = useState(''); const [createOpen, setCreateOpen] = useState(false); @@ -32,11 +31,6 @@ const BookmarkTable = () => { setRows(_bookmarks); }, [bookmarks]); - // Reset page when search changes - useEffect(() => { - setPageIndex(0); - }, [searchQuery]); - const moveRow = useCallback((dragIndex: number, hoverIndex: number) => { setRows((prevTags: TConversationTag[]) => { const updatedRows = [...prevTags]; @@ -50,14 +44,15 @@ const BookmarkTable = () => { (row) => row.tag && row.tag.toLowerCase().includes(searchQuery.toLowerCase()), ); - const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); - const totalPages = Math.ceil(filteredRows.length / pageSize); - return ( -
- {/* Header: Filter + Create Button */} -
+
+ {/* Sticky header: filter + create */} +
{
- {/* Bookmark List */} - 0} - /> - - {/* Pagination */} - {filteredRows.length > pageSize && ( -
- -
- {pageIndex + 1} / {totalPages} -
- -
- )} + {/* Only the list scrolls */} + } + className="px-3 pb-3" + > + 0} + /> +
); diff --git a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx index 80cf8f4b9e..ae7b0532a1 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPBuilderPanel.tsx @@ -1,15 +1,18 @@ import { useState, useRef, useMemo } from 'react'; import { Plus } from 'lucide-react'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { Button, Spinner, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client'; -import { useLocalize, useMCPServerManager, useHasAccess } from '~/hooks'; +import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; +import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client'; +import { useLocalize, useMCPServerManager, useHasAccess, useAuthContext } from '~/hooks'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; +import { PanelFooter, PanelContent } from '~/components/ui'; +import MCPServerCardSkeleton from './MCPServerCardSkeleton'; import MCPAdminSettings from './MCPAdminSettings'; import MCPServerDialog from './MCPServerDialog'; import MCPServerList from './MCPServerList'; export default function MCPBuilderPanel() { const localize = useLocalize(); + const { user } = useAuthContext(); const { availableMCPServers, isLoading, getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); @@ -36,9 +39,13 @@ export default function MCPBuilderPanel() { }, [availableMCPServers, searchQuery]); return ( -
-
- {/* Toolbar: Search + Add Button */} +
+ {/* Sticky header: Search + Add Button */} +
)}
- - {/* Server Cards List */} - {isLoading ? ( -
- -
- ) : ( - 0} - /> - )} - - {/* Config Dialog for custom user vars */} - {configDialogProps && } - - {/* Admin Settings Section */} -
+ + {/* Only the list scrolls */} + } + className="px-3 pb-3" + > + 0} + /> + + + {/* Config Dialog for custom user vars */} + {configDialogProps && } + + {user?.role === SystemRoles.ADMIN && ( + + + + )}
); } diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerCardSkeleton.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerCardSkeleton.tsx new file mode 100644 index 0000000000..a208a97b6e --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerCardSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@librechat/client'; + +/** Mirrors MCPServerCard: square icon, then name over description */ +export default function MCPServerCardSkeleton({ count = 5 }: { count?: number }) { + return ( + + ); +} diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx index ac078cae6f..25f9d5856d 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx @@ -1,13 +1,13 @@ import { FormProvider, useWatch } from 'react-hook-form'; import { Permissions, PermissionTypes } from 'librechat-data-provider'; -import { useHasAccess } from '~/hooks'; import type { useMCPServerForm, MCPServerFormData } from './hooks/useMCPServerForm'; -import { AuthTypeEnum } from './hooks/useMCPServerForm'; import ConnectionSection from './sections/ConnectionSection'; import BasicInfoSection from './sections/BasicInfoSection'; import TransportSection from './sections/TransportSection'; +import { AuthTypeEnum } from './hooks/useMCPServerForm'; import TrustSection from './sections/TrustSection'; import AuthSection from './sections/AuthSection'; +import { useHasAccess } from '~/hooks'; interface MCPServerFormProps { formHook: ReturnType; @@ -37,9 +37,11 @@ export default function MCPServerForm({ formHook }: MCPServerFormProps) {
+ {/* `display: contents` would drop the margin `space-y-4` puts on this fieldset, + collapsing the gap above the first section to zero */}
diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/__tests__/TrustSection.spec.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/__tests__/TrustSection.spec.tsx index f573d72465..05e5b947d6 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/__tests__/TrustSection.spec.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/__tests__/TrustSection.spec.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import { FormProvider, useForm } from 'react-hook-form'; import type { ChangeEvent, ReactNode } from 'react'; -import TrustSection from '../TrustSection'; import type { MCPServerFormData } from '../../hooks/useMCPServerForm'; +import TrustSection from '../TrustSection'; type LocalizedValue = string | Record; @@ -43,31 +43,27 @@ jest.mock('~/hooks', () => ({ }, })); -jest.mock( - '@librechat/client', - () => { - const React = jest.requireActual('react'); - return { - Checkbox: ({ +jest.mock('@librechat/client', () => { + const React = jest.requireActual('react'); + return { + Checkbox: ({ + checked, + onCheckedChange, + ...props + }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + }) => + React.createElement('input', { + type: 'checkbox', checked, - onCheckedChange, - ...props - }: { - checked: boolean; - onCheckedChange: (checked: boolean) => void; - }) => - React.createElement('input', { - type: 'checkbox', - checked, - onChange: (event: ChangeEvent) => onCheckedChange(event.target.checked), - ...props, - }), - Label: ({ children, ...props }: { children: ReactNode }) => - React.createElement('label', props, children), - }; - }, - { virtual: true }, -); + onChange: (event: ChangeEvent) => onCheckedChange(event.target.checked), + ...props, + }), + Label: ({ children, ...props }: { children: ReactNode }) => + React.createElement('label', props, children), + }; +}); function createDefaultValues(): MCPServerFormData { return { diff --git a/client/src/components/SidePanel/Memories/MemoryCardSkeleton.tsx b/client/src/components/SidePanel/Memories/MemoryCardSkeleton.tsx new file mode 100644 index 0000000000..d63e4c4d02 --- /dev/null +++ b/client/src/components/SidePanel/Memories/MemoryCardSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@librechat/client'; + +/** Mirrors MemoryCard: key + token pill on the first row, value + date on the second */ +export default function MemoryCardSkeleton({ count = 6 }: { count?: number }) { + return ( + + ); +} diff --git a/client/src/components/SidePanel/Memories/MemoryPanel.tsx b/client/src/components/SidePanel/Memories/MemoryPanel.tsx index 89f3e39658..46443a9d00 100644 --- a/client/src/components/SidePanel/Memories/MemoryPanel.tsx +++ b/client/src/components/SidePanel/Memories/MemoryPanel.tsx @@ -5,7 +5,6 @@ import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provid import { Button, Checkbox, - Spinner, Dropdown, FilterInput, TooltipAnchor, @@ -19,12 +18,13 @@ import { useGetUserQuery, } from '~/data-provider'; import { useLocalize, useAuthContext, useHasAccess } from '~/hooks'; +import { PanelFooter, PanelContent } from '~/components/ui'; +import MemoryCardSkeleton from './MemoryCardSkeleton'; import MemoryCreateDialog from './MemoryCreateDialog'; import MemoryUsageBadge from './MemoryUsageBadge'; import AdminSettings from './AdminSettings'; import MemoryList from './MemoryList'; - -const pageSize = 10; +import { cn } from '~/utils'; /** Partition filter sentinels; any other value is an agent id */ const PARTITION_ALL = 'all'; @@ -36,7 +36,6 @@ export default function MemoryPanel() { const { data: userData } = useGetUserQuery(); const { data: memData, isLoading } = useMemoriesQuery(); const { showToast } = useToastContext(); - const [pageIndex, setPageIndex] = useState(0); const [searchQuery, setSearchQuery] = useState(''); const [partitionFilter, setPartitionFilter] = useState(PARTITION_ALL); const [createDialogOpen, setCreateDialogOpen] = useState(false); @@ -134,23 +133,6 @@ export default function MemoryPanel() { }); }, [memories, searchQuery, activePartition]); - const currentRows = useMemo(() => { - return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); - }, [filteredMemories, pageIndex]); - - // Reset page when search or partition changes - useEffect(() => { - setPageIndex(0); - }, [searchQuery, activePartition]); - - if (isLoading) { - return ( -
- -
- ); - } - if (!hasReadAccess) { return (
@@ -161,11 +143,17 @@ export default function MemoryPanel() { ); } - const totalPages = Math.ceil(filteredMemories.length / pageSize); + const tokenLimit = memData?.tokenLimit ?? null; + const showUsageBadge = tokenLimit != null; return ( -
-
+
+ {/* Sticky header: filter, partition, usage + toggle */} +
{/* Header: Filter + Create Button */}
{/* Usage Badge */} - {memData?.tokenLimit != null && ( + {showUsageBadge && ( )} @@ -227,7 +215,10 @@ export default function MemoryPanel() { )}
)} +
- {/* Memory List */} + {/* Only the list scrolls */} + } className="px-3 pb-3"> 0} /> + - {/* Footer: Admin Settings + Pagination */} - {(user?.role === SystemRoles.ADMIN || filteredMemories.length > pageSize) && ( -
- {/* Admin Settings - Left */} - {user?.role === SystemRoles.ADMIN ? :
} - - {/* Pagination - Right */} - {filteredMemories.length > pageSize && ( -
- -
- {pageIndex + 1} / {totalPages} -
- -
- )} -
- )} -
+ {user?.role === SystemRoles.ADMIN && ( + + + + )}
); } diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index 3f845fe71b..14fef38a79 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -12,20 +12,23 @@ import { applyModelAwareDefaults, } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider'; +import { useChatContext, useLiveAnnouncer } from '~/Providers'; import { SaveAsPresetDialog } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; import { componentMapping } from './components'; -import { useChatContext } from '~/Providers'; -import { logger } from '~/utils'; +import { logger, cn } from '~/utils'; export default function Parameters() { const localize = useLocalize(); const { conversation, setConversation } = useChatContext(); + const { announcePolite } = useLiveAnnouncer(); const { setOption } = useSetIndexOptions(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [preset, setPreset] = useState(null); + /** Bumped on every reset; used as a key so the spin animation replays */ + const [resetCount, setResetCount] = useState(0); const { data: endpointsConfig = {} } = useGetEndpointsQuery(); const provider = conversation?.endpoint ?? ''; @@ -138,7 +141,11 @@ export default function Parameters() { logger.log('parameters', 'parameters reset, affected keys:', resetKeys); return updatedConversation; }); - }, [setConversation]); + + announcePolite({ message: localize('com_ui_model_parameters_reset'), isStatus: true }); + + setResetCount((count) => count + 1); + }, [setConversation, announcePolite, localize]); const openDialog = useCallback(() => { const newPreset = tConvoUpdateSchema.parse({ @@ -186,9 +193,16 @@ export default function Parameters() { variant="outline" type="button" onClick={resetParameters} - className="flex w-full items-center justify-center gap-2 px-4 py-2 text-sm" + className="flex w-full items-center justify-center gap-2 px-4 py-2 text-sm active:scale-[0.98] motion-reduce:transform-none" > -
diff --git a/client/src/components/Skills/buttons/CreateSkillMenu.tsx b/client/src/components/Skills/buttons/CreateSkillMenu.tsx index a61dd7fd92..db554f9670 100644 --- a/client/src/components/Skills/buttons/CreateSkillMenu.tsx +++ b/client/src/components/Skills/buttons/CreateSkillMenu.tsx @@ -1,52 +1,58 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; +import * as Ariakit from '@ariakit/react'; import { Plus, PenLine, Upload } from 'lucide-react'; -import { Dropdown } from '@librechat/client'; -import type { Option } from '~/common'; +import { DropdownPopup, TooltipAnchor } from '@librechat/client'; +import type { MenuItemProps } from '@librechat/client'; import { CreateSkillDialog, UploadSkillDialog } from '../dialogs'; import { useLocalize } from '~/hooks'; -const WRITE = 'write'; -const UPLOAD_SKILL = 'upload'; - export default function CreateSkillMenu() { const localize = useLocalize(); + const [isOpen, setIsOpen] = useState(false); const [writeOpen, setWriteOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); - const options = useMemo( + const createLabel = localize('com_ui_create_skill'); + + const items: MenuItemProps[] = useMemo( () => [ { - value: WRITE, label: localize('com_ui_skill_write_instructions'), - icon: , + onClick: () => setWriteOpen(true), + icon:
- {user?.role === SystemRoles.ADMIN && ( -
- -
- )}
); } diff --git a/client/src/components/Skills/sidebar/SkillsAccordion.tsx b/client/src/components/Skills/sidebar/SkillsAccordion.tsx index e2e273dfd3..5f00683210 100644 --- a/client/src/components/Skills/sidebar/SkillsAccordion.tsx +++ b/client/src/components/Skills/sidebar/SkillsAccordion.tsx @@ -1,17 +1,18 @@ import { SystemRoles } from 'librechat-data-provider'; import { AdminSettings } from '~/components/Skills/buttons'; import SkillsSidePanel from './SkillsSidePanel'; +import { PanelFooter } from '~/components/ui'; import { useAuthContext } from '~/hooks'; export default function SkillsAccordion() { const { user } = useAuthContext(); return ( -
- +
+ {user?.role === SystemRoles.ADMIN && ( -
+ -
+ )}
); diff --git a/client/src/components/Skills/sidebar/SkillsSidePanel.tsx b/client/src/components/Skills/sidebar/SkillsSidePanel.tsx index c403b27f8b..cc5d05f8a4 100644 --- a/client/src/components/Skills/sidebar/SkillsSidePanel.tsx +++ b/client/src/components/Skills/sidebar/SkillsSidePanel.tsx @@ -1,42 +1,51 @@ import { useState, useMemo } from 'react'; -import { Search, X } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; +import { Spinner } from '@librechat/client'; import { useParams } from 'react-router-dom'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { useListSkillsQuery } from '~/data-provider'; -import { useDebounce, useHasAccess, useLocalize } from '~/hooks'; -import { CreateSkillMenu } from '../buttons'; +import type { TSkillListResponse } from 'librechat-data-provider'; +import { useLocalize, useDebounce, useNavScrolling } from '~/hooks'; +import SkillListSkeleton from '../lists/SkillListSkeleton'; +import { useSkillsInfiniteQuery } from '~/data-provider'; import SkillListPanel from '../lists/SkillList'; +import { PanelContent } from '~/components/ui'; +import FilterSkills from './FilterSkills'; import { cn } from '~/utils'; +import store from '~/store'; interface SkillsSidePanelProps { className?: string; } /** - * Claude.ai–style skills sidebar panel. - * Header: "Skills" title + search icon + create menu (+ dropdown). - * Body: "My Skills" collapsible section with skill list. + * Skills sidebar panel. + * Header: filter input + create menu, matching the other side panels. */ + export default function SkillsSidePanel({ className }: SkillsSidePanelProps) { const localize = useLocalize(); const { skillId: activeSkillId } = useParams(); - const [searchOpen, setSearchOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [sectionOpen, setSectionOpen] = useState(true); const debouncedSearch = useDebounce(searchTerm, 250); - const hasCreateAccess = useHasAccess({ - permissionType: PermissionTypes.SKILLS, - permission: Permissions.CREATE, + const listQuery = useSkillsInfiniteQuery({ search: debouncedSearch || undefined, limit: 20 }); + + const pages = useMemo(() => listQuery.data?.pages ?? [], [listQuery.data]); + const skills = useMemo(() => pages.flatMap((page) => page.skills), [pages]); + + const lastPage = pages[pages.length - 1]; + const nextCursor = lastPage?.has_more === true ? lastPage.after : null; + + /** A collapsed sidebar keeps this panel mounted, so stop draining pages into it */ + const sidebarExpanded = useRecoilValue(store.sidebarExpanded); + + const { containerRef } = useNavScrolling({ + nextCursor, + isFetchingNext: listQuery.isFetchingNextPage, + fetchNextPage: listQuery.fetchNextPage, + enabled: sidebarExpanded && sectionOpen, }); - const listQuery = useListSkillsQuery({ search: debouncedSearch || undefined, limit: 50 }); - const skills = useMemo(() => listQuery.data?.skills ?? [], [listQuery.data]); - - const handleCloseSearch = () => { - setSearchOpen(false); - setSearchTerm(''); - }; - return (
- {/* Header — title+icons or inline search input */} -
- {searchOpen ? ( - <> -
- - setSearchTerm(e.target.value)} - placeholder={localize('com_ui_search')} - aria-label={localize('com_ui_search_skills')} - className="h-8 w-full rounded-md border border-border-light bg-transparent pl-8 pr-3 text-sm text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary" - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus - /> -
- - - ) : ( - <> -

- {localize('com_ui_skills')} -

-
- - {hasCreateAccess && } -
- - )} -
+ setSearchTerm(e.target.value)} + /> - {/* Skill list */} -
+ {/* Only the list scrolls */} + } + className="px-4" + > -
+ {/* Appending the next page, so the loaded rows stay put */} + {listQuery.isFetchingNextPage && ( +
+ + + {localize('com_ui_loading')} + +
+ )} +
); } diff --git a/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx b/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx new file mode 100644 index 0000000000..d9bb2c390b --- /dev/null +++ b/client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { MemoryRouter } from 'react-router-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import SkillsSidePanel from '../SkillsSidePanel'; + +const mockFetchNextPage = jest.fn(); +const mockUseNavScrolling = jest.fn((_options?: object) => ({ + containerRef: { current: null }, +})); +const mockUseSkillsInfiniteQuery = jest.fn(() => ({ + data: { + pages: [{ skills: [], has_more: true, after: 'cursor-2' }], + }, + isFetchingNextPage: false, + fetchNextPage: mockFetchNextPage, + isLoading: false, +})); + +jest.mock('recoil', () => ({ + useRecoilValue: () => true, +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { sidebarExpanded: {} }, +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useDebounce: (value: string) => value, + useNavScrolling: (options: object) => mockUseNavScrolling(options), +})); + +jest.mock('~/data-provider', () => ({ + useSkillsInfiniteQuery: () => mockUseSkillsInfiniteQuery(), +})); + +jest.mock('~/components/ui', () => { + const ReactModule = jest.requireActual('react'); + const PanelContent = ReactModule.forwardRef( + ({ children }, ref) =>
{children}
, + ); + return { PanelContent }; +}); + +jest.mock('../FilterSkills', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../../lists/SkillListItem', () => ({ + __esModule: true, + default: () => null, +})); + +describe('SkillsSidePanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables automatic pagination while My Skills is collapsed', () => { + render( + + + , + ); + + const toggle = screen.getByRole('button', { name: 'com_ui_my_skills' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(mockUseNavScrolling).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true }), + ); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(mockUseNavScrolling).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }), + ); + + fireEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(mockUseNavScrolling).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 74d4ed893c..5faf2b32dc 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react'; -import { useSetRecoilState, useRecoilValue } from 'recoil'; import { useMediaQuery } from '@librechat/client'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; import type { ConversationListResponse } from 'librechat-data-provider'; @@ -13,9 +13,9 @@ import { useNavScrolling, } from '~/hooks'; import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider'; -import { Conversations } from '~/components/Conversations'; import ProjectsSection from '~/components/Conversations/ProjectsSection'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; +import { Conversations } from '~/components/Conversations'; import SearchBar from '~/components/Nav/SearchBar'; import store from '~/store'; @@ -29,7 +29,6 @@ const ConversationsSection = memo(() => { useTitleGeneration(isAuthenticated); const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true); - const [showLoading, setShowLoading] = useState(false); const [tags, setTags] = useState([]); const hasAccessToBookmarks = useHasAccess({ @@ -63,7 +62,6 @@ const ConversationsSection = memo(() => { const conversationsRef = useRef(null); const { moveToTop } = useNavScrolling({ - setShowLoading, fetchNextPage: async (options?) => { if (computedHasNextPage) { return fetchNextPage(options); @@ -131,7 +129,7 @@ const ConversationsSection = memo(() => { toggleNav={toggleNav} containerRef={conversationsRef} loadMoreConversations={loadMoreConversations} - isLoading={isFetchingNextPage || showLoading || isLoading} + isLoading={isFetchingNextPage || isLoading} isSearchLoading={isSearchLoading} isChatsExpanded={isChatsExpanded} setIsChatsExpanded={setIsChatsExpanded} diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx index c5ca83436b..f8074855d8 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx @@ -16,6 +16,10 @@ import type { SetterOrUpdater } from 'recoil'; */ const streamTickAtom = atom({ key: 'conversations-section-stream-tick', default: 0 }); +/** Generous because it covers a first-require module transform, not a race. */ +const LAZY_CHUNK_TIMEOUT = 15_000; +const TEST_TIMEOUT = 30_000; + const mockUseFavorites = jest.fn(() => ({ favorites: [] as unknown[], reorderFavorites: jest.fn(), @@ -162,37 +166,46 @@ describe('ConversationsSection streaming re-renders', () => { }); }); - it('does not re-render FavoritesList or BookmarkNav when the section re-renders mid-stream', async () => { - renderSection(); + it( + 'does not re-render FavoritesList or BookmarkNav when the section re-renders mid-stream', + async () => { + renderSection(); - // BookmarkNav is lazy-loaded; wait until it has actually rendered (its own - // data hook firing is the deterministic signal that the chunk resolved). - await waitFor(() => expect(mockUseGetConversationTags).toHaveBeenCalled()); - - // waitFor resolves once the hook first fires, but on loaded Windows shards the - // Suspense resolution can leave a trailing pass pending in the real scheduler, - // which the first stream tick's act would flush into the children's counts. - await settleRenders(); - - expect(mockUseFavorites.mock.calls.length).toBeGreaterThan(0); - expect(mockUseGetConversationTags.mock.calls.length).toBeGreaterThan(0); - - const favBaseline = mockUseFavorites.mock.calls.length; - const tagBaseline = mockUseGetConversationTags.mock.calls.length; - const titleBaseline = mockUseTitleGeneration.mock.calls.length; - - // Simulate a stream: repeatedly re-render ConversationsSection. - for (let i = 0; i < 5; i++) { - act(() => { - setStreamTick((prev) => prev + 1); + // BookmarkNav is lazy-loaded; wait until it has actually rendered (its own + // data hook firing is the deterministic signal that the chunk resolved). + // Resolving that import means transforming BookmarkNav's whole module graph + // on first require, which outruns the default one-second budget whenever the + // transform cache is cold or the machine is busy. + await waitFor(() => expect(mockUseGetConversationTags).toHaveBeenCalled(), { + timeout: LAZY_CHUNK_TIMEOUT, }); - } - // Sanity check: the section genuinely re-rendered each tick. - expect(mockUseTitleGeneration.mock.calls.length).toBeGreaterThan(titleBaseline); + // waitFor resolves once the hook first fires, but on loaded Windows shards the + // Suspense resolution can leave a trailing pass pending in the real scheduler, + // which the first stream tick's act would flush into the children's counts. + await settleRenders(); - // The memoized children, fed referentially stable props, did not re-render. - expect(mockUseFavorites.mock.calls.length).toBe(favBaseline); - expect(mockUseGetConversationTags.mock.calls.length).toBe(tagBaseline); - }); + expect(mockUseFavorites.mock.calls.length).toBeGreaterThan(0); + expect(mockUseGetConversationTags.mock.calls.length).toBeGreaterThan(0); + + const favBaseline = mockUseFavorites.mock.calls.length; + const tagBaseline = mockUseGetConversationTags.mock.calls.length; + const titleBaseline = mockUseTitleGeneration.mock.calls.length; + + // Simulate a stream: repeatedly re-render ConversationsSection. + for (let i = 0; i < 5; i++) { + act(() => { + setStreamTick((prev) => prev + 1); + }); + } + + // Sanity check: the section genuinely re-rendered each tick. + expect(mockUseTitleGeneration.mock.calls.length).toBeGreaterThan(titleBaseline); + + // The memoized children, fed referentially stable props, did not re-render. + expect(mockUseFavorites.mock.calls.length).toBe(favBaseline); + expect(mockUseGetConversationTags.mock.calls.length).toBe(tagBaseline); + }, + TEST_TIMEOUT, + ); }); diff --git a/client/src/components/ui/PanelContent.tsx b/client/src/components/ui/PanelContent.tsx new file mode 100644 index 0000000000..7e4cae704f --- /dev/null +++ b/client/src/components/ui/PanelContent.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +/** + * Scrolling content region of a side panel, and the single place that decides + * which state to draw. Pass the query's `isLoading` rather than `isFetching`: + * a refetch that already has rows on screen should leave them alone instead of + * replacing them with a skeleton. + * + * Forwards a ref to the scroll container so panels that fetch on scroll can + * attach their listener. + */ +const PanelContent = React.forwardRef< + HTMLDivElement, + { + isLoading: boolean; + isEmpty?: boolean; + /** Shaped like the rows it stands in for */ + skeleton: ReactNode; + empty?: ReactNode; + children?: ReactNode; + className?: string; + } +>(({ isLoading, isEmpty, skeleton, empty, children, className }, ref) => { + const localize = useLocalize(); + + const renderContent = () => { + if (isLoading) { + /** Skeleton rows are decorative, so a live region carries the announcement */ + return ( + <> + + {localize('com_ui_loading')} + + {skeleton} + + ); + } + if (isEmpty === true && empty != null) { + return empty; + } + return children; + }; + + return ( +
+ {renderContent()} +
+ ); +}); + +PanelContent.displayName = 'PanelContent'; + +export default PanelContent; diff --git a/client/src/components/ui/PanelFooter.tsx b/client/src/components/ui/PanelFooter.tsx new file mode 100644 index 0000000000..a7446a2feb --- /dev/null +++ b/client/src/components/ui/PanelFooter.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { cn } from '~/utils'; + +/** + * Footer pinned to the bottom of a side panel. Sits outside the panel's scroll + * area as a non-shrinking flex child, so it stays put while the list scrolls. + */ +export default function PanelFooter({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/client/src/components/ui/__tests__/PanelContent.spec.tsx b/client/src/components/ui/__tests__/PanelContent.spec.tsx new file mode 100644 index 0000000000..7ac51de4fb --- /dev/null +++ b/client/src/components/ui/__tests__/PanelContent.spec.tsx @@ -0,0 +1,80 @@ +import '@testing-library/jest-dom/extend-expect'; +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import PanelContent from '../PanelContent'; + +describe('PanelContent', () => { + const skeleton =