🧭 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:
Marco Beretta 2026-08-09 05:15:46 +02:00 committed by GitHub
parent 667d97d668
commit 92d4705f79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 1501 additions and 923 deletions

View file

@ -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'],

View file

@ -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 = () => {

View file

@ -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),

View file

@ -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}

View file

@ -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" />}
/>
)

View file

@ -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" />}
/>
)}

View file

@ -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" />}
/>
)

View file

@ -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" />}
/>
)

View file

@ -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" />}
/>
)

View 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;

View file

@ -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 }),

View file

@ -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>

View file

@ -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');
});
});
});

View file

@ -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>
);
}

View 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>
);
}

View file

@ -31,7 +31,6 @@ const DeleteConfirmDialog = ({
<Button
variant="destructive"
size="icon"
className="size-9"
aria-label={localize('com_ui_delete')}
disabled={disabled}
onClick={(e) => {

View file

@ -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}
>

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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';

View file

@ -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();

View file

@ -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}`}
>

View file

@ -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}
/>
);
};

View file

@ -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>

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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);
}
});
});

View file

@ -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>

View file

@ -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}
/>

View file

@ -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';

View file

@ -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>
);
}

View file

@ -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: () => {

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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);

View file

@ -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>
);

View file

@ -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';

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 />

View file

@ -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 {

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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} />

View file

@ -202,6 +202,7 @@ export default function CreateSkillDialog({
</Button>
<Button
type="submit"
variant="submit"
disabled={submitDisabled}
className={cn(submitDisabled && 'opacity-50')}
>

View file

@ -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: () => ({

View file

@ -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 }) => ({

View file

@ -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,

View file

@ -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}
>

View file

@ -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;

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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.aistyle 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>
);
}

View file

@ -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 }),
);
});
});

View file

@ -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}

View file

@ -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,
);
});

View 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;

View 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>
);
}

View 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');
});
});

View file

@ -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';

View file

@ -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;

View file

@ -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,

View 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();
});
});

View file

@ -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",

View file

@ -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} />;
}

View file

@ -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',

View file

@ -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: {

View file

@ -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;

View file

@ -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

View file

@ -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">