mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🧭 refactor: make the side panels behave the same way (#14695)
* 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.
This commit is contained in:
parent
667d97d668
commit
92d4705f79
76 changed files with 1501 additions and 923 deletions
|
|
@ -33,6 +33,10 @@ module.exports = {
|
|||
'<rootDir>/../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'],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div data-testid="dialog-wrapper" data-open={open}>
|
||||
{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;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onClick: (e: any) => {
|
||||
(children as any).props?.onClick?.(e);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
},
|
||||
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
|
||||
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
};
|
||||
},
|
||||
{ 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 (
|
||||
<div data-testid="dialog-wrapper" data-open={open}>
|
||||
{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;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onClick: (e: any) => {
|
||||
(children as any).props?.onClick?.(e);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
},
|
||||
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
|
||||
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Create wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
|
|
|
|||
|
|
@ -23,22 +23,18 @@ jest.mock('librechat-data-provider', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => ({
|
||||
OGDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
jest.mock('@librechat/client', () => ({
|
||||
OGDialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
|
||||
useToastContext: () => ({
|
||||
showToast: jest.fn(),
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useDefaultConvo: () => jest.fn((value) => value.conversation),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex">
|
||||
<CheckboxButton
|
||||
className={cn('max-w-fit', isEnabled && 'rounded-r-none border-r-0')}
|
||||
className={cn(isEnabled && 'rounded-r-none border-r-0')}
|
||||
checked={isEnabled}
|
||||
setValue={handleToggle}
|
||||
label={localize('com_ui_artifacts')}
|
||||
isCheckedClassName="border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10"
|
||||
isCheckedClassName={badgeAccents.amber}
|
||||
icon={<WandSparkles className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ function Artifacts() {
|
|||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'w-7 rounded-l-none rounded-r-full border-b border-l-0 border-r border-t border-border-light md:w-6',
|
||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
badgeAccents.amber,
|
||||
'transition-colors',
|
||||
)}
|
||||
onClick={handleMenuButtonClick}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CheckboxButton } from '@librechat/client';
|
|||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { badgeAccents } from './accents';
|
||||
|
||||
function CodeInterpreter() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -22,11 +23,10 @@ function CodeInterpreter() {
|
|||
return (
|
||||
(runCode || isPinned) && (
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
checked={runCode}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_run_code')}
|
||||
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
|
||||
isCheckedClassName={badgeAccents.purple}
|
||||
icon={<TerminalSquareIcon className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CheckboxButton, VectorIcon } from '@librechat/client';
|
|||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { badgeAccents } from './accents';
|
||||
|
||||
function FileSearch() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -22,11 +23,10 @@ function FileSearch() {
|
|||
<>
|
||||
{(fileSearchEnabled || isPinned) && (
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
checked={fileSearchEnabled}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_assistants_file_search')}
|
||||
isCheckedClassName="border-green-600/40 bg-green-500/10 hover:bg-green-700/10"
|
||||
isCheckedClassName={badgeAccents.green}
|
||||
icon={<VectorIcon className="icon-md" />}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CheckboxButton } from '@librechat/client';
|
|||
import { defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasMemoryAccess, useAgentCapabilities, useAuthContext } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { badgeAccents } from './accents';
|
||||
|
||||
function Memory() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -26,11 +27,10 @@ function Memory() {
|
|||
return (
|
||||
(memoryActive || isPinned) && (
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
checked={memoryActive}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_memory')}
|
||||
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
|
||||
isCheckedClassName={badgeAccents.purple}
|
||||
icon={<Brain className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CheckboxButton } from '@librechat/client';
|
|||
import { Permissions, PermissionTypes, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { badgeAccents } from './accents';
|
||||
|
||||
function Skills() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -26,11 +27,10 @@ function Skills() {
|
|||
return (
|
||||
(skillsActive || isPinned) && (
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
checked={skillsActive}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_skills')}
|
||||
isCheckedClassName="border-cyan-600/40 bg-cyan-500/10 hover:bg-cyan-700/10"
|
||||
isCheckedClassName={badgeAccents.cyan}
|
||||
icon={<ScrollText className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CheckboxButton } from '@librechat/client';
|
|||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { badgeAccents } from './accents';
|
||||
|
||||
function WebSearch() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -26,11 +27,10 @@ function WebSearch() {
|
|||
(isPinned || (webSearch && authData?.authenticated)) && (
|
||||
<CheckboxButton
|
||||
ref={badgeTriggerRef}
|
||||
className="max-w-fit"
|
||||
checked={webSearch}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_search')}
|
||||
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
isCheckedClassName={badgeAccents.blue}
|
||||
icon={<Globe className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
11
client/src/components/Chat/Input/accents.ts
Normal file
11
client/src/components/Chat/Input/accents.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Checked-state accent classes for the chat input tool badges.
|
||||
* Written out in full because Tailwind cannot resolve interpolated class names.
|
||||
*/
|
||||
export const badgeAccents = {
|
||||
amber: 'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
blue: 'border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10',
|
||||
cyan: 'border-cyan-600/40 bg-cyan-500/10 hover:bg-cyan-700/10',
|
||||
green: 'border-green-600/40 bg-green-500/10 hover:bg-green-700/10',
|
||||
purple: 'border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10',
|
||||
} as const;
|
||||
|
|
@ -20,15 +20,11 @@ jest.mock('librechat-data-provider', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => ({
|
||||
BirthdayIcon: () => <span data-testid="birthday-icon" />,
|
||||
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
SplitText: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('@librechat/client', () => ({
|
||||
BirthdayIcon: () => <span data-testid="birthday-icon" />,
|
||||
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
SplitText: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useChatContext: () => ({ conversation: mockConversation }),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { Ellipsis, PinOff } from 'lucide-react';
|
||||
import { DropdownPopup } from '@librechat/client';
|
||||
import React from 'react';
|
||||
import { PinOff } from 'lucide-react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { Agent, TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { FavoriteModel } from '~/store/favorites';
|
||||
import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon';
|
||||
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
|
||||
import { useFavorites, useLocalize } from '~/hooks';
|
||||
import { renderAgentAvatar, cn } from '~/utils';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
|
||||
type Kwargs = {
|
||||
model?: string;
|
||||
|
|
@ -46,7 +45,6 @@ export default function FavoriteItem(props: FavoriteItemProps) {
|
|||
const { onRemoveFocus } = props;
|
||||
const localize = useLocalize();
|
||||
const { removeFavoriteAgent, removeFavoriteModel, removeFavoriteSpec } = useFavorites();
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (props.type === 'agent') {
|
||||
|
|
@ -59,7 +57,7 @@ export default function FavoriteItem(props: FavoriteItemProps) {
|
|||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-testid="favorite-options-button"]')) {
|
||||
if ((e.target as HTMLElement).closest('[data-testid="favorite-unpin-button"]')) {
|
||||
return;
|
||||
}
|
||||
handleSelect();
|
||||
|
|
@ -81,7 +79,6 @@ export default function FavoriteItem(props: FavoriteItemProps) {
|
|||
} else {
|
||||
removeFavoriteModel(props.item.model, props.item.endpoint);
|
||||
}
|
||||
setIsPopoverActive(false);
|
||||
requestAnimationFrame(() => {
|
||||
onRemoveFocus?.();
|
||||
});
|
||||
|
|
@ -119,25 +116,12 @@ export default function FavoriteItem(props: FavoriteItemProps) {
|
|||
}
|
||||
const ariaLabel = `${name} (${typeLabel})`;
|
||||
|
||||
const menuId = React.useId();
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: localize('com_ui_unpin'),
|
||||
onClick: handleRemove,
|
||||
icon: <PinOff className="h-4 w-4 text-text-secondary" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary outline-none hover:bg-surface-active-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
|
||||
isPopoverActive ? 'bg-surface-active-alt' : '',
|
||||
)}
|
||||
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg p-2 text-sm text-text-primary outline-none hover:bg-surface-active-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="favorite-item"
|
||||
|
|
@ -148,48 +132,34 @@ export default function FavoriteItem(props: FavoriteItemProps) {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-2 flex items-center',
|
||||
isPopoverActive
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: // Interactive by default so it's tappable on touch; only
|
||||
// hidden-until-hover on hover-capable pointers. Otherwise the
|
||||
// whole row is hover-dependent and the first tap just reveals
|
||||
// this instead of selecting (the iOS double-tap).
|
||||
'group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:hover)]:pointer-events-none [@media(hover:hover)]:opacity-0',
|
||||
)}
|
||||
className={
|
||||
// Interactive by default so it's tappable on touch; only
|
||||
// hidden-until-hover on hover-capable pointers. Otherwise the
|
||||
// whole row is hover-dependent and the first tap just reveals
|
||||
// this instead of selecting (the iOS double-tap).
|
||||
'absolute right-1 flex items-center group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 [@media(hover:hover)]:pointer-events-none [@media(hover:hover)]:opacity-0'
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
mountByState={true}
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
className="z-[125]"
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
||||
isPopoverActive
|
||||
? 'opacity-100'
|
||||
: 'focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100 [@media(hover:hover)]:opacity-0',
|
||||
)}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
data-testid="favorite-options-button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_unpin')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_unpin')}
|
||||
data-testid="favorite-unpin-button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0 text-text-secondary transition-colors duration-150 hover:border-border-medium hover:bg-surface-active hover:text-text-primary focus-visible:border-border-medium focus-visible:bg-surface-active focus-visible:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
|
||||
onClick={handleRemove}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
</Menu.MenuButton>
|
||||
<PinOff className="size-4" aria-hidden={true} />
|
||||
</button>
|
||||
}
|
||||
items={dropdownItems}
|
||||
menuId={menuId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,17 +31,6 @@ jest.mock('~/utils', () => ({
|
|||
renderAgentAvatar: () => <span data-testid="agent-avatar" />,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
DropdownPopup: () => <div data-testid="dropdown-popup" />,
|
||||
}));
|
||||
|
||||
jest.mock('@ariakit/react/menu', () => ({
|
||||
MenuButton: ({ children }: { children?: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseAgent: Agent = {
|
||||
id: 'agent-123',
|
||||
name: 'Research Agent',
|
||||
|
|
@ -147,4 +136,28 @@ describe('FavoriteItem', () => {
|
|||
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpin button', () => {
|
||||
it('unpins directly without opening a menu', () => {
|
||||
render(<FavoriteItem type="agent" item={baseAgent} />);
|
||||
const unpin = screen.getByTestId('favorite-unpin-button');
|
||||
expect(unpin).toHaveAttribute('aria-label', 'com_ui_unpin');
|
||||
fireEvent.click(unpin);
|
||||
expect(mockRemoveFavoriteAgent).toHaveBeenCalledWith('agent-123');
|
||||
});
|
||||
|
||||
it('unpins a model without selecting the row', () => {
|
||||
const onSelectEndpoint = jest.fn();
|
||||
render(<FavoriteItem type="model" item={baseModel} onSelectEndpoint={onSelectEndpoint} />);
|
||||
fireEvent.click(screen.getByTestId('favorite-unpin-button'));
|
||||
expect(mockRemoveFavoriteModel).toHaveBeenCalledWith('gpt-5', 'openai');
|
||||
expect(onSelectEndpoint).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unpins a spec', () => {
|
||||
render(<FavoriteItem type="spec" item={baseSpec} />);
|
||||
fireEvent.click(screen.getByTestId('favorite-unpin-button'));
|
||||
expect(mockRemoveFavoriteSpec).toHaveBeenCalledWith('my-spec');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, TooltipAnchor } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Button, TooltipAnchor, OGDialogTrigger } from '@librechat/client';
|
||||
import CreatePromptDialog from '../dialogs/CreatePromptDialog';
|
||||
import { useHasAccess, useLocalize } from '~/hooks';
|
||||
|
||||
export default function CreatePromptButton() {
|
||||
const localize = useLocalize();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
|
|
@ -16,22 +18,24 @@ export default function CreatePromptButton() {
|
|||
}
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_create_prompt')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 shrink-0 bg-transparent"
|
||||
aria-label={localize('com_ui_create_prompt')}
|
||||
>
|
||||
<Link to="/prompts/new">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CreatePromptDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_create_prompt')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 shrink-0 bg-transparent"
|
||||
aria-label={localize('com_ui_create_prompt')}
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</CreatePromptDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
43
client/src/components/Prompts/dialogs/CreatePromptDialog.tsx
Normal file
43
client/src/components/Prompts/dialogs/CreatePromptDialog.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
|
||||
import CreatePromptForm from '../forms/CreatePromptForm';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface CreatePromptDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CreatePromptDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: CreatePromptDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(groupId: string) => {
|
||||
onOpenChange(false);
|
||||
navigate(`/prompts/${groupId}`);
|
||||
},
|
||||
[navigate, onOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
<OGDialogContent className="w-11/12 max-w-5xl">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_create_prompt')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
{/* Padding keeps the name field's floating label and focus rings from clipping */}
|
||||
<div className="max-h-[75vh] overflow-y-auto px-1 pt-3">
|
||||
<CreatePromptForm isDialog onSuccess={handleSuccess} />
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ const DeleteConfirmDialog = ({
|
|||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
aria-label={localize('com_ui_delete')}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const SharePrompt = React.memo(
|
|||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 border-border-medium"
|
||||
className="border-border-medium"
|
||||
aria-label={localize('com_ui_share')}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import CreatePromptDialog from '../CreatePromptDialog';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockOnOpenChange = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement(React.Fragment, null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', { role: 'dialog' }, children),
|
||||
OGDialogHeader: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
OGDialogTitle: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('h2', null, children),
|
||||
};
|
||||
});
|
||||
|
||||
/** The form is exercised by its own specs; this isolates the dialog's contract */
|
||||
jest.mock('../../forms/CreatePromptForm', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSuccess }: { onSuccess?: (groupId: string) => void }) => (
|
||||
<button type="button" data-testid="submit" onClick={() => onSuccess?.('group-123')} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('CreatePromptDialog', () => {
|
||||
test('renders the create form in a dialog when open', () => {
|
||||
render(<CreatePromptDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Create Prompt' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders nothing while closed', () => {
|
||||
render(<CreatePromptDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closes and opens the new group once created, rather than a "new" page', () => {
|
||||
render(<CreatePromptDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('submit'));
|
||||
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/prompts/group-123');
|
||||
});
|
||||
|
||||
test('renders the trigger passed as children alongside the dialog', () => {
|
||||
render(
|
||||
<CreatePromptDialog open={true} onOpenChange={mockOnOpenChange}>
|
||||
<button type="button" data-testid="trigger" />
|
||||
</CreatePromptDialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function EmptyPromptPreview() {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full content-center text-center font-bold text-text-secondary">
|
||||
{localize('com_ui_select_or_create_prompt')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,5 +3,4 @@ export { default as PromptActions } from './PromptActions';
|
|||
export { default as PromptTextCard } from './PromptTextCard';
|
||||
export { default as PromptVersions } from './PromptVersions';
|
||||
export { default as PromptVariables } from './PromptVariables';
|
||||
export { default as EmptyPromptPreview } from './EmptyPromptPreview';
|
||||
export { default as PromptDetailHeader } from './PromptDetailHeader';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, memo } from 'react';
|
||||
import { useRef, useMemo, memo } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
|
@ -24,6 +24,7 @@ type Props = {
|
|||
const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
||||
const localize = useLocalize();
|
||||
const { control } = useFormContext();
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const EditorIcon = useMemo(() => {
|
||||
return isEditing ? Check : EditIcon;
|
||||
|
|
@ -51,7 +52,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
|||
)}
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
|
||||
<VariablesDropdown fieldName={name} />
|
||||
<VariablesDropdown fieldName={name} finalFocus={textareaRef} />
|
||||
<TooltipAnchor
|
||||
description={isEditing ? localize('com_ui_save') : localize('com_ui_edit')}
|
||||
render={
|
||||
|
|
@ -84,12 +85,22 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
|||
isEditing ? (
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
ref={(el: HTMLTextAreaElement | null) => {
|
||||
field.ref(el);
|
||||
textareaRef.current = el;
|
||||
}}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
className="w-full resize-none overflow-y-auto bg-transparent font-mono text-sm leading-relaxed text-text-primary placeholder:text-text-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary sm:text-base"
|
||||
minRows={4}
|
||||
maxRows={16}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
onBlur={(e) => {
|
||||
/** Opening the variables menu moves focus into it; that is not leaving the editor */
|
||||
if (e.relatedTarget?.closest('[role="menu"]')) {
|
||||
return;
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -15,13 +15,19 @@ const variableKeys = Object.keys(specialVariables) as Array<keyof typeof special
|
|||
interface VariablesDropdownProps {
|
||||
fieldName?: string;
|
||||
className?: string;
|
||||
finalFocus?: React.RefObject<HTMLElement>;
|
||||
/** Portaled menus are unclickable inside a modal dialog, which locks pointer events on the body */
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
export default function VariablesDropdown({
|
||||
fieldName = 'prompt',
|
||||
className = '',
|
||||
finalFocus,
|
||||
portal = true,
|
||||
}: VariablesDropdownProps) {
|
||||
const menuId = useId();
|
||||
const triggerId = `${menuId}-button`;
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext();
|
||||
const { setValue, getValues, watch } = methods;
|
||||
|
|
@ -102,15 +108,17 @@ export default function VariablesDropdown({
|
|||
return (
|
||||
<div className={className}>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
portal={portal}
|
||||
mountByState={true}
|
||||
unmountOnHide={true}
|
||||
preserveTabOrder={true}
|
||||
finalFocus={finalFocus}
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
id="variables-menu-button"
|
||||
id={triggerId}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
aria-label={localize('com_ui_add_special_variables')}
|
||||
className={`group flex h-8 items-center gap-1.5 rounded-lg bg-transparent px-2 text-sm ${buttonClass}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useId, useMemo, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DropdownPopup } from '@librechat/client';
|
||||
|
|
@ -14,13 +14,18 @@ interface CategorySelectorProps {
|
|||
currentCategory?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
className?: string;
|
||||
/** Portaled menus are unclickable inside a modal dialog, which locks pointer events on the body */
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
const CategorySelector: React.FC<CategorySelectorProps> = ({
|
||||
currentCategory,
|
||||
onValueChange,
|
||||
className = '',
|
||||
portal = true,
|
||||
}) => {
|
||||
const instanceId = useId();
|
||||
const menuId = `${instanceId}-category-menu`;
|
||||
const { t } = useTranslation();
|
||||
const formContext = useFormContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -56,7 +61,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
|
|||
if (!categories) return [];
|
||||
|
||||
return categories.map((category) => ({
|
||||
id: category.value,
|
||||
id: `${menuId}-item-${category.value}`,
|
||||
label: category.label,
|
||||
icon: 'icon' in category ? category.icon : undefined,
|
||||
onClick: () => {
|
||||
|
|
@ -69,7 +74,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
|
|||
setIsOpen(false);
|
||||
},
|
||||
}));
|
||||
}, [categories, formContext, setValue, onValueChange]);
|
||||
}, [categories, formContext, menuId, setValue, onValueChange]);
|
||||
|
||||
const trigger = (
|
||||
<Ariakit.MenuButton
|
||||
|
|
@ -101,9 +106,9 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
|
|||
items={menuItems}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
menuId="category-selector-menu"
|
||||
menuId={menuId}
|
||||
className="mt-2"
|
||||
portal={true}
|
||||
portal={portal}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -113,9 +118,9 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
|
|||
items={menuItems}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
menuId="category-selector-menu"
|
||||
menuId={menuId}
|
||||
className="mt-2"
|
||||
portal={true}
|
||||
portal={portal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
import { useId, useState, useEffect } from 'react';
|
||||
import { Input } from '@librechat/client';
|
||||
import { SquareSlash } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Command = ({
|
||||
initialValue,
|
||||
onValueChange,
|
||||
disabled,
|
||||
tabIndex,
|
||||
labelBgClassName = 'bg-presentation',
|
||||
}: {
|
||||
initialValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
/** Surface the floating label notches out of; must match whatever sits behind the field */
|
||||
labelBgClassName?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const commandId = useId();
|
||||
const [command, setCommand] = useState(initialValue || '');
|
||||
const [charCount, setCharCount] = useState(initialValue?.length || 0);
|
||||
|
||||
|
|
@ -45,10 +50,7 @@ const Command = ({
|
|||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-medium">
|
||||
<label
|
||||
htmlFor="prompt-command"
|
||||
className="block px-4 pt-2 text-sm text-text-secondary md:hidden"
|
||||
>
|
||||
<label htmlFor={commandId} className="block px-4 pt-2 text-sm text-text-secondary md:hidden">
|
||||
{localize('com_ui_command_placeholder')}
|
||||
</label>
|
||||
<div className="relative flex h-10 items-center gap-1 pl-4 pr-2 text-sm text-text-secondary">
|
||||
|
|
@ -56,7 +58,7 @@ const Command = ({
|
|||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
id="prompt-command"
|
||||
id={commandId}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
placeholder=" "
|
||||
|
|
@ -66,8 +68,11 @@ const Command = ({
|
|||
aria-label={localize('com_ui_command_placeholder')}
|
||||
/>
|
||||
<label
|
||||
htmlFor="prompt-command"
|
||||
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-presentation px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
|
||||
htmlFor={commandId}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block',
|
||||
labelBgClassName,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_command_placeholder')}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useId, useState, useEffect } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Input } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Info } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const MAX_LENGTH = 120;
|
||||
|
||||
|
|
@ -10,13 +11,17 @@ const Description = ({
|
|||
onValueChange,
|
||||
disabled,
|
||||
tabIndex,
|
||||
labelBgClassName = 'bg-presentation',
|
||||
}: {
|
||||
initialValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
/** Surface the floating label notches out of; must match whatever sits behind the field */
|
||||
labelBgClassName?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const descriptionId = useId();
|
||||
const [description, setDescription] = useState(initialValue || '');
|
||||
const [charCount, setCharCount] = useState(initialValue?.length || 0);
|
||||
|
||||
|
|
@ -43,7 +48,7 @@ const Description = ({
|
|||
return (
|
||||
<div className="rounded-xl border border-border-medium">
|
||||
<label
|
||||
htmlFor="prompt-description"
|
||||
htmlFor={descriptionId}
|
||||
className="block px-4 pt-2 text-sm text-text-secondary md:hidden"
|
||||
>
|
||||
{localize('com_ui_description_placeholder')}
|
||||
|
|
@ -53,7 +58,7 @@ const Description = ({
|
|||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
id="prompt-description"
|
||||
id={descriptionId}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
placeholder=" "
|
||||
|
|
@ -63,8 +68,11 @@ const Description = ({
|
|||
aria-label={localize('com_ui_description_placeholder')}
|
||||
/>
|
||||
<label
|
||||
htmlFor="prompt-description"
|
||||
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-presentation px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
|
||||
htmlFor={descriptionId}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block',
|
||||
labelBgClassName,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_description_placeholder')}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import VariablesDropdown from '../../editor/VariablesDropdown';
|
||||
import CategorySelector from '../CategorySelector';
|
||||
|
||||
const mockAnnouncePolite = jest.fn();
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useCategories: () => ({
|
||||
categories: [{ value: 'general', label: 'General' }],
|
||||
emptyCategory: { value: '', label: 'Empty' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
usePromptGroupsContext: () => ({ hasAccess: true }),
|
||||
useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }),
|
||||
}));
|
||||
|
||||
function PromptControls() {
|
||||
const methods = useForm({
|
||||
defaultValues: { category: '', prompt: '' },
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<CategorySelector portal={false} />
|
||||
<VariablesDropdown portal={false} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getAttribute(element: HTMLElement, name: string) {
|
||||
const value = element.getAttribute(name);
|
||||
expect(value).not.toBeNull();
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function getElementsWithId(container: HTMLElement, id: string) {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[id]')).filter(
|
||||
(element) => element.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
describe('prompt dropdown IDs', () => {
|
||||
it('keeps menu relationships unique when multiple prompt forms are mounted', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<PromptControls />
|
||||
<PromptControls />
|
||||
</>,
|
||||
);
|
||||
|
||||
const categoryTriggers = screen.getAllByRole('button', {
|
||||
name: 'com_ui_prompt_category_selector_aria',
|
||||
});
|
||||
const categoryMenuIds = categoryTriggers.map((trigger) =>
|
||||
getAttribute(trigger, 'aria-controls'),
|
||||
);
|
||||
|
||||
expect(new Set(categoryMenuIds).size).toBe(categoryMenuIds.length);
|
||||
categoryTriggers.forEach((trigger, index) => {
|
||||
const menus = getElementsWithId(container, categoryMenuIds[index]);
|
||||
expect(menus).toHaveLength(1);
|
||||
expect(menus[0]).toHaveAttribute('aria-labelledby', trigger.id);
|
||||
});
|
||||
|
||||
const categoryItems = Array.from(container.querySelectorAll<HTMLElement>('[role="menuitem"]'));
|
||||
const categoryItemIds = categoryItems.map((item) => item.id);
|
||||
expect(categoryItemIds.every(Boolean)).toBe(true);
|
||||
expect(new Set(categoryItemIds).size).toBe(categoryItemIds.length);
|
||||
|
||||
const variableTriggers = screen.getAllByRole('button', {
|
||||
name: 'com_ui_add_special_variables',
|
||||
});
|
||||
const variableTriggerIds = variableTriggers.map((trigger) => trigger.id);
|
||||
expect(variableTriggerIds.every(Boolean)).toBe(true);
|
||||
expect(new Set(variableTriggerIds).size).toBe(variableTriggerIds.length);
|
||||
|
||||
variableTriggers.forEach((trigger) => fireEvent.click(trigger));
|
||||
|
||||
const variableMenuIds = variableTriggers.map((trigger) =>
|
||||
getAttribute(trigger, 'aria-controls'),
|
||||
);
|
||||
expect(new Set(variableMenuIds).size).toBe(variableMenuIds.length);
|
||||
variableTriggers.forEach((trigger, index) => {
|
||||
const menus = getElementsWithId(container, variableMenuIds[index]);
|
||||
expect(menus).toHaveLength(1);
|
||||
expect(menus[0]).toHaveAttribute('aria-labelledby', trigger.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Description from '../Description';
|
||||
import Command from '../Command';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
function PromptFields() {
|
||||
return (
|
||||
<>
|
||||
<Description />
|
||||
<Command />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('prompt field IDs', () => {
|
||||
it('associates every label with one field when multiple forms are mounted', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<PromptFields />
|
||||
<PromptFields />
|
||||
</>,
|
||||
);
|
||||
const inputs = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
|
||||
expect(new Set(inputs.map((input) => input.id)).size).toBe(inputs.length);
|
||||
|
||||
for (const label of container.querySelectorAll('label')) {
|
||||
expect(inputs.filter((input) => input.id === label.htmlFor)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@ import { useEffect } from 'react';
|
|||
import { FileText } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||
import { Button, TextareaAutosize, Input, useMediaQuery } from '@librechat/client';
|
||||
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Button, Spinner, TextareaAutosize, Input, useMediaQuery } from '@librechat/client';
|
||||
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
|
||||
import VariablesDropdown from '../editor/VariablesDropdown';
|
||||
import CategorySelector from '../fields/CategorySelector';
|
||||
|
|
@ -36,9 +36,12 @@ const defaultPrompt: CreateFormValues = {
|
|||
const CreatePromptForm = ({
|
||||
defaultValues = defaultPrompt,
|
||||
onSuccess,
|
||||
isDialog = false,
|
||||
}: {
|
||||
defaultValues?: CreateFormValues;
|
||||
onSuccess?: (groupId: string) => void;
|
||||
/** Drops the page-level chrome (sidebar toggle, page padding) when hosted in a dialog */
|
||||
isDialog?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -88,6 +91,10 @@ const CreatePromptForm = ({
|
|||
});
|
||||
|
||||
const promptText = watch('prompt');
|
||||
const isCreating = createPromptMutation.isLoading;
|
||||
const isBlocked = !isDirty || isSubmitting || !isValid || isCreating;
|
||||
/** Floating labels notch out the surface behind them: the dialog sits on `surface-primary`, the page on `presentation` */
|
||||
const labelBgClassName = isDialog ? 'bg-surface-primary' : 'bg-presentation';
|
||||
|
||||
const onSubmit = (data: CreateFormValues) => {
|
||||
const { name, category, oneliner, command, ...rest } = data;
|
||||
|
|
@ -113,12 +120,12 @@ const CreatePromptForm = ({
|
|||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
|
||||
<h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={cn('w-full', !isDialog && 'px-4 py-2')}>
|
||||
{!isDialog && <h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>}
|
||||
{isSmallScreen ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<OpenSidebar />
|
||||
<CategorySelector />
|
||||
{!isDialog && <OpenSidebar />}
|
||||
<CategorySelector portal={!isDialog} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
|
||||
|
|
@ -141,7 +148,10 @@ const CreatePromptForm = ({
|
|||
/>
|
||||
<label
|
||||
htmlFor="prompt-name"
|
||||
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-presentation px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
|
||||
className={cn(
|
||||
'pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75',
|
||||
labelBgClassName,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_prompt_name')}*
|
||||
</label>
|
||||
|
|
@ -158,7 +168,7 @@ const CreatePromptForm = ({
|
|||
/>
|
||||
{!isSmallScreen && (
|
||||
<div>
|
||||
<CategorySelector />
|
||||
<CategorySelector portal={!isDialog} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -173,7 +183,7 @@ const CreatePromptForm = ({
|
|||
</h2>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<VariablesDropdown fieldName="prompt" />
|
||||
<VariablesDropdown fieldName="prompt" portal={!isDialog} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-medium p-3 sm:p-4">
|
||||
|
|
@ -186,7 +196,7 @@ const CreatePromptForm = ({
|
|||
<TextareaAutosize
|
||||
{...field}
|
||||
className="w-full resize-none overflow-y-auto bg-transparent font-mono text-sm leading-relaxed text-text-primary placeholder:text-text-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary sm:text-base"
|
||||
minRows={4}
|
||||
minRows={isDialog ? 10 : 4}
|
||||
maxRows={16}
|
||||
tabIndex={0}
|
||||
placeholder={localize('com_ui_prompt_input')}
|
||||
|
|
@ -210,25 +220,29 @@ const CreatePromptForm = ({
|
|||
<Description
|
||||
onValueChange={(value) => methods.setValue('oneliner', value)}
|
||||
tabIndex={0}
|
||||
labelBgClassName={labelBgClassName}
|
||||
/>
|
||||
<Command
|
||||
onValueChange={(value) => methods.setValue('command', value)}
|
||||
tabIndex={0}
|
||||
labelBgClassName={labelBgClassName}
|
||||
/>
|
||||
<Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={0} />
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="submit"
|
||||
aria-label={localize('com_ui_create_prompt')}
|
||||
className={cn(
|
||||
'w-full sm:w-auto',
|
||||
(!isDirty || isSubmitting || !isValid) && 'opacity-50',
|
||||
)}
|
||||
className={cn('w-full gap-2 sm:w-auto', isBlocked && 'opacity-50')}
|
||||
tabIndex={0}
|
||||
type="submit"
|
||||
aria-disabled={!isDirty || isSubmitting || !isValid || undefined}
|
||||
aria-disabled={isBlocked || undefined}
|
||||
aria-busy={isCreating || undefined}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (!isDirty || isSubmitting || !isValid) {
|
||||
if (isBlocked) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isCreating && <Spinner className="size-4" aria-hidden="true" />}
|
||||
{localize('com_ui_create_prompt')}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ const HeaderActions = React.memo(
|
|||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CategorySelector
|
||||
className="h-10"
|
||||
currentCategory={groupCategory}
|
||||
onValueChange={canEdit ? onCategoryChange : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ export { PromptName, Command, Description, CategorySelector } from './fields';
|
|||
export { PreviewPrompt, DeleteVersion, VariableDialog, SharePrompt } from './dialogs';
|
||||
export { PromptForm, CreatePromptForm, VariableForm, PromptLabelsForm } from './forms';
|
||||
export { PromptEditor, VariablesDropdown, CodeVariableGfm, PromptVariableGfm } from './editor';
|
||||
export { PromptDetails, PromptVariables, PromptVersions, EmptyPromptPreview } from './display';
|
||||
export {
|
||||
GroupSidePanel as PromptSidePanel,
|
||||
PromptsAccordion,
|
||||
FilterPrompts,
|
||||
PanelNavigation,
|
||||
} from './sidebar';
|
||||
export { PromptDetails, PromptVariables, PromptVersions } from './display';
|
||||
export { GroupSidePanel as PromptSidePanel, PromptsAccordion, FilterPrompts } from './sidebar';
|
||||
export { List as PromptGroupsList, ChatGroupItem, ListCard, NoPromptGroup } from './lists';
|
||||
export { CreatePromptButton, AdminSettings, AlwaysMakeProd, AutoSendPrompt } from './buttons';
|
||||
|
|
|
|||
|
|
@ -1,48 +1,23 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useParams, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import EmptyPromptPreview from '../display/EmptyPromptPreview';
|
||||
import CreatePromptForm from '../forms/CreatePromptForm';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import PromptForm from '../forms/PromptForm';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
|
||||
export default function InlinePromptsView() {
|
||||
const { promptId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isNew = promptId === undefined;
|
||||
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const handleCreateSuccess = useCallback(
|
||||
(groupId: string) => {
|
||||
navigate(`/prompts/${groupId}`, { replace: true });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <Navigate to="/c/new" replace />;
|
||||
}
|
||||
|
||||
if (isNew && !hasCreateAccess) {
|
||||
return <EmptyPromptPreview />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
|
||||
{isNew ? (
|
||||
<CreatePromptForm onSuccess={handleCreateSuccess} />
|
||||
) : (
|
||||
<PromptForm promptId={promptId} />
|
||||
)}
|
||||
<PromptForm promptId={promptId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function ChatGroupItem({
|
|||
isStatus: true,
|
||||
});
|
||||
if (!isChatRoute && params.promptId === group._id) {
|
||||
navigate(`${PROMPT_PATH}/new`, { replace: true });
|
||||
navigate(PROMPT_PATH, { replace: true });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
|
|
|
|||
|
|
@ -1,44 +1,42 @@
|
|||
import { FileText } from 'lucide-react';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import ChatGroupItem from './ChatGroupItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function List({
|
||||
groups = [],
|
||||
isLoading,
|
||||
isChatRoute,
|
||||
}: {
|
||||
groups?: TPromptGroup[];
|
||||
isLoading: boolean;
|
||||
isChatRoute?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const renderContent = () => {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="my-2 flex flex-col items-center justify-center rounded-lg border border-border-medium bg-transparent p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_prompts_title')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{localize('com_ui_add_first_prompt')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return groups.map((group) => (
|
||||
<ChatGroupItem key={group._id} group={group} isChatRoute={isChatRoute} />
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex-grow" aria-label={localize('com_ui_prompt_groups')}>
|
||||
<div>
|
||||
{isLoading &&
|
||||
Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="mb-1.5 h-[72px] w-full rounded-xl" />
|
||||
))}
|
||||
{!isLoading && groups.length === 0 && (
|
||||
<div className="my-2 flex flex-col items-center justify-center rounded-lg border border-border-medium bg-transparent p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_prompts_title')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">
|
||||
{localize('com_ui_add_first_prompt')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<ChatGroupItem key={group._id} group={group} isChatRoute={isChatRoute} />
|
||||
))}
|
||||
</div>
|
||||
<div>{renderContent()}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
12
client/src/components/Prompts/lists/PromptGroupSkeleton.tsx
Normal file
12
client/src/components/Prompts/lists/PromptGroupSkeleton.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Skeleton } from '@librechat/client';
|
||||
|
||||
/** Mirrors ListCard's stacked name and one-liner */
|
||||
export default function PromptGroupSkeleton({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Skeleton key={i} className="mb-1.5 h-[72px] w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Sidebar, TooltipAnchor } from '@librechat/client';
|
||||
import { Button, Sidebar, Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import type { PromptGroupListResponse } from 'librechat-data-provider';
|
||||
import PromptGroupSkeleton from '../lists/PromptGroupSkeleton';
|
||||
import { useLocalize, useNavScrolling } from '~/hooks';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import PanelNavigation from './PanelNavigation';
|
||||
import { PanelContent } from '~/components/ui';
|
||||
import List from '../lists/List';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function GroupSidePanel({
|
||||
children,
|
||||
footer,
|
||||
className = '',
|
||||
closePanelRef,
|
||||
onClose,
|
||||
isChatRoute: isChatRouteProp,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
/** Rendered below the list, outside the scroll area, so it stays visible */
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
closePanelRef?: React.RefObject<HTMLButtonElement>;
|
||||
onClose?: () => void;
|
||||
|
|
@ -24,10 +31,21 @@ export default function GroupSidePanel({
|
|||
const isChatRoute = isChatRouteProp ?? location.pathname?.startsWith('/c/') ?? false;
|
||||
|
||||
const context = usePromptGroupsContext();
|
||||
|
||||
/** A collapsed sidebar keeps this panel mounted, so stop draining pages into it */
|
||||
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
|
||||
|
||||
const { containerRef } = useNavScrolling<PromptGroupListResponse>({
|
||||
nextCursor: context?.nextCursor,
|
||||
isFetchingNext: context?.isFetchingNextPage ?? false,
|
||||
fetchNextPage: context?.fetchNextPage,
|
||||
enabled: sidebarExpanded,
|
||||
});
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = context;
|
||||
const { promptGroups, groupsQuery, isFetchingNextPage } = context;
|
||||
|
||||
return (
|
||||
<div id="prompts-panel" className={cn('flex h-full w-full flex-col', className)}>
|
||||
|
|
@ -53,31 +71,27 @@ export default function GroupSidePanel({
|
|||
</div>
|
||||
)}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="scrollbar-gutter-stable flex h-full min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden pl-3 pr-1 text-text-primary">
|
||||
<div className="shrink-0 space-y-2">{children}</div>
|
||||
<List
|
||||
groups={promptGroups}
|
||||
isLoading={!!groupsQuery.isLoading}
|
||||
isChatRoute={isChatRoute}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none inset-x-0 bottom-0 bg-gradient-to-t from-surface-primary-alt from-60% to-transparent px-3 pb-2',
|
||||
)}
|
||||
{/* Sticky header: filter and toggles stay put while the list scrolls */}
|
||||
<div className="shrink-0 space-y-2 px-3 pb-2 pt-2 text-text-primary">{children}</div>
|
||||
<PanelContent
|
||||
ref={containerRef}
|
||||
isLoading={!!groupsQuery.isLoading}
|
||||
skeleton={<PromptGroupSkeleton />}
|
||||
className="scrollbar-gutter-stable flex flex-col gap-2 overflow-x-hidden pl-3 pr-1 text-text-primary"
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<PanelNavigation
|
||||
onPrevious={prevPage}
|
||||
onNext={nextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
isLoading={groupsQuery.isFetching}
|
||||
isChatRoute={isChatRoute}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<List groups={promptGroups} isChatRoute={isChatRoute} />
|
||||
{/* Appending the next page, so the loaded rows stay put */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex shrink-0 justify-center py-2">
|
||||
<Spinner className="size-4" />
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{localize('com_ui_loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</PanelContent>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 pl-1">{children}</div>
|
||||
<nav className="flex items-center gap-2" aria-label={localize('com_ui_pagination')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={!hasPreviousPage || isLoading}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={!hasNextPage || isLoading}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PanelNavigation);
|
||||
|
|
@ -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 (
|
||||
<PromptSidePanel className="space-y-2 pt-2">
|
||||
<PromptSidePanel
|
||||
className="space-y-2 pt-2"
|
||||
footer={
|
||||
user?.role === SystemRoles.ADMIN ? (
|
||||
<PanelFooter>
|
||||
<AdminSettings />
|
||||
</PanelFooter>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FilterPrompts />
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
<AutoSendPrompt />
|
||||
</PromptSidePanel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-2" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded-lg border border-border-light px-3 py-2.5"
|
||||
>
|
||||
<Skeleton className="size-4 shrink-0 rounded" />
|
||||
<Skeleton className="h-4 min-w-0 flex-1 rounded" />
|
||||
<Skeleton className="h-4 w-8 shrink-0 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="h-auto max-w-full overflow-x-visible pt-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-2">
|
||||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<BookmarkTable />
|
||||
<BookmarkTable isLoading={isLoading} />
|
||||
</BookmarkContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ConversationTagsResponse>([]);
|
||||
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 (
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<div role="region" aria-label={localize('com_ui_bookmarks')} className="space-y-2 px-3 pb-3">
|
||||
{/* Header: Filter + Create Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
role="region"
|
||||
aria-label={localize('com_ui_bookmarks')}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{/* Sticky header: filter + create */}
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 pb-2">
|
||||
<FilterInput
|
||||
inputId="bookmarks-filter"
|
||||
label={localize('com_ui_bookmarks_filter')}
|
||||
|
|
@ -86,43 +81,18 @@ const BookmarkTable = () => {
|
|||
</BookmarkEditDialog>
|
||||
</div>
|
||||
|
||||
{/* Bookmark List */}
|
||||
<BookmarkList
|
||||
bookmarks={currentRows}
|
||||
moveRow={moveRow}
|
||||
isFiltered={searchQuery.length > 0}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredRows.length > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div className="whitespace-nowrap text-sm" aria-live="polite">
|
||||
{pageIndex + 1} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
|
||||
disabled={pageIndex + 1 >= totalPages}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Only the list scrolls */}
|
||||
<PanelContent
|
||||
isLoading={isLoading}
|
||||
skeleton={<BookmarkCardSkeleton />}
|
||||
className="px-3 pb-3"
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={filteredRows}
|
||||
moveRow={moveRow}
|
||||
isFiltered={searchQuery.length > 0}
|
||||
/>
|
||||
</PanelContent>
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-auto w-full flex-col px-3 pb-3 pt-2">
|
||||
<div role="region" aria-label={localize('com_ui_mcp_servers')} className="space-y-2">
|
||||
{/* Toolbar: Search + Add Button */}
|
||||
<div
|
||||
role="region"
|
||||
aria-label={localize('com_ui_mcp_servers')}
|
||||
className="flex h-full w-full flex-col overflow-hidden pt-2"
|
||||
>
|
||||
{/* Sticky header: Search + Add Button */}
|
||||
<div className="shrink-0 px-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterInput
|
||||
inputId="mcp-filter"
|
||||
|
|
@ -74,26 +81,29 @@ export default function MCPBuilderPanel() {
|
|||
</MCPServerDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server Cards List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spinner className="size-6" aria-label={localize('com_ui_loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<MCPServerList
|
||||
servers={filteredServers}
|
||||
getServerStatusIconProps={getServerStatusIconProps}
|
||||
isFiltered={searchQuery.trim().length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Config Dialog for custom user vars */}
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
|
||||
{/* Admin Settings Section */}
|
||||
<MCPAdminSettings />
|
||||
</div>
|
||||
|
||||
{/* Only the list scrolls */}
|
||||
<PanelContent
|
||||
isLoading={isLoading}
|
||||
skeleton={<MCPServerCardSkeleton />}
|
||||
className="px-3 pb-3"
|
||||
>
|
||||
<MCPServerList
|
||||
servers={filteredServers}
|
||||
getServerStatusIconProps={getServerStatusIconProps}
|
||||
isFiltered={searchQuery.trim().length > 0}
|
||||
/>
|
||||
</PanelContent>
|
||||
|
||||
{/* Config Dialog for custom user vars */}
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<PanelFooter>
|
||||
<MCPAdminSettings />
|
||||
</PanelFooter>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-2" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-lg border border-border-light px-3 py-2.5"
|
||||
>
|
||||
<Skeleton className="size-8 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<Skeleton className="h-3 w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof useMCPServerForm>;
|
||||
|
|
@ -37,9 +37,11 @@ export default function MCPServerForm({ formHook }: MCPServerFormProps) {
|
|||
<FormProvider {...methods}>
|
||||
<div className="space-y-4 px-1 py-1">
|
||||
<BasicInfoSection />
|
||||
{/* `display: contents` would drop the margin `space-y-4` puts on this fieldset,
|
||||
collapsing the gap above the first section to zero */}
|
||||
<fieldset
|
||||
disabled={isOboLockedReadOnly}
|
||||
className="contents space-y-4"
|
||||
className="space-y-4"
|
||||
aria-disabled={isOboLockedReadOnly}
|
||||
>
|
||||
<ConnectionSection />
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
|
||||
|
|
@ -43,31 +43,27 @@ jest.mock('~/hooks', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
Checkbox: ({
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('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<HTMLInputElement>) => onCheckedChange(event.target.checked),
|
||||
...props,
|
||||
}),
|
||||
Label: ({ children, ...props }: { children: ReactNode }) =>
|
||||
React.createElement('label', props, children),
|
||||
};
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => onCheckedChange(event.target.checked),
|
||||
...props,
|
||||
}),
|
||||
Label: ({ children, ...props }: { children: ReactNode }) =>
|
||||
React.createElement('label', props, children),
|
||||
};
|
||||
});
|
||||
|
||||
function createDefaultValues(): MCPServerFormData {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-2" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border-light px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-baseline gap-2">
|
||||
<Skeleton className="h-4 flex-1 rounded" />
|
||||
<Skeleton className="h-3 w-20 shrink-0 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasReadAccess) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-auto w-full flex-col px-3 pb-3 pt-2">
|
||||
<div role="region" aria-label={localize('com_ui_memories')} className="space-y-2">
|
||||
<div
|
||||
role="region"
|
||||
aria-label={localize('com_ui_memories')}
|
||||
className="flex h-full w-full flex-col overflow-hidden pt-2"
|
||||
>
|
||||
{/* Sticky header: filter, partition, usage + toggle */}
|
||||
<div className="shrink-0 space-y-2 px-3 pb-2">
|
||||
{/* Header: Filter + Create Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterInput
|
||||
|
|
@ -211,14 +199,14 @@ export default function MemoryPanel() {
|
|||
)}
|
||||
|
||||
{/* Controls: Usage Badge + Memory Toggle */}
|
||||
{(memData?.tokenLimit != null || hasOptOutAccess) && (
|
||||
{(showUsageBadge || hasOptOutAccess) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Usage Badge */}
|
||||
{memData?.tokenLimit != null && (
|
||||
{showUsageBadge && (
|
||||
<MemoryUsageBadge
|
||||
percentage={memData.usagePercentage ?? 0}
|
||||
tokenLimit={memData.tokenLimit}
|
||||
totalTokens={memData.totalTokens}
|
||||
percentage={memData?.usagePercentage ?? 0}
|
||||
tokenLimit={tokenLimit}
|
||||
totalTokens={memData?.totalTokens ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -227,7 +215,10 @@ export default function MemoryPanel() {
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`ml-auto ${referenceSavedMemories ? 'bg-surface-hover hover:bg-surface-hover' : ''}`}
|
||||
className={cn(
|
||||
showUsageBadge ? 'ml-auto' : 'w-full',
|
||||
referenceSavedMemories && 'bg-surface-hover hover:bg-surface-hover',
|
||||
)}
|
||||
onClick={() => handleMemoryToggle(!referenceSavedMemories)}
|
||||
aria-label={localize('com_ui_use_memory')}
|
||||
aria-pressed={referenceSavedMemories}
|
||||
|
|
@ -238,56 +229,29 @@ export default function MemoryPanel() {
|
|||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
aria-label={localize('com_ui_use_memory')}
|
||||
className="pointer-events-none mr-2"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
{localize('com_ui_use_memory')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memory List */}
|
||||
{/* Only the list scrolls */}
|
||||
<PanelContent isLoading={isLoading} skeleton={<MemoryCardSkeleton />} className="px-3 pb-3">
|
||||
<MemoryList
|
||||
memories={currentRows}
|
||||
memories={filteredMemories}
|
||||
hasUpdateAccess={hasUpdateAccess}
|
||||
isFiltered={searchQuery.length > 0}
|
||||
/>
|
||||
</PanelContent>
|
||||
|
||||
{/* Footer: Admin Settings + Pagination */}
|
||||
{(user?.role === SystemRoles.ADMIN || filteredMemories.length > pageSize) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Admin Settings - Left */}
|
||||
{user?.role === SystemRoles.ADMIN ? <AdminSettings /> : <div />}
|
||||
|
||||
{/* Pagination - Right */}
|
||||
{filteredMemories.length > pageSize && (
|
||||
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div className="whitespace-nowrap text-sm" aria-live="polite">
|
||||
{pageIndex + 1} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
|
||||
disabled={pageIndex + 1 >= totalPages}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<PanelFooter>
|
||||
<AdminSettings />
|
||||
</PanelFooter>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TPreset | null>(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"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
<RotateCcw
|
||||
key={resetCount}
|
||||
className={cn(
|
||||
'h-4 w-4 motion-reduce:animate-none',
|
||||
resetCount > 0 && 'animate-reset-spin',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{localize('com_ui_reset_var', { 0: localize('com_ui_model_parameters') })}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<Option[]>(
|
||||
const createLabel = localize('com_ui_create_skill');
|
||||
|
||||
const items: MenuItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: WRITE,
|
||||
label: localize('com_ui_skill_write_instructions'),
|
||||
icon: <PenLine className="size-4 text-text-primary" />,
|
||||
onClick: () => setWriteOpen(true),
|
||||
icon: <PenLine className="icon-md" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: UPLOAD_SKILL,
|
||||
label: localize('com_ui_skill_upload'),
|
||||
icon: <Upload className="size-4 text-text-primary" />,
|
||||
onClick: () => setUploadOpen(true),
|
||||
icon: <Upload className="icon-md" aria-hidden="true" />,
|
||||
},
|
||||
],
|
||||
[localize],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((value: string) => {
|
||||
if (value === WRITE) {
|
||||
setWriteOpen(true);
|
||||
} else if (value === UPLOAD_SKILL) {
|
||||
setUploadOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
value=""
|
||||
onChange={handleSelect}
|
||||
options={options}
|
||||
className="shrink-0 rounded-lg bg-transparent"
|
||||
icon={<Plus className="size-5" />}
|
||||
ariaLabel={localize('com_ui_create_skill')}
|
||||
iconOnly
|
||||
<DropdownPopup
|
||||
gutter={2}
|
||||
menuId="create-skill-menu"
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
unmountOnHide={true}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={createLabel}
|
||||
side="bottom"
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={createLabel}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
<CreateSkillDialog isOpen={writeOpen} setIsOpen={setWriteOpen} />
|
||||
<UploadSkillDialog isOpen={uploadOpen} setIsOpen={setUploadOpen} />
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export default function CreateSkillDialog({
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="submit"
|
||||
disabled={submitDisabled}
|
||||
className={cn(submitDisabled && 'opacity-50')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,43 +12,39 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const ReactDOM = jest.requireActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) =>
|
||||
React.createElement('button', props, children),
|
||||
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
(props, ref) => React.createElement('input', { ...props, ref }),
|
||||
),
|
||||
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) =>
|
||||
React.createElement('label', props, children),
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement(React.Fragment, null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
ReactDOM.createPortal(React.createElement('div', null, children), globalThis.document.body),
|
||||
TextareaAutosize: React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}
|
||||
>(({ minRows: _minRows, maxRows: _maxRows, ...props }, ref) =>
|
||||
React.createElement('textarea', { ...props, ref }),
|
||||
),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const ReactDOM = jest.requireActual<typeof import('react-dom')>('react-dom');
|
||||
return {
|
||||
Button: ({
|
||||
children,
|
||||
variant: _variant,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) =>
|
||||
React.createElement('button', props, children),
|
||||
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
(props, ref) => React.createElement('input', { ...props, ref }),
|
||||
),
|
||||
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) =>
|
||||
React.createElement('label', props, children),
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement(React.Fragment, null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
ReactDOM.createPortal(React.createElement('div', null, children), globalThis.document.body),
|
||||
TextareaAutosize: React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
}
|
||||
>(({ minRows: _minRows, maxRows: _maxRows, ...props }, ref) =>
|
||||
React.createElement('textarea', { ...props, ref }),
|
||||
),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useCreateSkillMutation: () => ({
|
||||
|
|
|
|||
|
|
@ -18,23 +18,19 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
OGDialogContent: ({ children }: { children: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
|
||||
useToastContext: () => ({
|
||||
showToast: mockShowToast,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFileConfig: ({ select }: { select?: (data: FileConfigInput | undefined) => unknown }) => ({
|
||||
|
|
|
|||
|
|
@ -6,23 +6,15 @@ import SkillsView from '../SkillsView';
|
|||
const mockUseHasAccess = jest.fn((..._args: unknown[]) => true);
|
||||
const mockUseMediaQuery = jest.fn((_query: string) => false);
|
||||
|
||||
jest.mock(
|
||||
'librechat-data-provider',
|
||||
() => ({
|
||||
PermissionTypes: { SKILLS: 'skills' },
|
||||
Permissions: { USE: 'use', CREATE: 'create' },
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
PermissionTypes: { SKILLS: 'skills' },
|
||||
Permissions: { USE: 'use', CREATE: 'create' },
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@librechat/client',
|
||||
() => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
useMediaQuery: (query: string) => mockUseMediaQuery(query),
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
useMediaQuery: (query: string) => mockUseMediaQuery(query),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Menus/OpenSidebar', () => ({
|
||||
__esModule: true,
|
||||
|
|
|
|||
|
|
@ -1,43 +1,37 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import type { TSkill } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { TSkillSummary } from 'librechat-data-provider';
|
||||
import SkillListItem from './SkillListItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SkillListProps {
|
||||
skills: TSkill[];
|
||||
isLoading: boolean;
|
||||
skills: TSkillSummary[];
|
||||
activeSkillId?: string;
|
||||
sectionOpen: boolean;
|
||||
onSectionOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Collapsible skill list. Active/inactive toggling lives in the detail view. */
|
||||
export default function SkillList({ skills, isLoading, activeSkillId }: SkillListProps) {
|
||||
export default function SkillList({
|
||||
skills,
|
||||
activeSkillId,
|
||||
sectionOpen,
|
||||
onSectionOpenChange,
|
||||
}: SkillListProps) {
|
||||
const localize = useLocalize();
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeFile = searchParams.get('file');
|
||||
const [sectionOpen, setSectionOpen] = useState(true);
|
||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(activeSkillId ?? null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-2 pt-2">
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSectionOpen((prev) => !prev)}
|
||||
onClick={() => onSectionOpenChange(!sectionOpen)}
|
||||
className="flex cursor-pointer items-center gap-1.5"
|
||||
aria-expanded={sectionOpen}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { memo, useState, useMemo, useCallback } from 'react';
|
||||
import { ScrollText, ChevronDown, ChevronRight, Folder, Pin } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FixedSizeTree } from 'react-vtree';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ScrollText, ChevronDown, ChevronRight, Folder, Pin } from 'lucide-react';
|
||||
import type { FixedSizeNodeData, TreeWalkerValue, TreeWalker } from 'react-vtree';
|
||||
import type { TSkill, TSkillFile } from 'librechat-data-provider';
|
||||
import type { TSkillSummary, TSkillFile } from 'librechat-data-provider';
|
||||
import { useListSkillFilesQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SkillListItemProps {
|
||||
skill: TSkill;
|
||||
skill: TSkillSummary;
|
||||
isActive: boolean;
|
||||
isExpanded: boolean;
|
||||
activeFile: string | null;
|
||||
|
|
|
|||
12
client/src/components/Skills/lists/SkillListSkeleton.tsx
Normal file
12
client/src/components/Skills/lists/SkillListSkeleton.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Skeleton } from '@librechat/client';
|
||||
|
||||
/** Mirrors SkillListItem's compact single-line rows */
|
||||
export default function SkillListSkeleton({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-2 pt-2" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { FilterInput } from '@librechat/client';
|
||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { AdminSettings, CreateSkillMenu } from '~/components/Skills/buttons';
|
||||
import { useHasAccess, useAuthContext, useLocalize } from '~/hooks';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { CreateSkillMenu } from '~/components/Skills/buttons';
|
||||
import { useHasAccess, useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function FilterSkills({
|
||||
|
|
@ -15,7 +15,6 @@ export default function FilterSkills({
|
|||
className?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.CREATE,
|
||||
|
|
@ -33,11 +32,6 @@ export default function FilterSkills({
|
|||
/>
|
||||
{hasCreateAccess && <CreateSkillMenu />}
|
||||
</div>
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<AdminSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-auto w-full flex-col">
|
||||
<SkillsSidePanel className="h-auto border-r-0" />
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<SkillsSidePanel className="min-h-0 flex-1 border-r-0" />
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<div className="flex w-full items-center justify-end px-4 pb-2">
|
||||
<PanelFooter>
|
||||
<AdminSettings />
|
||||
</div>
|
||||
</PanelFooter>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<TSkillListResponse>({
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -44,60 +53,35 @@ export default function SkillsSidePanel({ className }: SkillsSidePanelProps) {
|
|||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header — title+icons or inline search input */}
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
{searchOpen ? (
|
||||
<>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseSearch}
|
||||
className="ml-2 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="truncate text-lg font-bold text-text-primary">
|
||||
{localize('com_ui_skills')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={localize('com_ui_search')}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
{hasCreateAccess && <CreateSkillMenu />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FilterSkills
|
||||
className="shrink-0 px-4 pb-2 pt-3"
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Skill list */}
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
{/* Only the list scrolls */}
|
||||
<PanelContent
|
||||
ref={containerRef}
|
||||
isLoading={listQuery.isLoading}
|
||||
skeleton={<SkillListSkeleton />}
|
||||
className="px-4"
|
||||
>
|
||||
<SkillListPanel
|
||||
skills={skills as unknown as import('librechat-data-provider').TSkill[]}
|
||||
isLoading={listQuery.isLoading}
|
||||
skills={skills}
|
||||
activeSkillId={activeSkillId}
|
||||
sectionOpen={sectionOpen}
|
||||
onSectionOpenChange={setSectionOpen}
|
||||
/>
|
||||
</div>
|
||||
{/* Appending the next page, so the loaded rows stay put */}
|
||||
{listQuery.isFetchingNextPage && (
|
||||
<div className="flex shrink-0 justify-center py-2">
|
||||
<Spinner className="size-4" />
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{localize('com_ui_loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</PanelContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof import('react')>('react');
|
||||
const PanelContent = ReactModule.forwardRef<HTMLDivElement, { children?: React.ReactNode }>(
|
||||
({ children }, ref) => <div ref={ref}>{children}</div>,
|
||||
);
|
||||
return { PanelContent };
|
||||
});
|
||||
|
||||
jest.mock('../FilterSkills', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div />,
|
||||
}));
|
||||
|
||||
jest.mock('../../lists/SkillListItem', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe('SkillsSidePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('disables automatic pagination while My Skills is collapsed', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SkillsSidePanel />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string[]>([]);
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
|
|
@ -63,7 +62,6 @@ const ConversationsSection = memo(() => {
|
|||
const conversationsRef = useRef<List | null>(null);
|
||||
|
||||
const { moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { SetterOrUpdater } from 'recoil';
|
|||
*/
|
||||
const streamTickAtom = atom<number>({ 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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
60
client/src/components/ui/PanelContent.tsx
Normal file
60
client/src/components/ui/PanelContent.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{localize('com_ui_loading')}
|
||||
</span>
|
||||
{skeleton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (isEmpty === true && empty != null) {
|
||||
return empty;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-busy={isLoading}
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto', className)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PanelContent.displayName = 'PanelContent';
|
||||
|
||||
export default PanelContent;
|
||||
25
client/src/components/ui/PanelFooter.tsx
Normal file
25
client/src/components/ui/PanelFooter.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-end gap-2 border-t border-border-light bg-surface-primary-alt px-3 py-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
client/src/components/ui/__tests__/PanelContent.spec.tsx
Normal file
80
client/src/components/ui/__tests__/PanelContent.spec.tsx
Normal file
|
|
@ -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 = <div data-testid="skeleton" aria-hidden="true" />;
|
||||
|
||||
test('announces loading while the skeleton stands in for the rows', () => {
|
||||
render(
|
||||
<PanelContent isLoading={true} skeleton={skeleton}>
|
||||
<span data-testid="rows" />
|
||||
</PanelContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rows')).not.toBeInTheDocument();
|
||||
|
||||
/** The skeleton is aria-hidden, so the announcement has to come from elsewhere */
|
||||
const announcement = screen.getByText('Loading...');
|
||||
expect(announcement).toHaveClass('sr-only');
|
||||
expect(announcement).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
test('marks the scroll container busy only while loading', () => {
|
||||
const { container, rerender } = render(<PanelContent isLoading={true} skeleton={skeleton} />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-busy', 'true');
|
||||
|
||||
rerender(<PanelContent isLoading={false} skeleton={skeleton} />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-busy', 'false');
|
||||
});
|
||||
|
||||
test('renders children once loaded', () => {
|
||||
render(
|
||||
<PanelContent isLoading={false} skeleton={skeleton}>
|
||||
<span data-testid="rows" />
|
||||
</PanelContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rows')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the empty state instead of children when the list is empty', () => {
|
||||
render(
|
||||
<PanelContent
|
||||
isLoading={false}
|
||||
isEmpty={true}
|
||||
skeleton={skeleton}
|
||||
empty={<span data-testid="empty" />}
|
||||
>
|
||||
<span data-testid="rows" />
|
||||
</PanelContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('rows')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to children when empty but given no empty state', () => {
|
||||
render(
|
||||
<PanelContent isLoading={false} isEmpty={true} skeleton={skeleton}>
|
||||
<span data-testid="rows" />
|
||||
</PanelContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('rows')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('forwards its ref to the scroll container so panels can fetch on scroll', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const { container } = render(
|
||||
<PanelContent ref={ref} isLoading={false} skeleton={skeleton} className="px-4" />,
|
||||
);
|
||||
|
||||
expect(ref.current).toBe(container.firstChild);
|
||||
expect(ref.current).toHaveClass('overflow-y-auto', 'px-4');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
export { Button } from '@librechat/client';
|
||||
export { default as Collapse } from './Collapse';
|
||||
export { default as PanelFooter } from './PanelFooter';
|
||||
export { default as PanelContent } from './PanelContent';
|
||||
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
|
||||
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
|
||||
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';
|
||||
|
|
|
|||
|
|
@ -1,64 +1,76 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { FetchNextPageOptions, InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
|
||||
export default function useNavScrolling<TData>({
|
||||
nextCursor,
|
||||
isFetchingNext,
|
||||
setShowLoading,
|
||||
fetchNextPage,
|
||||
enabled = true,
|
||||
}: {
|
||||
nextCursor?: string | null;
|
||||
isFetchingNext: boolean;
|
||||
setShowLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fetchNextPage?: (
|
||||
options?: FetchNextPageOptions | undefined,
|
||||
) => Promise<InfiniteQueryObserverResult<TData, unknown>>;
|
||||
/** Set false while the list is hidden or collapsed so it stops draining pages */
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const scrollPositionRef = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stateRef = useRef({ nextCursor, isFetchingNext, enabled });
|
||||
stateRef.current = { nextCursor, isFetchingNext, enabled };
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchNext = useCallback(
|
||||
throttle(
|
||||
() => {
|
||||
if (fetchNextPage) {
|
||||
return fetchNextPage();
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
750,
|
||||
{ leading: true },
|
||||
),
|
||||
[fetchNextPage],
|
||||
const maybeFetchNext = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const { nextCursor: cursor, isFetchingNext: fetching, enabled: isEnabled } = stateRef.current;
|
||||
if (!container || !isEnabled || cursor == null || fetching || !fetchNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = container;
|
||||
if (clientHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** A list too short to scroll never fires a scroll event, so fill it first */
|
||||
const needsFill = scrollHeight <= clientHeight;
|
||||
const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97;
|
||||
if (needsFill || nearBottomOfList) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage]);
|
||||
|
||||
const throttledFetchNext = useMemo(
|
||||
() => throttle(maybeFetchNext, 750, { leading: true }),
|
||||
[maybeFetchNext],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, clientHeight, scrollHeight } = containerRef.current;
|
||||
const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97;
|
||||
|
||||
if (nearBottomOfList && nextCursor != null && !isFetchingNext) {
|
||||
setShowLoading(true);
|
||||
fetchNext();
|
||||
} else {
|
||||
setShowLoading(false);
|
||||
}
|
||||
}
|
||||
}, [nextCursor, isFetchingNext, fetchNext, setShowLoading]);
|
||||
useEffect(() => throttledFetchNext.cancel, [throttledFetchNext]);
|
||||
|
||||
/**
|
||||
* The resize observer covers the case where the panel has no layout yet
|
||||
* (`clientHeight` of 0) and only gets one once it is expanded or revealed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', throttledFetchNext, { passive: true });
|
||||
const observer = new ResizeObserver(() => throttledFetchNext());
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
container.removeEventListener('scroll', throttledFetchNext);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [handleScroll]);
|
||||
}, [throttledFetchNext]);
|
||||
|
||||
useEffect(() => {
|
||||
throttledFetchNext();
|
||||
}, [nextCursor, enabled, throttledFetchNext]);
|
||||
|
||||
const moveToTop = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
|
@ -8,12 +8,6 @@ export default function usePromptGroupsNav(hasAccess = true) {
|
|||
const [category] = useRecoilState(store.promptsCategory);
|
||||
const [name, setName] = useRecoilState(store.promptsName);
|
||||
|
||||
// Track current page index and cursor history
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const cursorHistoryRef = useRef<Array<string | null>>([null]); // Start with null for first page
|
||||
|
||||
const prevFiltersRef = useRef({ name, category });
|
||||
|
||||
const groupsQuery = usePromptGroupsInfiniteQuery(
|
||||
{
|
||||
name,
|
||||
|
|
@ -25,89 +19,29 @@ export default function usePromptGroupsNav(hasAccess = true) {
|
|||
},
|
||||
);
|
||||
|
||||
// Get the current page data
|
||||
const currentPageData = useMemo(() => {
|
||||
if (!hasAccess || !groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) {
|
||||
const promptGroups = useMemo(() => {
|
||||
if (!hasAccess || !groupsQuery.data?.pages) {
|
||||
return [];
|
||||
}
|
||||
return groupsQuery.data.pages.flatMap((page) => page.promptGroups ?? []);
|
||||
}, [hasAccess, groupsQuery.data?.pages]);
|
||||
|
||||
/** `useNavScrolling` stops fetching once this is null */
|
||||
const nextCursor = useMemo(() => {
|
||||
const pages = groupsQuery.data?.pages;
|
||||
if (!hasAccess || !pages?.length) {
|
||||
return null;
|
||||
}
|
||||
// Ensure we don't go out of bounds
|
||||
const pageIndex = Math.min(currentPageIndex, groupsQuery.data.pages.length - 1);
|
||||
return groupsQuery.data.pages[pageIndex];
|
||||
}, [hasAccess, groupsQuery.data?.pages, currentPageIndex]);
|
||||
|
||||
// Get prompt groups for current page
|
||||
const promptGroups = useMemo(() => {
|
||||
return currentPageData?.promptGroups || [];
|
||||
}, [currentPageData]);
|
||||
|
||||
// Calculate pagination state
|
||||
const hasNextPage = useMemo(() => {
|
||||
if (!currentPageData) return false;
|
||||
|
||||
// If we're not on the last loaded page, we have a next page
|
||||
if (currentPageIndex < (groupsQuery.data?.pages?.length || 0) - 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we're on the last loaded page, check if there are more from backend
|
||||
return currentPageData.has_more || false;
|
||||
}, [currentPageData, currentPageIndex, groupsQuery.data?.pages?.length]);
|
||||
|
||||
const hasPreviousPage = currentPageIndex > 0;
|
||||
const currentPage = currentPageIndex + 1;
|
||||
const totalPages = hasNextPage ? currentPage + 1 : currentPage;
|
||||
|
||||
// Navigate to next page
|
||||
const nextPage = useCallback(async () => {
|
||||
if (!hasAccess || !hasNextPage) return;
|
||||
|
||||
const nextPageIndex = currentPageIndex + 1;
|
||||
|
||||
// Check if we need to load more data
|
||||
if (nextPageIndex >= (groupsQuery.data?.pages?.length || 0)) {
|
||||
// We need to fetch the next page
|
||||
const result = await groupsQuery.fetchNextPage();
|
||||
if (result.isSuccess && result.data?.pages) {
|
||||
// Update cursor history with the cursor for the next page
|
||||
const lastPage = result.data.pages[result.data.pages.length - 2]; // Get the page before the newly fetched one
|
||||
if (lastPage?.after && !cursorHistoryRef.current.includes(lastPage.after)) {
|
||||
cursorHistoryRef.current.push(lastPage.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPageIndex(nextPageIndex);
|
||||
}, [hasAccess, currentPageIndex, hasNextPage, groupsQuery]);
|
||||
|
||||
// Navigate to previous page
|
||||
const prevPage = useCallback(() => {
|
||||
if (!hasAccess || !hasPreviousPage) return;
|
||||
setCurrentPageIndex(currentPageIndex - 1);
|
||||
}, [hasAccess, currentPageIndex, hasPreviousPage]);
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
|
||||
const filtersChanged =
|
||||
prevFiltersRef.current.name !== name || prevFiltersRef.current.category !== category;
|
||||
|
||||
if (filtersChanged) {
|
||||
setCurrentPageIndex(0);
|
||||
cursorHistoryRef.current = [null];
|
||||
prevFiltersRef.current = { name, category };
|
||||
}
|
||||
}, [hasAccess, name, category]);
|
||||
const lastPage = pages[pages.length - 1];
|
||||
return lastPage.has_more === true ? (lastPage.after ?? null) : null;
|
||||
}, [hasAccess, groupsQuery.data?.pages]);
|
||||
|
||||
return {
|
||||
promptGroups: hasAccess ? promptGroups : [],
|
||||
promptGroups,
|
||||
groupsQuery,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage: hasAccess && hasNextPage,
|
||||
hasPreviousPage: hasAccess && hasPreviousPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
nextCursor,
|
||||
fetchNextPage: groupsQuery.fetchNextPage,
|
||||
isFetchingNextPage: hasAccess ? groupsQuery.isFetchingNextPage : false,
|
||||
isFetching: hasAccess ? groupsQuery.isFetching : false,
|
||||
name,
|
||||
setName,
|
||||
|
|
|
|||
139
client/src/hooks/__tests__/useNavScrolling.spec.tsx
Normal file
139
client/src/hooks/__tests__/useNavScrolling.spec.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import useNavScrolling from '../Nav/useNavScrolling';
|
||||
|
||||
type Observed = { element: Element; callback: ResizeObserverCallback };
|
||||
|
||||
const observed: Observed[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(private callback: ResizeObserverCallback) {}
|
||||
observe(element: Element) {
|
||||
observed.push({ element, callback: this.callback });
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
/** Fakes the layout numbers jsdom always reports as 0 */
|
||||
function sizeContainer(
|
||||
container: HTMLDivElement,
|
||||
{ clientHeight, scrollHeight, scrollTop = 0 }: Record<string, number>,
|
||||
) {
|
||||
Object.defineProperty(container, 'clientHeight', { value: clientHeight, configurable: true });
|
||||
Object.defineProperty(container, 'scrollHeight', { value: scrollHeight, configurable: true });
|
||||
Object.defineProperty(container, 'scrollTop', { value: scrollTop, configurable: true });
|
||||
}
|
||||
|
||||
describe('useNavScrolling', () => {
|
||||
const originalResizeObserver = global.ResizeObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
observed.length = 0;
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
global.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
const setup = (
|
||||
props: Partial<Parameters<typeof useNavScrolling>[0]> = {},
|
||||
layout: Record<string, number> = { clientHeight: 500, scrollHeight: 200 },
|
||||
) => {
|
||||
const fetchNextPage = jest.fn().mockResolvedValue({});
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
sizeContainer(container, layout);
|
||||
|
||||
const rendered = renderHook((hookProps: Record<string, unknown>) => {
|
||||
const nav = useNavScrolling({
|
||||
nextCursor: 'cursor-1',
|
||||
isFetchingNext: false,
|
||||
fetchNextPage,
|
||||
...props,
|
||||
...hookProps,
|
||||
});
|
||||
/** Stands in for the ref React would attach before effects run */
|
||||
nav.containerRef.current = container;
|
||||
return nav;
|
||||
});
|
||||
return { ...rendered, fetchNextPage, container };
|
||||
};
|
||||
|
||||
it('fills a list that is too short to scroll', () => {
|
||||
const { rerender, fetchNextPage } = setup();
|
||||
|
||||
act(() => {
|
||||
rerender({ nextCursor: 'cursor-2' });
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not restart a fetch when loading finishes with the same cursor', () => {
|
||||
const { rerender, fetchNextPage } = setup();
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
rerender({ isFetchingNext: true });
|
||||
});
|
||||
act(() => {
|
||||
rerender({ isFetchingNext: false });
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fetch while the container has no layout yet', () => {
|
||||
const { rerender, fetchNextPage } = setup({}, { clientHeight: 0, scrollHeight: 0 });
|
||||
|
||||
act(() => {
|
||||
rerender({ nextCursor: 'cursor-2' });
|
||||
});
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries once the container gets laid out', () => {
|
||||
const { rerender, fetchNextPage, container } = setup({}, { clientHeight: 0, scrollHeight: 0 });
|
||||
|
||||
act(() => {
|
||||
rerender({ nextCursor: 'cursor-2' });
|
||||
});
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
|
||||
sizeContainer(container, { clientHeight: 500, scrollHeight: 200 });
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
observed.forEach(({ callback }) => callback([], {} as unknown as ResizeObserver));
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays put when disabled', () => {
|
||||
const { rerender, fetchNextPage } = setup({ enabled: false });
|
||||
|
||||
act(() => {
|
||||
rerender({ nextCursor: 'cursor-2', enabled: false });
|
||||
});
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops at the end of the list', () => {
|
||||
const { rerender, fetchNextPage } = setup({ nextCursor: null });
|
||||
|
||||
act(() => {
|
||||
rerender({ nextCursor: null });
|
||||
});
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1602,7 +1602,6 @@
|
|||
"com_ui_options": "options",
|
||||
"com_ui_output": "Output",
|
||||
"com_ui_page": "Page",
|
||||
"com_ui_pagination": "Pagination",
|
||||
"com_ui_parameters": "Parameters",
|
||||
"com_ui_path": "Path",
|
||||
"com_ui_people": "people",
|
||||
|
|
@ -1796,7 +1795,6 @@
|
|||
"com_ui_select_file": "Select a file",
|
||||
"com_ui_select_model": "Select a model",
|
||||
"com_ui_select_options": "Select options...",
|
||||
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
|
||||
"com_ui_select_project": "Select project",
|
||||
"com_ui_select_provider": "Select a provider",
|
||||
"com_ui_select_provider_first": "Select a provider first",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import DashboardRoute from './Layouts/Dashboard';
|
|||
|
||||
function PromptsRedirect() {
|
||||
const { '*': splat } = useParams();
|
||||
const target = splat ? `/prompts/${splat}` : '/prompts/new';
|
||||
/** Prompts are created from a dialog, so there is no "new" page to land on */
|
||||
const target = splat && splat !== 'new' ? `/prompts/${splat}` : '/c/new';
|
||||
return <Navigate to={target} replace={true} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
|
|||
import AgentMarketplace from '~/components/Agents/Marketplace';
|
||||
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
import WithRum from '~/lib/rum/WithRum';
|
||||
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||
import StartupLayout from './Layouts/Startup';
|
||||
import LoginLayout from './Layouts/Login';
|
||||
import dashboardRoutes from './Dashboard';
|
||||
import WithRum from '~/lib/rum/WithRum';
|
||||
import ShareRoute from './ShareRoute';
|
||||
import ChatRoute from './ChatRoute';
|
||||
import Search from './Search';
|
||||
|
|
@ -136,11 +136,12 @@ export const router = createBrowserRouter(
|
|||
},
|
||||
{
|
||||
path: 'prompts',
|
||||
element: <Navigate to="/prompts/new" replace={true} />,
|
||||
element: <Navigate to="/c/new" replace={true} />,
|
||||
},
|
||||
{
|
||||
/** Prompts are created from a dialog, so there is no "new" page to land on */
|
||||
path: 'prompts/new',
|
||||
lazy: loadInlinePromptsView,
|
||||
element: <Navigate to="/c/new" replace={true} />,
|
||||
},
|
||||
{
|
||||
path: 'prompts/:promptId',
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ module.exports = {
|
|||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0' },
|
||||
},
|
||||
'reset-spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(-360deg)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
|
|
@ -71,6 +75,7 @@ module.exports = {
|
|||
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||
'shortcut-shake': 'shortcut-shake 0.25s ease-in-out',
|
||||
'logo-blink': 'logo-blink 3s infinite',
|
||||
'reset-spin': 'reset-spin 500ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
},
|
||||
colors: createTailwindColors(),
|
||||
borderRadius: {
|
||||
|
|
|
|||
|
|
@ -138,15 +138,18 @@ test.describe('prompt manager', () => {
|
|||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await openPromptsPanel(page);
|
||||
|
||||
await page.getByRole('link', { name: 'Create Prompt' }).click();
|
||||
await expect(page).toHaveURL(/\/prompts\/new$/);
|
||||
await page.getByRole('button', { name: 'Create Prompt' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Prompt Name' }).fill(promptName);
|
||||
await page.getByRole('textbox', { name: 'Prompt text input field' }).fill(promptText);
|
||||
await page
|
||||
/** Prompts are created from a dialog rather than a dedicated page */
|
||||
const createDialog = page.getByRole('dialog');
|
||||
await expect(createDialog).toBeVisible();
|
||||
|
||||
await createDialog.getByRole('textbox', { name: 'Prompt Name' }).fill(promptName);
|
||||
await createDialog.getByRole('textbox', { name: 'Prompt text input field' }).fill(promptText);
|
||||
await createDialog
|
||||
.getByRole('textbox', { name: 'Optional: Enter a description to display for the prompt' })
|
||||
.fill(DESCRIPTION);
|
||||
await page
|
||||
await createDialog
|
||||
.getByRole('textbox', {
|
||||
name: 'Optional: Enter a command for the prompt or name will be used',
|
||||
})
|
||||
|
|
@ -161,7 +164,7 @@ test.describe('prompt manager', () => {
|
|||
response.status() < 300,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Prompt' }).click(),
|
||||
createDialog.getByRole('button', { name: 'Create Prompt' }).click(),
|
||||
]);
|
||||
const createdPrompt = (await createResponse.json()) as {
|
||||
group?: PromptGroup;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const CheckboxButton: React.ForwardRefExoticComponent<
|
|||
// Base styling from MultiSelect's selectClassName
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'rounded-full border border-border-medium text-sm font-medium',
|
||||
'size-9 p-2 transition-all md:w-full md:p-3',
|
||||
'size-9 max-w-fit p-2 transition-all md:w-full md:p-3',
|
||||
'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
|
||||
// Checked state styling
|
||||
|
|
|
|||
|
|
@ -137,16 +137,17 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
store={selectProps}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-between rounded-xl border border-border-light bg-surface-primary px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary',
|
||||
'relative inline-flex items-center justify-between rounded-xl border border-border-light bg-surface-primary py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface-primary disabled:hover:text-text-primary',
|
||||
iconOnly ? 'size-10' : 'w-fit gap-2',
|
||||
/** Horizontal padding would squeeze the icon, which flex-shrinks to fit */
|
||||
iconOnly ? 'size-10 justify-center px-0' : 'w-fit gap-2 px-3',
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className={cn('flex items-center gap-2', iconOnly ? 'shrink-0' : 'w-full')}>
|
||||
{icon}
|
||||
{!iconOnly && (
|
||||
<span className="block truncate">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue