From 92d4705f7992f0a6ac5cb3962f9679bef0c4e6fe Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:15:46 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20refactor:=20make=20the=20side=20?= =?UTF-8?q?panels=20behave=20the=20same=20way=20(#14695)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: unify chat input tool badge styling Every tool badge repeated max-w-fit and its own hand-written checked-state colour triplet. Move max-w-fit into CheckboxButton's base classes, where tailwind-merge still lets a consumer override it, and collect the accent colours into a single map so the palette lives in one place. Artifacts repeated the amber triplet a second time on its dropdown button; that now reads from the same map. * feat: add feedback when resetting model parameters The button did nothing visible on click, so with parameters already at their defaults it looked broken. Spin the icon a full turn on press and announce the change politely, matching the Agent Builder panel which already announced but had no visual counterpart. The animation replays on consecutive clicks via a reflow, and is gated behind motion-reduce. * fix: keep the prompt editor open when inserting a special variable Opening the variables menu moved focus out of the textarea, whose blur handler exits edit mode, so the prompt snapped back to its rendered preview as if it had been saved. Guard the blur against focus landing inside a menu, since Ariakit focuses the menu itself on open, and hand the menu a finalFocus target so focus returns to the textarea on close. Without the latter the editor stayed open but unfocused, which quietly broke click-away-to-exit. * feat: create prompts from a dialog instead of a dedicated page Prompts now open a dialog from the sidebar, matching how skills and MCP servers are created, and /prompts/new is gone. The dialog reuses the existing form rather than duplicating it, with a flag to drop the page-level chrome that has no place in a modal. Three things the modal exposed: - Radix locks pointer events on the body, so the portaled category and special-variable menus rendered but could not be clicked. They now render inline when hosted in a dialog, as SetKeyDialog already does. - The floating labels notch out the page surface, which left a visible chip against the dialog background in dark mode. The surface is now passed in rather than hardcoded. - Creating gave no indication anything was happening; the button now shows a spinner and blocks repeat submits. Create buttons for both prompts and skills use the submit variant, since both perform a write. * style: match prompt action button sizes The share button sat at 36px next to a 40px Use Prompt button in the preview. Drop the size override so it takes the icon variant's default, and bring its row-mates in the editor header along so that row stays uniform. * feat: load prompts by scrolling instead of paging The query was already cursor-based; the nav hook was slicing it back into one page at a time behind Prev/Next buttons. Flatten the loaded pages and let the existing scroll hook fetch as the list nears its end. useNavScrolling only fetched from a scroll event, so a first page that did not overflow its container produced no event and the rest of the list was unreachable. It now tops up until the list actually scrolls, which is why zooming in used to 'fix' it. * feat: pin panel admin settings and scroll only the panel content Each side panel scrolled as a whole, so its filter row and toggles slid away with the list and the scrollbar spanned the full height. Give every panel a fixed header, a scrolling content region, and a footer that holds the admin settings. The skills panel gains the standard filter input in place of its title and toggle-to-search icon; it also rendered admin settings twice, once from the filter row and once from the accordion. Memories drops its client-side paging, which only sliced already-loaded data, in favour of scrolling the full list. * fix: repair the skills create menu and icon-only dropdowns The create menu was built on Dropdown, which is a select rather than an action menu, and Dropdown applies its className to the popover as well as the trigger. Sizing the trigger therefore shrank the menu itself to 36px and clipped both entries. Rebuild it on DropdownPopup, which is what the rest of the app uses for action menus. Dropdown's icon-only trigger also kept its horizontal padding and laid the icon out in a full-width flex row, leaving too little room so the icon flex-shrank to roughly half its width. That affected every icon-only consumer, including the prompts category filter. * fix: correct the gap above the MCP server URL field The fieldset grouping the connection sections carried display: contents, which removes its box and with it the margin that space-y puts on it. The first section inside sat flush against the description while every other gap kept its 16px. * refactor: unpin a favorite in one click The row's overflow menu held a single Unpin entry, so opening it was pure overhead. Show the unpin button directly instead. Its hover surface matched the row's own hover colour exactly, so hovering changed nothing; it now uses a surface that differs in both themes, with a border carrying the contrast in light mode where the surfaces are close. Adds the tests for unpinning, which had none. * fix: stop prompt skeletons stacking on top of the loaded list The groups were rendered outside the loading branch, so a refetch with data already cached drew three skeletons above the existing rows instead of leaving the list alone. The three states are now mutually exclusive. * feat: add PanelContent to standardize side panel loading states Each panel decided for itself whether to draw a spinner, a skeleton, or nothing, and some replaced the whole panel rather than just the list. PanelContent owns the scroll region and the loading/empty/content decision so a panel cannot invent a fourth pattern. It takes isLoading rather than isFetching on purpose: a refetch that already has rows on screen should leave them alone. * feat: give the side panels row-shaped loading skeletons Each panel now loads with a skeleton built from the row it stands in for, rather than a spinner or nothing: the memory card's key and token pill, the MCP server's icon over name and description, the bookmark's icon and count, the prompt card's block. Memories previously replaced the entire panel while loading, so the filter you had just typed into disappeared. The skeleton is now confined to the content region and the header stays put. Loading also moves out of the list components, which had each grown their own copy of it, and into the shared PanelContent. * feat: show a loading state in the bookmarks panel Bookmarks had no loading state at all: it rendered straight into its empty state while fetching, so it flashed 'no bookmarks' before the list appeared. Thread isLoading through and give it the same header, scrolling content and skeleton as the other panels. * style: tighten the favorite row and unpin button Even padding on the row, the unpin button sitting a little closer to the edge, and no border until it is hovered. * feat: scroll the bookmarks list instead of paging it Bookmarks were already fetched in full, so the pager was slicing data that was sitting in memory. Render the whole list and let it scroll, the same as the other side panels. It also removes a latent drag bug: rows were reordered by their index in the unsliced array while the list rendered a page slice, so dragging on any page past the first moved the wrong row. * feat: load skills by scrolling instead of capping the list The skills panel fetched a single page of 50 and never asked for more, so a 51st skill was unreachable. Switch it to the cursor-paginated infinite query that already existed alongside it and wire the shared scroll hook, matching prompts and the other side panels. The list and its rows only ever read summary fields, so they now take TSkillSummary and the response no longer needs casting through unknown. * fix: stop mocking real modules as virtual in specs Seven specs mocked @librechat/client and librechat-data-provider with `virtual: true`, which is for modules that do not exist on disk. These do, so the flag keyed each mock to a path derived from the spec's own directory rather than the module's resolved id. The component under test resolves the real id, so whether it got the mock depended on the module id cache of whichever worker picked the file up. UploadSkillDialog was the one that bit: when the mock missed, the real Radix dialog rendered and portaled its content to the body, so every assertion reading from the render container failed with the input "not rendered" while it sat in a portal a few nodes away. * test: give the lazy bookmark chunk room to load Waiting for BookmarkNav means waiting for babel to transform its whole module graph on first require, which does not fit in waitFor's default second when the transform cache is cold or the machine is busy. The failure looked like a missed re-render but was just an import in flight. * build: recycle jest workers before the OS kills them Coverage maps accumulate for the life of a worker, so a full client run pushes workers past a gigabyte and the OS kills one, failing whichever suite it was holding at the time. Capping idle worker memory also cut the wall clock, since the run no longer swaps. * fix: give the dialog prompt labels a real backdrop Floating labels notch out the surface behind them so the input's border does not run through the text. The dialog variant asked for `bg-background`, which no longer maps to anything and computes to transparent in both themes, leaving the border visible through the label. `bg-surface-primary` is what OGDialogContent actually paints. * fix: resolve side panel review findings Send the removed prompt create page to a tombstone route so a stale /prompts/new cannot render a blank form or fetch the id "new". Drive the list footer spinner from isFetchingNextPage alone; the old showLoading flag was set on scroll and only cleared by a later scroll, so it stuck on after the last page. Retry the scroll auto-fill through a ResizeObserver: the fill bailed whenever the panel had no layout yet and nothing asked again once it got one. A collapsed sidebar keeps its panel mounted and laid out, so gate fetching on the sidebar being expanded rather than draining the catalog behind an invisible panel. Gate the MCP admin footer on the admin role, matching the memories, prompts and skills panels; the bordered bar rendered empty for everyone else. Replay the reset icon spin by remounting the icon. Toggling the class list lost the animation to the re-render that setConversation causes. Announce panel loading from a live region carrying its own text. The skeleton rows and the spinner are both aria-hidden, so labelling the region left nothing for a screen reader to read out. Cover the scroll hook, the panel content primitive and the prompt create dialog with unit tests, and point the prompts e2e spec at the dialog rather than the deleted page. * chore: remove unused translation keys com_ui_pagination and com_ui_select_or_create_prompt lost their last callers when the prompt list moved to infinite scroll and the empty prompt preview was dropped. Only the English file is touched; the other locales are generated externally. * Fix nav pagination retry loop * Fix prompt field IDs and skills pagination * Fix prompt dropdown ARIA IDs * test: stub syncStaticTools in the server bootstrap specs initializeMCPs now calls syncStaticTools from services/Config when no MCP servers are configured. Both bootstrap specs mock that module wholesale, so the call threw, the post-listen handler ran process.exit(1), and the Jest worker died four times over before the suite was reported as failing to run. --- client/jest.config.cjs | 4 + .../Agents/tests/AgentCard.spec.tsx | 85 +++++------ .../Agents/tests/AgentDetailContent.spec.tsx | 26 ++-- .../src/components/Chat/Input/Artifacts.tsx | 7 +- .../components/Chat/Input/CodeInterpreter.tsx | 4 +- .../src/components/Chat/Input/FileSearch.tsx | 4 +- client/src/components/Chat/Input/Memory.tsx | 4 +- client/src/components/Chat/Input/Skills.tsx | 4 +- .../src/components/Chat/Input/WebSearch.tsx | 4 +- client/src/components/Chat/Input/accents.ts | 11 ++ .../__tests__/Landing.agent-contact.spec.tsx | 14 +- .../components/Nav/Favorites/FavoriteItem.tsx | 80 ++++------ .../Nav/Favorites/tests/FavoriteItem.spec.tsx | 35 +++-- .../Prompts/buttons/CreatePromptButton.tsx | 42 +++--- .../Prompts/dialogs/CreatePromptDialog.tsx | 43 ++++++ .../Prompts/dialogs/DeletePrompt.tsx | 1 - .../Prompts/dialogs/SharePrompt.tsx | 2 +- .../__tests__/CreatePromptDialog.spec.tsx | 68 +++++++++ .../Prompts/display/EmptyPromptPreview.tsx | 12 -- .../src/components/Prompts/display/index.ts | 1 - .../Prompts/editor/PromptEditor.tsx | 17 ++- .../Prompts/editor/VariablesDropdown.tsx | 12 +- .../Prompts/fields/CategorySelector.tsx | 19 ++- .../src/components/Prompts/fields/Command.tsx | 21 ++- .../components/Prompts/fields/Description.tsx | 20 ++- .../__tests__/PromptDropdownIds.spec.tsx | 99 +++++++++++++ .../fields/__tests__/PromptFieldIds.spec.tsx | 36 +++++ .../Prompts/forms/CreatePromptForm.tsx | 46 ++++-- .../components/Prompts/forms/PromptForm.tsx | 1 + client/src/components/Prompts/index.ts | 9 +- .../Prompts/layouts/InlinePromptsView.tsx | 31 +--- .../Prompts/lists/ChatGroupItem.tsx | 2 +- client/src/components/Prompts/lists/List.tsx | 48 +++--- .../Prompts/lists/PromptGroupSkeleton.tsx | 12 ++ .../Prompts/sidebar/GroupSidePanel.tsx | 68 +++++---- .../Prompts/sidebar/PanelNavigation.tsx | 50 ------- .../Prompts/sidebar/PromptsAccordion.tsx | 17 ++- .../src/components/Prompts/sidebar/index.ts | 1 - .../Bookmarks/BookmarkCardSkeleton.tsx | 19 +++ .../SidePanel/Bookmarks/BookmarkPanel.tsx | 8 +- .../SidePanel/Bookmarks/BookmarkTable.tsx | 74 +++------- .../SidePanel/MCPBuilder/MCPBuilderPanel.tsx | 60 ++++---- .../MCPBuilder/MCPServerCardSkeleton.tsx | 21 +++ .../MCPServerDialog/MCPServerForm.tsx | 8 +- .../sections/__tests__/TrustSection.spec.tsx | 46 +++--- .../SidePanel/Memories/MemoryCardSkeleton.tsx | 21 +++ .../SidePanel/Memories/MemoryPanel.tsx | 100 ++++--------- .../components/SidePanel/Parameters/Panel.tsx | 24 ++- .../Skills/buttons/CreateSkillMenu.tsx | 60 ++++---- .../Skills/dialogs/CreateSkillDialog.tsx | 1 + .../__tests__/CreateSkillDialog.spec.tsx | 70 +++++---- .../__tests__/UploadSkillDialog.spec.tsx | 30 ++-- .../layouts/__tests__/SkillsView.spec.tsx | 24 +-- .../src/components/Skills/lists/SkillList.tsx | 30 ++-- .../components/Skills/lists/SkillListItem.tsx | 8 +- .../Skills/lists/SkillListSkeleton.tsx | 12 ++ .../Skills/sidebar/FilterSkills.tsx | 12 +- .../Skills/sidebar/SkillsAccordion.tsx | 9 +- .../Skills/sidebar/SkillsSidePanel.tsx | 124 +++++++--------- .../__tests__/SkillsSidePanel.spec.tsx | 89 +++++++++++ .../UnifiedSidebar/ConversationsSection.tsx | 8 +- .../__tests__/ConversationsSection.spec.tsx | 71 +++++---- client/src/components/ui/PanelContent.tsx | 60 ++++++++ client/src/components/ui/PanelFooter.tsx | 25 ++++ .../ui/__tests__/PanelContent.spec.tsx | 80 ++++++++++ client/src/components/ui/index.ts | 2 + client/src/hooks/Nav/useNavScrolling.ts | 82 ++++++----- .../src/hooks/Prompts/usePromptGroupsNav.ts | 104 +++---------- .../hooks/__tests__/useNavScrolling.spec.tsx | 139 ++++++++++++++++++ client/src/locales/en/translation.json | 2 - client/src/routes/Dashboard.tsx | 3 +- client/src/routes/index.tsx | 7 +- client/tailwind.config.cjs | 5 + e2e/specs/mock/prompts.spec.ts | 17 ++- .../client/src/components/CheckboxButton.tsx | 2 +- packages/client/src/components/Dropdown.tsx | 7 +- 76 files changed, 1501 insertions(+), 923 deletions(-) create mode 100644 client/src/components/Chat/Input/accents.ts create mode 100644 client/src/components/Prompts/dialogs/CreatePromptDialog.tsx create mode 100644 client/src/components/Prompts/dialogs/__tests__/CreatePromptDialog.spec.tsx delete mode 100644 client/src/components/Prompts/display/EmptyPromptPreview.tsx create mode 100644 client/src/components/Prompts/fields/__tests__/PromptDropdownIds.spec.tsx create mode 100644 client/src/components/Prompts/fields/__tests__/PromptFieldIds.spec.tsx create mode 100644 client/src/components/Prompts/lists/PromptGroupSkeleton.tsx delete mode 100644 client/src/components/Prompts/sidebar/PanelNavigation.tsx create mode 100644 client/src/components/SidePanel/Bookmarks/BookmarkCardSkeleton.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerCardSkeleton.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryCardSkeleton.tsx create mode 100644 client/src/components/Skills/lists/SkillListSkeleton.tsx create mode 100644 client/src/components/Skills/sidebar/__tests__/SkillsSidePanel.spec.tsx create mode 100644 client/src/components/ui/PanelContent.tsx create mode 100644 client/src/components/ui/PanelFooter.tsx create mode 100644 client/src/components/ui/__tests__/PanelContent.spec.tsx create mode 100644 client/src/hooks/__tests__/useNavScrolling.spec.tsx 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 =