🧑‍🎨 refactor: Prompts/Sidebar styles for improved UI Consistency (#12426)

* style(sidebar): polish button styles, icon sizes, and nav separator

- Match new chat button style to sidebar toggle

- Unify icon sizes and stroke weights across sidebar icons

- Add separator between new chat and nav links

feat(prompts): inline prompt editing with route-based navigation

- Show prompts dashboard inline when editing/creating from chat

- Use route-based navigation instead of state-driven view switching

- Strip inline view to form-only without duplicate sidebar

style(prompts): unify borders, backgrounds, and admin controls

- Unify borders to border-medium and remove opaque backgrounds

- Add admin/advanced controls to sidebar

- Match chat background, move advanced toggle to versions panel

refactor(prompts): consolidate list actions into dropdown with compact layout

- Unify prompt list to single ChatGroupItem with all actions

- Consolidate actions into dropdown menu

- Distinct dropdown icons with status icon tooltips

- Remove rename action, use Trash icon matching convo items

refactor(prompts): inline-edit title with click-to-edit and save indicator

- Click title text to enter edit mode with pencil icon on hover

- Clean title input with inline save status indicator

- Uniform h-9 header height, smaller title, icon-only save status

- Remove separate edit icon button and confirm/cancel controls

style(prompts): sticky click-to-edit pill with surface-primary background

fix: preserve AuthContext identity across Vite HMR updates

createContext() was re-executed on every HMR module replacement, creating
a new context object that disconnected the existing provider from its
consumers. useAuthContext() would throw because useContext(newContext)
returned undefined while the provider was still on the old context.

Stash the context object in import.meta.hot.data so it survives HMR
re-execution. Dead-code-eliminated in production builds.

feat: add advanced prompts editor toggle to Chat settings

- Add AdvancedPrompts switch in Settings > Chat that maps the
  promptsEditorMode enum (simple/advanced) to a boolean toggle
- Preserve existing behavior: switching to simple forces alwaysMakeProd
- Add focus styling to PromptName input (border-medium on focus,
  outline on focus-visible, no ring)
- Add localization keys: com_nav_advanced_prompts,
  com_nav_advanced_prompts_desc
- Various Prompts UI refinements

style(sidebar): update nav icons and reorder links for clarity

- Prompts: MessageSquareQuote → NotebookPen (distinct from chat bubble)
- Agent Builder: Blocks → Bot (no longer identical to Assistants)
- Memories: Database → BrainCircuit (brain + AI circuit)
- Parameters: Settings2 → SlidersHorizontal (tuning-specific)
- Reorder: Chat → Prompts → Agents → Assistants → Memories →
  Bookmarks → Files → Parameters → MCP (frequency and grouping)

refactor(prompts): remove AdvancedSwitch component

The simple/advanced toggle is now in Settings > Chat, making
the standalone AdvancedSwitch redundant. Remove the component,
its barrel exports, and all render sites (PromptForm,
DashBreadcrumb, PromptsView).

style(prompts): tighten panel layout and spacing to match sidebar conventions

- Remove top margin from form wrapper, add mt-2 to header row
- Reduce header gaps and remove flex-wrap for tighter alignment
- Shrink prompt title from text-lg/h-9 to text-base/h-8
- Add pl-2 inner padding to PromptName input and static button
- Compact versions panel and deploy button container padding
- Reduce editor header and content spacing
- Compact mobile versions panel header

fix(prompts): rebuild mobile versions panel with proper transitions

- Add background (bg-surface-primary-alt) and border-l so content
  doesn't bleed through
- Replace conditional render with CSS opacity/translate so both
  overlay fade-out and panel slide-out animate on close
- Use inert attribute to disable focus when panel is hidden
- Replace raw × character with X icon from lucide-react
- Use size-icon button variant for close instead of text sm
- Use Tailwind classes (w-80, translate-x-full) instead of inline
  style object for width and transform
- Remove unused sidePanelWidth constant
- Overlay stays in DOM with pointer-events-none when hidden

chore: remove stale tooling artifacts

Remove .bg-shell/manifest.json and .gsd symlink that were
accidentally committed — these are local dev tooling files.

fix: resolve review issues in PromptName and ChatGroupItem

- Fix Escape/onBlur race in PromptName via cancelledRef guard
- Clear existing timer before creating new one to prevent status flicker
- Guard delete mutation against empty group id
- Attach menuButtonRef to MenuButton for proper focus restoration after preview dialog closes

fix: address review findings across prompts UI and sidebar

PromptName:
- Fix Enter key double-save: set skipBlurRef before inline save to prevent
  onBlur from re-firing saveName on input unmount
- Add isError prop to distinguish mutation failure from success, preventing
  false 'saved' checkmark on error
- Remove dead cn ternary that always evaluated to opacity-100
- Remove unused cn import

PromptForm:
- Pass isError from updateGroupMutation to PromptName
- Remove dead default arg (= {}) on function component

ChatGroupItem:
- Stabilize handleDelete via ref pattern to prevent memo-defeating
  recreation on every render (deleteGroup is a new ref each render)
- Disable delete button during mutation to prevent double-fire

ExpandedPanel:
- Restore <a> element for NewChatButton to fix middle-click (open in
  new tab) regression caused by anchor-to-button migration

DashGroupItem:
- Delete dead file (no longer exported or imported anywhere)

fix: address review findings across prompts UI and sidebar

- PromptName: render error state (red X) when save fails, extract
  shared commitName helper to deduplicate blur/Enter save paths
- ChatGroupItem: navigate away after deleting the active prompt in
  sidebar view; use context-aware route prefix (/prompts vs /d/prompts)
  for edit and card-click navigation
- InlinePromptsView: redirect to /c/new when user lacks prompts access
  instead of rendering a blank screen
- Remove dead ManagePrompts component and its barrel exports (no
  remaining consumers after GroupSidePanel cleanup)

fix: remove duplicate showThinking Recoil atom key

The 'showThinking' key was defined in both store/settings.ts (Recoil)
and store/showThinking.ts (Jotai). Only the Jotai atom is consumed;
the stale Recoil duplicate causes 'A key option with a unique string
value must be provided' at startup.

refactor: remove /d/prompts dashboard route and dead code

The prompts UI now lives at /prompts/* inline under the chat layout.
The old /d/prompts/* dashboard route, its layout (PromptsView), and
its breadcrumb (DashBreadcrumb) are no longer used.

- Delete PromptsView and DashBreadcrumb (zero consumers)
- Delete BackToChat button (zero consumers)
- Replace /d/prompts/* child routes with a redirect to /prompts/new
- Add /prompts index route that redirects to /prompts/new
- Update all /d/prompts navigation to /prompts:
  - ChatGroupItem: always use /prompts prefix
  - NoPromptGroup: navigate to /prompts
  - CreatePromptForm: fallback navigate to /prompts/:id
  - CreatePromptButton: simplified to /prompts/new (no dual-path)
- Strip GroupSidePanel of dashboard-only breadcrumb nav, recoil
  state clearing, and useDashboardContext dependency

refactor: remove dead Dashboard code and unused translation keys

- Delete DashboardContext provider (zero remaining consumers)
- Simplify DashboardRoute layout to a plain Outlet
- Remove commented-out file/vector-store route blocks
- Change catch-all redirect from /d/files to /c/new
- Remove 7 unused translation keys (com_nav_toggle_sidebar,
  com_ui_back_to_chat, com_ui_dashboard, com_ui_delete_prompt_name,
  com_ui_global_group, com_ui_prompt_renamed, com_ui_rename_prompt,
  com_ui_rename_prompt_name)

fix: add Babel plugin to transform import.meta.hot for Jest

`babel-plugin-transform-import-meta` handles standard properties (url,
filename, dirname, resolve) but not Vite's `hot` property. Jest runs in
CommonJS where `import.meta` is unavailable, so `import.meta.hot` in
AuthContext.tsx (added for HMR preservation) causes a SyntaxError that
breaks 27 test suites.

Add a small Babel plugin that replaces `import.meta.hot` with `undefined`
during Jest transforms, making the HMR guard blocks dead-code in tests.

fix: address review findings — inert typing, accessibility, and cleanup

- Add React type augmentation for `inert` attribute (React 18 compat)
- Replace spread hack `{...{ inert: }}` with direct prop in all 3 files
- Add `aria-hidden` to mobile overlay in PromptForm for screen readers
- Simplify deleteGroupRef pattern to direct mutation call
- Remove unused `useEffect` import and stale useMemo dependency
- Add clarifying comment for skipBlurRef mechanism in PromptName

fix: mobile UX for marketplace and prompts views

- Remove mobile new chat button from chat history section
- Add OpenSidebar entry points to marketplace and prompts views on mobile
- Move marketplace admin settings to a compact mobile top row
- Restructure prompt forms to surface category selector beside sidebar toggle
- Make versions panel slide content like the main sidebar and drop redundant borders
- Collapse versions button to icon-only on mobile
- Remove theme selector from prompt panel navigation

* fix: address PR review findings for prompts refactor

- Preserve prompt ID in /d/prompts/:id → /prompts/:id redirect
- Gate PreviewPrompt and VariableDialog behind isChatRoute to avoid
  mounting dead dialogs in dashboard mode
- Add onError handler to useDeletePromptGroup and close dialog on
  success
- Use useId() instead of hardcoded labelId in AdvancedPrompts
- Extract shared lazy loader for InlinePromptsView routes

* fix: complete review fixes for prompts refactor

- Move OGDialog (delete) inside isChatRoute gate with other dialogs
- Use useId() for both Switch id and label id in AdvancedPrompts
- Add com_ui_prompt_delete_error i18n key for actionable error context
- Drop no-op useCallback on handleDelete (unstable deleteGroup dep)

* refactor: hoist promptPath to module-scope constant

Eliminates stale-closure lint concern in dropdownItems useMemo and
removes the unnecessary dep array entry from onCardClick.

* refactor(sidebar): update icons and reorder links for clarity

- Replace Blocks icon with OpenAIMinimalIcon for the Assistant Builder link.
- Update Memories icon from BrainCircuit to Brain.
- Reintroduce Prompts link conditionally based on access permissions.
- Change Conversations icon from MessageSquare to MessagesSquare for consistency.

* refactor(sidebar): update icons and improve file attachment link

- Replace NewChatIcon with SquarePen in the NewChatButton for better visual consistency.
- Change AttachmentIcon to Paperclip in the file attachment link for clarity.

* refactor(sidebar): update file attachment icon for consistency

- Replace Paperclip icon with AttachmentIcon in the file attachment link for improved clarity and visual consistency.

* refactor(admin-settings): remove unused button and streamline dialog integration

- Eliminate the Admin button and its associated icon from the AdminSettings component for a cleaner interface.
- Simplify the confirm dialog integration by directly using the OGDialog without the button trigger.

* fix: context HMR issue

* style(prompts): enhance component structure and accessibility

- Update AutoSendPrompt button class for improved styling.
- Refactor List component to streamline loading and empty states.
- Ensure FilterPrompts handles context gracefully with null checks.
- Modify GroupSidePanel to prevent rendering without context.
- Simplify PromptsAccordion layout for better readability.
- Adjust CategoryIcon fallback behavior for undefined categories.

* refactor(useMCPServerManager): clean up import statements

- Remove duplicate import of MCPServerInitState for better clarity and organization.
- Adjust import order to maintain consistency with project structure.

* refactor(GroupSidePanel): restructure layout for improved readability and accessibility

- Adjust the structure of the GroupSidePanel component to enhance layout clarity.
- Move the PanelNavigation component into a more appropriate position within the hierarchy.
- Ensure consistent styling and behavior based on the isChatRoute condition.

* style(GroupSidePanel): adjust padding for improved layout consistency

- Update padding in the GroupSidePanel component to enhance visual alignment and readability.
- Ensure consistent styling across the component for a better user experience.

* fix(Conversations): add cache clearing and row height recomputation on search query change

- Implement useEffect to clear cache and recompute row heights when the search query changes.
- Enhance performance and responsiveness of the Conversations component during search operations.

* refactor(GroupSidePanel, PromptsAccordion): simplify layout and improve styling

* chore: import order

* fix: redirect users without CREATE permission from /prompts/new

Users with USE but not CREATE permission were seeing a blank page at
/prompts/new because InlinePromptsView only checked USE access.
CreatePromptForm's internal redirect was bypassed by the onSuccess
prop always being passed. Add CREATE check in InlinePromptsView so
the redirect happens before CreatePromptForm mounts.

* fix: restore dropdown actions for all routes and handle non-creator landing

- Remove isChatRoute gate on dropdown menu so preview, edit, and
  delete actions are available on the prompts management route
- Un-gate PreviewPrompt and OGDialog (delete) since both are
  triggered from the now-always-visible dropdown
- Keep VariableDialog gated behind isChatRoute (chat submission only)
- Show EmptyPromptPreview for non-creators at /prompts/new instead
  of redirecting to /c/new, so they stay in the prompts section
  with sidebar access to browse existing prompts

* fix: add isPublic to TPromptGroup type

The database schema (IPromptGroup in data-schemas) has isPublic but
the shared TPromptGroup type in data-provider was missing it,
causing a TS2339 error in ChatGroupItem.

* fix: prevent duplicate rename and restore name on error in PromptName

- Block re-entry to edit mode while a save is in flight by guarding
  the click handler with isLoading/saveStatus checks
- Reset newName to the prop value when mutation fails so the UI
  doesn't display the unsaved name after the error icon clears

* fix: address review findings across prompts refactor

- Consolidate duplicate usePromptGroupsContext() calls in PromptForm
- Remove invalid aria-labelledby (text string, not ID) from
  AutoSendPrompt checkbox that is already aria-hidden
- Remove useMemo wrapping trivial `disabled ?? false` in ToolsDropdown
- Remove dead context spread in PromptsAccordion (GroupSidePanel
  reads context internally)
- Wrap search cache-clear effect in requestAnimationFrame to match
  favorites effect pattern in Conversations
- Use Set for O(1) lookups in MCPSelect server filtering
- Fix unnecessary JSX expression wrapper on string literal in
  CreatePromptButton Link

* style(PromptTextCard): update icon classes for improved accessibility

- Add 'text-text-secondary' class to Check and Copy icons for better visibility and consistency in the PromptTextCard component.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-04-09 06:02:31 +02:00 committed by GitHub
parent 55fc37ff49
commit b8158a613e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 936 additions and 1416 deletions

View file

@ -14,6 +14,7 @@ module.exports = {
*/
plugins: [
'@babel/plugin-transform-runtime',
'./test/babel-plugin-transform-import-meta-hot.cjs',
'babel-plugin-transform-import-meta',
'babel-plugin-transform-vite-meta-env',
'babel-plugin-replace-ts-export-assignment',

View file

@ -29,11 +29,7 @@ interface BadgeRowContextType {
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
export function useBadgeRowContext() {
const context = useContext(BadgeRowContext);
if (context === undefined) {
throw new Error('useBadgeRowContext must be used within a BadgeRowProvider');
}
return context;
return useContext(BadgeRowContext);
}
interface BadgeRowProviderProps {

View file

@ -1,7 +0,0 @@
import { createContext, useContext } from 'react';
type TDashboardContext = {
prevLocationPath: string;
};
export const DashboardContext = createContext<TDashboardContext>({} as TDashboardContext);
export const useDashboardContext = () => useContext(DashboardContext);

View file

@ -83,10 +83,14 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;
}
const defaultDragDropValue: DragDropContextValue = {
conversationId: undefined,
agentId: undefined,
endpoint: undefined,
endpointType: undefined,
useResponsesApi: undefined,
};
export function useDragDropContext() {
const context = useContext(DragDropContext);
if (!context) {
throw new Error('useDragDropContext must be used within DragDropProvider');
}
return context;
return useContext(DragDropContext) ?? defaultDragDropValue;
}

View file

@ -77,9 +77,5 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
};
export const usePromptGroupsContext = () => {
const context = useContext(PromptGroupsContext);
if (!context) {
throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider');
}
return context;
return useContext(PromptGroupsContext);
};

View file

@ -10,7 +10,6 @@ export * from './EditorContext';
export * from './ChatFormContext';
export * from './BookmarkContext';
export * from './MessageContext';
export * from './DashboardContext';
export * from './AssistantsContext';
export * from './AgentsContext';
export * from './AssistantsMapContext';

View file

@ -1,11 +1,12 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import type t from 'librechat-data-provider';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import { SidePanelGroup } from '~/components/SidePanel';
import CategoryTabs from './CategoryTabs';
import SearchBar from './SearchBar';
@ -215,14 +216,19 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div className="sticky top-0 z-10 bg-presentation pb-4">
<div className="sticky top-0 z-10 mt-4 bg-presentation pb-4 md:mt-0">
<div className="container mx-auto max-w-4xl px-4">
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2 md:hidden">
<OpenSidebar />
<MarketplaceAdminSettings compact />
</div>
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
<div className="hidden md:block">
<MarketplaceAdminSettings />
</div>
</div>
{/* Category tabs */}

View file

@ -10,7 +10,7 @@ const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
];
const MarketplaceAdminSettings = () => {
const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -23,7 +23,16 @@ const MarketplaceAdminSettings = () => {
},
});
const trigger = (
const trigger = compact ? (
<Button
size="icon"
variant="outline"
className="rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="icon-md" aria-hidden="true" />
</Button>
) : (
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"

View file

@ -14,8 +14,8 @@ interface ArtifactsToggleState {
function Artifacts() {
const localize = useLocalize();
const { artifacts } = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = artifacts;
const context = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = context?.artifacts ?? {};
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isButtonExpanded, setIsButtonExpanded] = useState(false);
@ -32,6 +32,7 @@ function Artifacts() {
const isCustomEnabled = currentState.mode === ArtifactModes.CUSTOM;
const handleToggle = useCallback(() => {
if (!debouncedChange) return;
if (isEnabled) {
debouncedChange({ value: '' });
setIsButtonExpanded(false);
@ -55,6 +56,7 @@ function Artifacts() {
}, [isPopoverOpen]);
const handleShadcnToggle = useCallback(() => {
if (!debouncedChange) return;
if (isShadcnEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
@ -63,6 +65,7 @@ function Artifacts() {
}, [isShadcnEnabled, debouncedChange]);
const handleCustomToggle = useCallback(() => {
if (!debouncedChange) return;
if (isCustomEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {

View file

@ -7,9 +7,9 @@ import { useBadgeRowContext } from '~/Providers';
function CodeInterpreter() {
const localize = useLocalize();
const { codeInterpreter, codeApiKeyForm } = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = codeInterpreter;
const { badgeTriggerRef } = codeApiKeyForm;
const context = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = context?.codeInterpreter ?? {};
const { badgeTriggerRef } = context?.codeApiKeyForm ?? {};
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,

View file

@ -6,8 +6,8 @@ import { useBadgeRowContext } from '~/Providers';
function FileSearch() {
const localize = useLocalize();
const { fileSearch } = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch;
const context = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = context?.fileSearch ?? {};
const canUseFileSearch = useHasAccess({
permissionType: PermissionTypes.FILE_SEARCH,

View file

@ -6,47 +6,55 @@ import { TooltipAnchor } from '@librechat/client';
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import StackedMCPIcons from '~/components/MCP/StackedMCPIcons';
import { useHasAccess, useLocalize } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
function MCPSelectContent() {
const { conversationId, storageContextKey, mcpServerManager } = useBadgeRowContext();
const {
localize,
isPinned,
mcpValues,
placeholderText,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const localize = useLocalize();
const context = useBadgeRowContext();
const { conversationId, storageContextKey, mcpServerManager: manager } = context ?? {};
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
const isOpen = menuStore.useState('open');
const selectedCount = mcpValues?.length ?? 0;
const selectedServers = useMemo(() => {
if (!mcpValues || mcpValues.length === 0) {
if (!manager?.mcpValues || manager.mcpValues.length === 0) {
return [];
}
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
}, [selectableServers, mcpValues]);
const selectedSet = new Set(manager.mcpValues);
return manager.selectableServers?.filter((s) => selectedSet.has(s.serverName));
}, [manager?.selectableServers, manager?.mcpValues]);
const displayText = useMemo(() => {
const selectedCount = manager?.mcpValues?.length ?? 0;
if (selectedCount === 0) {
return null;
}
if (selectedCount === 1) {
const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]);
return server?.config?.title || mcpValues?.[0];
const server = manager?.selectableServers?.find(
(s) => s.serverName === manager?.mcpValues?.[0],
);
return server?.config?.title || manager?.mcpValues?.[0];
}
return localize('com_ui_x_selected', { 0: selectedCount });
}, [selectedCount, selectableServers, mcpValues, localize]);
}, [manager?.selectableServers, manager?.mcpValues, localize]);
if (!manager) {
return null;
}
const {
isPinned,
mcpValues,
isInitializing,
placeholderText,
connectionStatus,
selectableServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = manager;
if (!isPinned && mcpValues?.length === 0) {
return null;
@ -126,8 +134,8 @@ function MCPSelectContent() {
}
function MCPSelect() {
const { mcpServerManager } = useBadgeRowContext();
const { selectableServers } = mcpServerManager;
const context = useBadgeRowContext();
const { selectableServers } = context?.mcpServerManager ?? {};
const canUseMcp = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.USE,

View file

@ -15,19 +15,8 @@ interface MCPSubMenuProps {
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
const localize = useLocalize();
const { storageContextKey, mcpServerManager } = useBadgeRowContext();
const {
isPinned,
mcpValues,
setIsPinned,
placeholderText,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const context = useBadgeRowContext();
const { storageContextKey, mcpServerManager } = context ?? {};
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
@ -35,6 +24,23 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
placement: 'right',
});
if (!mcpServerManager) {
return null;
}
const {
isPinned,
mcpValues,
setIsPinned,
isInitializing,
placeholderText,
connectionStatus,
selectableServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
if (!selectableServers || selectableServers.length === 0) {
return null;
}

View file

@ -62,8 +62,9 @@ function PromptsCommand({
}) {
const localize = useLocalize();
const { mutate: recordUsage } = useRecordPromptUsage();
const { allPromptGroups, hasAccess } = usePromptGroupsContext();
const { data, isLoading } = allPromptGroups;
const promptGroupsContext = usePromptGroupsContext();
const { allPromptGroups, hasAccess } = promptGroupsContext ?? {};
const { data, isLoading } = allPromptGroups ?? {};
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

View file

@ -5,9 +5,19 @@ import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useBadgeRowContext } from '~/Providers';
function ToolDialogs() {
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = useBadgeRowContext();
const { authData: webSearchAuthData } = webSearch;
const { authData: codeAuthData } = codeInterpreter;
const context = useBadgeRowContext();
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = context ?? {};
const { authData: webSearchAuthData } = webSearch ?? {};
const { authData: codeAuthData } = codeInterpreter ?? {};
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
if (!searchApiKeyForm || !codeApiKeyForm) {
return null;
}
const {
methods: searchMethods,
@ -29,12 +39,6 @@ function ToolDialogs() {
menuTriggerRef: codeMenuTriggerRef,
} = codeApiKeyForm;
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
return (
<>
<SearchApiKeyDialog

View file

@ -23,39 +23,11 @@ interface ToolsDropdownProps {
const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const isDisabled = disabled ?? false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const {
webSearch,
artifacts,
fileSearch,
agentsConfig,
mcpServerManager,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = useBadgeRowContext();
const context = useBadgeRowContext();
const { data: startupConfig } = useGetStartupConfig();
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm;
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
searchApiKeyForm;
const {
isPinned: isSearchPinned,
setIsPinned: setIsSearchPinned,
authData: webSearchAuthData,
} = webSearch;
const {
isPinned: isCodePinned,
setIsPinned: setIsCodePinned,
authData: codeAuthData,
} = codeInterpreter;
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
useAgentCapabilities(context?.agentsConfig?.capabilities ?? defaultAgentCapabilities);
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
@ -77,6 +49,35 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
permission: Permissions.USE,
});
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isDisabled = disabled ?? false;
const {
webSearch,
artifacts,
fileSearch,
mcpServerManager,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = context ?? {};
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm ?? {};
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
searchApiKeyForm ?? {};
const {
isPinned: isSearchPinned,
setIsPinned: setIsSearchPinned,
authData: webSearchAuthData,
} = webSearch ?? {};
const {
isPinned: isCodePinned,
setIsPinned: setIsCodePinned,
authData: codeAuthData,
} = codeInterpreter ?? {};
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {};
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {};
const showWebSearchSettings = useMemo(() => {
const authTypes = webSearchAuthData?.authTypes ?? [];
if (authTypes.length === 0) return true;
@ -89,44 +90,44 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
);
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ value: newValue });
const newValue = !webSearch?.toggleState;
webSearch?.debouncedChange({ value: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ value: newValue });
const newValue = !codeInterpreter?.toggleState;
codeInterpreter?.debouncedChange({ value: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ value: newValue });
const newValue = !fileSearch?.toggleState;
fileSearch?.debouncedChange({ value: newValue });
}, [fileSearch]);
const handleArtifactsToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (!currentState || currentState === '') {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: '' });
artifacts?.debouncedChange({ value: '' });
}
}, [artifacts]);
const handleShadcnToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (currentState === ArtifactModes.SHADCNUI) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI });
artifacts?.debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [artifacts]);
const handleCustomToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (currentState === ArtifactModes.CUSTOM) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.CUSTOM });
artifacts?.debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [artifacts]);
@ -148,7 +149,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsFileSearchPinned(!isFileSearchPinned);
setIsFileSearchPinned?.(!isFileSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -182,7 +183,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchDialogOpen(true);
setIsSearchDialogOpen?.(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -201,7 +202,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
setIsSearchPinned?.(!isSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -236,7 +237,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodeDialogOpen(true);
setIsCodeDialogOpen?.(true);
}}
ref={codeMenuTriggerRef}
className={cn(
@ -255,7 +256,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
setIsCodePinned?.(!isCodePinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -274,15 +275,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
if (artifactsEnabled) {
if (artifactsEnabled && setIsArtifactsPinned != null) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<ArtifactsSubMenu
{...props}
isArtifactsPinned={isArtifactsPinned}
isArtifactsPinned={isArtifactsPinned ?? false}
setIsArtifactsPinned={setIsArtifactsPinned}
artifactsMode={artifacts.toggleState as string}
artifactsMode={artifacts?.toggleState as string}
handleArtifactsToggle={handleArtifactsToggle}
handleShadcnToggle={handleShadcnToggle}
handleCustomToggle={handleCustomToggle}
@ -291,7 +292,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
const { availableMCPServers } = mcpServerManager;
const { availableMCPServers } = mcpServerManager ?? {};
if (canUseMcp && availableMCPServers && availableMCPServers.length > 0) {
dropdownItems.push({
hideOnClick: false,

View file

@ -7,18 +7,20 @@ import { useBadgeRowContext } from '~/Providers';
function WebSearch() {
const localize = useLocalize();
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const context = useBadgeRowContext();
if (!canUseWebSearch) {
return null;
}
if (!context) {
return null;
}
const { webSearch: webSearchData, searchApiKeyForm } = context;
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
return (
(isPinned || (webSearch && authData?.authenticated)) && (

View file

@ -266,6 +266,16 @@ const Conversations: FC<ConversationsProps> = ({
return () => cancelAnimationFrame(frameId);
}, [favorites.length, isFavoritesLoading, showAgentMarketplace, clearFavoritesCache]);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
cache.clearAll();
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
containerRef.current.recomputeRowHeights(0);
}
});
return () => cancelAnimationFrame(frameId);
}, [search.query, cache, containerRef]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];

View file

@ -0,0 +1,46 @@
import { useCallback, useId } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { PromptsEditorMode } from '~/common';
import { useLocalize } from '~/hooks';
import store from '~/store';
const { promptsEditorMode, alwaysMakeProd } = store;
export default function AdvancedPrompts() {
const localize = useLocalize();
const [mode, setMode] = useRecoilState(promptsEditorMode);
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
const isAdvanced = mode === PromptsEditorMode.ADVANCED;
const handleChange = useCallback(
(checked: boolean) => {
if (!checked) {
setAlwaysMakeProd(true);
}
setMode(checked ? PromptsEditorMode.ADVANCED : PromptsEditorMode.SIMPLE);
},
[setMode, setAlwaysMakeProd],
);
const rootId = useId();
const labelId = `${rootId}-label`;
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div id={labelId}>{localize('com_nav_advanced_prompts')}</div>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_advanced_prompts_desc')} />
</div>
<Switch
id={rootId}
checked={isAdvanced}
onCheckedChange={handleChange}
className="ml-4"
data-testid="advancedPrompts"
aria-labelledby={labelId}
/>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { memo } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import AdvancedPrompts from './AdvancedPrompts';
import FontSizeSelector from './FontSizeSelector';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
@ -119,6 +120,9 @@ function Chat() {
/>
</div>
))}
<div className="pb-3">
<AdvancedPrompts />
</div>
<ForkSettings />
</div>
);

View file

@ -1,7 +1,6 @@
import { useState } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client';
import { OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client';
import type { PermissionConfig } from '~/components/ui';
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { AdminSettingsDialog } from '~/components/ui';
@ -39,18 +38,6 @@ const AdminSettings = () => {
setConfirmAdminUseChange({ newValue, callback: onChange });
};
const trigger = (
<Button
size="sm"
variant="outline"
aria-label={localize('com_ui_admin')}
className="mr-2 h-10 w-fit sm:m-0"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
);
const confirmDialog = (
<OGDialog
open={confirmAdminUseChange !== null}
@ -88,7 +75,6 @@ const AdminSettings = () => {
permissions={permissions}
menuId="prompt-role-dropdown"
mutation={mutation}
trigger={trigger}
onPermissionConfirm={handlePermissionConfirm}
confirmPermissions={[Permissions.USE]}
extraContent={confirmDialog}

View file

@ -1,52 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Sparkles, Layers } from 'lucide-react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { PromptsEditorMode } from '~/common';
import { Radio } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
const { promptsEditorMode, alwaysMakeProd } = store;
const AdvancedSwitch = () => {
const localize = useLocalize();
const [mode, setMode] = useRecoilState(promptsEditorMode);
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
const options = useMemo(
() => [
{
value: PromptsEditorMode.SIMPLE,
label: localize('com_ui_simple'),
icon: <Sparkles className="size-3.5" />,
},
{
value: PromptsEditorMode.ADVANCED,
label: localize('com_ui_advanced'),
icon: <Layers className="size-3.5" />,
},
],
[localize],
);
const handleChange = useCallback(
(value: string) => {
if (value === PromptsEditorMode.SIMPLE) {
setAlwaysMakeProd(true);
}
setMode(value as PromptsEditorMode);
},
[setMode, setAlwaysMakeProd],
);
return (
<Radio
options={options}
value={mode}
onChange={handleChange}
className="border border-border-light"
/>
);
};
export default AdvancedSwitch;

View file

@ -25,13 +25,13 @@ export default function AutoSendPrompt({
onClick={() => handleCheckedChange(!autoSendPrompts)}
aria-label={localize('com_nav_auto_send_prompts')}
aria-pressed={autoSendPrompts}
className={autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}
className={`relative h-9 w-full gap-2 rounded-lg border-border-light font-medium ${autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}`}
>
<Checkbox
checked={autoSendPrompts}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2"
className="pointer-events-none"
/>
{localize('com_nav_auto_send_prompts')}
</Button>

View file

@ -1,33 +0,0 @@
import { useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { buttonVariants } from '@librechat/client';
import { useDashboardContext } from '~/Providers';
import { useLocalize, useCustomLink } from '~/hooks';
import { cn } from '~/utils';
export default function BackToChat({ className }: { className?: string }) {
const localize = useLocalize();
const { prevLocationPath } = useDashboardContext();
const conversationId = useMemo(() => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const parts = prevLocationPath.split('/');
return parts[parts.length - 1];
}, [prevLocationPath]);
const href = `/c/${conversationId}`;
const clickHandler = useCustomLink(href);
return (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href={href}
onClick={clickHandler}
>
<ArrowLeft className="icon-xs mr-2" aria-hidden="true" />
{localize('com_ui_back_to_chat')}
</a>
);
}

View file

@ -27,7 +27,7 @@ export default function CreatePromptButton() {
className="size-9 shrink-0 bg-transparent"
aria-label={localize('com_ui_create_prompt')}
>
<Link to="/d/prompts/new">
<Link to="/prompts/new">
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>

View file

@ -1,32 +0,0 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { Button } from '@librechat/client';
import { useLocalize, useCustomLink } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export default function ManagePrompts({ className }: { className?: string }) {
const localize = useLocalize();
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const customLink = useCustomLink('/d/prompts', clickCallback);
const clickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
customLink(e as unknown as React.MouseEvent<HTMLAnchorElement>);
};
return (
<Button
variant="outline"
className={cn(className, 'bg-transparent')}
onClick={clickHandler}
aria-label={localize('com_ui_manage')}
>
{localize('com_ui_manage')}
</Button>
);
}

View file

@ -1,7 +1,4 @@
export { default as BackToChat } from './BackToChat';
export { default as ManagePrompts } from './ManagePrompts';
export { default as AdminSettings } from './AdminSettings';
export { default as AdvancedSwitch } from './AdvancedSwitch';
export { default as AlwaysMakeProd } from './AlwaysMakeProd';
export { default as AutoSendPrompt } from './AutoSendPrompt';
export { default as CreatePromptButton } from './CreatePromptButton';

View file

@ -31,13 +31,14 @@ const DeleteConfirmDialog = ({
<Button
variant="destructive"
size="icon"
className="size-9"
aria-label={localize('com_ui_delete')}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<Trash2 className="size-4" aria-hidden="true" />
<Trash2 className="size-5" aria-hidden="true" />
</Button>
}
/>

View file

@ -61,10 +61,11 @@ const SharePrompt = React.memo(
<Button
variant="outline"
size="icon"
className="size-9 border-border-medium"
aria-label={localize('com_ui_share')}
disabled={disabled}
>
<Share2Icon className="size-4" aria-hidden="true" />
<Share2Icon className="size-5" aria-hidden="true" />
</Button>
}
/>

View file

@ -42,7 +42,7 @@ const PromptDetails = ({ group, showActions = true, onUsePrompt }: PromptDetails
<PromptVariables promptText={mainText} />
{group.command && (
<div className="flex items-center gap-2 rounded-xl border border-border-light bg-surface-secondary p-3">
<div className="flex items-center gap-2 rounded-xl border border-border-medium bg-transparent p-3">
<SquareSlash className="h-4 w-4 text-text-secondary" aria-hidden="true" />
<span className="font-mono text-sm text-text-primary">/{group.command}</span>
</div>

View file

@ -52,8 +52,8 @@ const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
}, [mainText, showToast, localize, isCopied]);
return (
<div className="flex h-full flex-col rounded-xl border border-border-light bg-transparent shadow-md">
<header className="flex shrink-0 items-center justify-between border-b border-border-light p-3">
<div className="flex h-full flex-col rounded-xl border border-border-medium bg-transparent">
<header className="flex shrink-0 items-center justify-between border-b border-border-medium p-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-text-secondary" aria-hidden="true" />
<h3 className="text-base font-semibold text-text-primary">
@ -74,9 +74,9 @@ const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
aria-live="polite"
>
{isCopied ? (
<Check className="size-4" aria-hidden="true" />
<Check className="size-4 text-text-secondary" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
<Copy className="size-4 text-text-secondary" aria-hidden="true" />
)}
</Button>
}

View file

@ -37,7 +37,7 @@ const DropdownVariableCard = ({ parsed }: { parsed: ParsedVariable }) => {
return (
<div
className="bg-surface-secondary/50 rounded-lg border border-border-light p-2.5 hover:bg-surface-secondary"
className="bg-surface-secondary/50 rounded-lg border border-border-medium p-2.5 hover:bg-surface-secondary"
role="listitem"
aria-label={localize('com_ui_variable_with_options', {
name: parsed.name,
@ -61,7 +61,7 @@ const DropdownVariableCard = ({ parsed }: { parsed: ParsedVariable }) => {
{parsed.options.map((option, index) => (
<span
key={index}
className="rounded-md border border-border-light bg-surface-primary px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
className="rounded-md border border-border-medium bg-transparent px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
role="listitem"
>
{option}
@ -82,7 +82,7 @@ const SpecialVariableChip = ({ parsed }: { parsed: ParsedVariable }) => {
return (
<div
className="group flex items-start gap-2 rounded-lg border border-border-light bg-transparent p-2 hover:bg-surface-secondary"
className="group flex items-start gap-2 rounded-lg border border-border-medium bg-transparent p-2 hover:bg-surface-secondary"
role="listitem"
aria-label={displayLabel}
>
@ -99,7 +99,7 @@ const SpecialVariableChip = ({ parsed }: { parsed: ParsedVariable }) => {
const SimpleVariableChip = ({ parsed }: { parsed: ParsedVariable }) => (
<span
className="bg-surface-secondary/50 inline-flex items-center gap-1.5 rounded-lg border border-border-light px-2.5 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
className="bg-surface-secondary/50 inline-flex items-center gap-1.5 rounded-lg border border-border-medium px-2.5 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
role="listitem"
>
<Variable className="size-3 text-text-secondary" aria-hidden="true" />
@ -138,8 +138,8 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}
return (
<div className="overflow-hidden rounded-xl border border-border-light">
<header className="flex items-center justify-between border-b border-border-light p-3">
<div className="overflow-hidden rounded-xl border border-border-medium">
<header className="flex items-center justify-between border-b border-border-medium p-3">
<div className="flex items-center gap-2">
<Variable className="size-4 text-text-secondary" aria-hidden="true" />
<h4 className="text-sm font-semibold text-text-primary">

View file

@ -52,9 +52,9 @@ const getTimelineConnectorClasses = (isSelected: boolean, isProduction: boolean)
return 'border-green-500 bg-green-500 text-white';
}
if (isProduction) {
return 'border-green-400 bg-surface-primary text-green-500';
return 'border-green-400 bg-transparent text-green-500';
}
return 'border-border-medium bg-surface-primary text-text-secondary';
return 'border-border-medium bg-transparent text-text-secondary';
};
const VersionCard = ({
@ -107,7 +107,7 @@ const VersionCard = ({
'group mb-2 ml-2 flex flex-1 flex-col rounded-lg border p-3 text-left',
isSelected
? 'border-green-500/50 bg-green-50/50 dark:bg-green-950/20'
: 'border-border-light bg-surface-primary hover:border-border-medium hover:bg-surface-hover',
: 'border-border-medium bg-transparent hover:border-border-heavy hover:bg-surface-hover',
)}
onClick={onClick}
aria-label={localize('com_ui_version_var', { 0: `${versionNumber}` })}

View file

@ -44,7 +44,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
return (
<div className="flex max-h-[85vh] flex-col sm:max-h-[85vh]">
<h2 className="sr-only">{localize('com_ui_control_bar')}</h2>
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<header className="flex items-center justify-between rounded-t-xl border border-border-medium bg-transparent px-2 py-1.5">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h3 className="text-sm font-semibold text-text-primary">
@ -73,10 +73,8 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
</header>
<div
className={cn(
'relative w-full flex-1 overflow-auto rounded-b-xl border border-t-0 border-border-light p-3 text-left transition-all duration-200 sm:p-4',
isEditing
? 'bg-surface-primary'
: 'cursor-pointer bg-surface-primary hover:bg-surface-secondary',
'relative w-full flex-1 overflow-auto rounded-b-xl border border-t-0 border-border-medium p-3 text-left transition-all duration-200 sm:p-4',
isEditing ? '' : 'cursor-pointer hover:bg-surface-tertiary',
)}
>
{!isEditing && (
@ -134,10 +132,10 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
{field.value}
</ReactMarkdown>
)}
<div className="bg-surface-secondary/0 group-hover/preview:bg-surface-secondary/50 pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg bg-surface-primary px-3 py-1.5 shadow-md">
<div className="pointer-events-none sticky bottom-1/2 z-10 flex translate-y-1/2 items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-1.5 shadow-md">
<EditIcon className="size-4 text-text-secondary" aria-hidden="true" />
<span className="text-sm font-medium text-text-secondary">
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_click_to_edit')}
</span>
</div>

View file

@ -24,7 +24,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const { t } = useTranslation();
const formContext = useFormContext();
const [isOpen, setIsOpen] = useState(false);
const { hasAccess } = usePromptGroupsContext();
const { hasAccess } = usePromptGroupsContext() ?? {};
const { categories, emptyCategory } = useCategories({ hasAccess });
const control = formContext?.control;
@ -74,7 +74,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const trigger = (
<Ariakit.MenuButton
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'focus:ring-offset-ring-offset relative inline-flex h-9 items-center justify-between rounded-xl border border-border-medium bg-transparent px-3 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'gap-2 sm:w-fit',
className,
)}

View file

@ -44,7 +44,7 @@ const Command = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<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"
@ -67,7 +67,7 @@ const Command = ({
/>
<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-surface-primary 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"
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"
>
{localize('com_ui_command_placeholder')}
</label>

View file

@ -41,7 +41,7 @@ const Description = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<div className="rounded-xl border border-border-medium">
<label
htmlFor="prompt-description"
className="block px-4 pt-2 text-sm text-text-secondary md:hidden"
@ -64,7 +64,7 @@ const Description = ({
/>
<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-surface-primary 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"
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"
>
{localize('com_ui_description_placeholder')}
</label>

View file

@ -1,70 +1,67 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Check, X, Pencil } from 'lucide-react';
import { Button, Input, Spinner, TooltipAnchor } from '@librechat/client';
import { Pencil, Check, Loader2, X } from 'lucide-react';
import { useLocalize } from '~/hooks';
type Props = {
name?: string;
isLoading?: boolean;
isError?: boolean;
onSave: (newName: string) => void;
};
const PromptName: React.FC<Props> = ({ name, isLoading = false, onSave }) => {
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const PromptName: React.FC<Props> = ({ name, isLoading = false, isError = false, onSave }) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>();
/** Prevents duplicate saves when Enter/Escape already called commitName before blur fires */
const skipBlurRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value);
}, []);
const handleCancel = useCallback(() => {
if (isLoading) {
return;
}
setIsEditing(false);
setNewName(name);
}, [name, isLoading]);
const saveName = useCallback(() => {
if (isLoading) {
return;
}
const commitName = useCallback(() => {
const savedName = newName?.trim();
if (savedName && savedName !== name) {
setSaveStatus('saving');
onSave(savedName);
} else {
setNewName(name);
setIsEditing(false);
}
}, [newName, name, onSave, isLoading]);
setIsEditing(false);
}, [newName, name, onSave]);
const saveName = useCallback(() => {
if (skipBlurRef.current) {
skipBlurRef.current = false;
return;
}
commitName();
}, [commitName]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
e.preventDefault();
skipBlurRef.current = true;
setNewName(name);
setIsEditing(false);
}
if (e.key === 'Enter') {
e.preventDefault();
saveName();
skipBlurRef.current = true;
commitName();
}
},
[handleCancel, saveName],
[name, commitName],
);
const handleTitleClick = useCallback(() => {
setIsEditing(true);
}, []);
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsEditing(true);
}
}, []);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
@ -72,90 +69,89 @@ const PromptName: React.FC<Props> = ({ name, isLoading = false, onSave }) => {
}
}, [isEditing]);
// Track loading state for detecting save completion
useEffect(() => {
if (isLoading) {
setSaveStatus('saving');
} else if (wasLoadingRef.current && !isLoading) {
setSaveStatus(isError ? 'error' : 'saved');
if (isError) {
setNewName(name);
}
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
}
savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
}
wasLoadingRef.current = isLoading;
}, [isLoading]);
}, [isLoading, isError, name]);
// Close editing when name updates after save (loading finished)
useEffect(() => {
setNewName(name);
if (wasLoadingRef.current) {
setIsEditing(false);
wasLoadingRef.current = false;
}
}, [name]);
useEffect(() => {
return () => {
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
}
};
}, []);
return (
<div className="flex min-w-0 flex-1 items-center">
<div className="group/title relative mr-2 flex h-8 min-w-0 flex-1 items-center">
{isEditing ? (
<div className="mr-3 flex min-w-0 flex-1 items-center gap-2">
<Input
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
ref={inputRef}
disabled={isLoading}
className="h-10 min-w-0 flex-1 rounded-lg border border-border-medium bg-surface-primary px-3 text-xl font-semibold text-text-primary transition-colors focus:border-border-heavy disabled:opacity-60 sm:text-2xl"
aria-label={localize('com_ui_name')}
/>
<div className="flex shrink-0 items-center gap-1">
<TooltipAnchor
description={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
side="bottom"
render={
<Button
type="button"
onClick={saveName}
variant="submit"
size="icon"
disabled={isLoading}
aria-label={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
>
{isLoading ? (
<Spinner size={16} className="text-white" />
) : (
<Check className="size-4" aria-hidden="true" />
)}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_cancel')}
side="bottom"
render={
<Button
type="button"
onClick={handleCancel}
variant="outline"
size="icon"
disabled={isLoading}
aria-label={localize('com_ui_cancel')}
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</div>
</div>
<input
ref={inputRef}
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={saveName}
disabled={isLoading}
className="h-8 min-w-0 flex-1 rounded-md border border-transparent bg-transparent pl-2 pr-0 text-base font-semibold text-text-primary outline-none focus:border-border-medium focus:outline-none disabled:opacity-60"
aria-label={localize('com_ui_name')}
/>
) : (
<button
type="button"
onClick={handleTitleClick}
onKeyDown={handleTitleKeyDown}
className="group mr-3 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_ui_edit') + ' ' + localize('com_ui_name')}
onClick={() => {
if (!isLoading && saveStatus !== 'saving') {
setIsEditing(true);
}
}}
className="h-8 min-w-0 flex-1 cursor-text truncate pl-2 text-left text-base font-semibold text-text-primary transition-colors hover:text-text-secondary focus:outline-none"
title={newName}
aria-label={localize('com_ui_edit') + ': ' + (newName ?? '')}
>
<span className="block truncate text-xl font-semibold text-text-primary sm:text-2xl">
{newName}
</span>
<Pencil
className="size-4 shrink-0 text-text-tertiary opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
{newName}
</button>
)}
<div className="ml-1.5 flex shrink-0 items-center justify-center">
{saveStatus === 'saving' && (
<Loader2
className="size-4 animate-spin text-text-secondary"
aria-label={localize('com_ui_saving')}
/>
)}
{saveStatus === 'saved' && (
<Check
className="size-4 text-green-500 transition-opacity duration-300"
aria-label={localize('com_ui_saved')}
/>
)}
{saveStatus === 'error' && (
<X
className="size-4 text-red-500 transition-opacity duration-300"
aria-label={localize('com_ui_error')}
/>
)}
{saveStatus === 'idle' && !isEditing && (
<Pencil
className="size-3.5 text-text-secondary opacity-0 transition-opacity group-hover/title:opacity-100"
aria-hidden="true"
/>
)}
</div>
</div>
);
};

View file

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import CategorySelector from '../fields/CategorySelector';
import VariablesDropdown from '../editor/VariablesDropdown';
import PromptVariables from '../display/PromptVariables';
@ -34,12 +35,14 @@ const defaultPrompt: CreateFormValues = {
const CreatePromptForm = ({
defaultValues = defaultPrompt,
onSuccess,
}: {
defaultValues?: CreateFormValues;
onSuccess?: (groupId: string) => void;
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const { hasAccess: hasUseAccess } = usePromptGroupsContext();
const { hasAccess: hasUseAccess } = usePromptGroupsContext() ?? {};
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@ -48,7 +51,7 @@ const CreatePromptForm = ({
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
if (!hasAccess && !onSuccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
@ -56,7 +59,7 @@ const CreatePromptForm = ({
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
}, [hasAccess, navigate, onSuccess]);
const methods = useForm({
defaultValues: {
@ -74,7 +77,12 @@ const CreatePromptForm = ({
const createPromptMutation = useCreatePrompt({
onSuccess: (response) => {
navigate(`/d/prompts/${response.prompt.groupId}`, { replace: true });
const groupId = response.prompt.groupId;
if (onSuccess && groupId) {
onSuccess(groupId);
} else {
navigate(`/prompts/${groupId}`, { replace: true });
}
},
});
@ -106,6 +114,10 @@ const CreatePromptForm = ({
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
<h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>
<div className="mb-2 flex items-center justify-between gap-2 sm:hidden">
<OpenSidebar />
<CategorySelector />
</div>
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
<Controller
@ -118,7 +130,7 @@ const CreatePromptForm = ({
{...field}
id="prompt-name"
type="text"
className="peer mr-2 w-full border border-border-light p-2 text-2xl text-text-primary"
className="peer mr-2 w-full border border-border-medium p-2 text-2xl text-text-primary"
placeholder=" "
tabIndex={0}
aria-label={localize('com_ui_prompt_name')}
@ -126,7 +138,7 @@ const CreatePromptForm = ({
/>
<label
htmlFor="prompt-name"
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-surface-primary 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="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"
>
{localize('com_ui_prompt_name')}*
</label>
@ -141,12 +153,14 @@ const CreatePromptForm = ({
</div>
)}
/>
<CategorySelector />
<div className="hidden sm:block">
<CategorySelector />
</div>
</div>
</div>
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div className="flex flex-col">
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<header className="flex items-center justify-between rounded-t-xl border border-border-medium bg-transparent p-2">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h2 className="text-sm font-semibold text-text-primary">
@ -157,7 +171,7 @@ const CreatePromptForm = ({
<VariablesDropdown fieldName="prompt" />
</div>
</header>
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-light p-3 sm:p-4">
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-medium p-3 sm:p-4">
<Controller
name="prompt"
control={control}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Menu, Rocket } from 'lucide-react';
import { Menu, Rocket, X } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { useForm, FormProvider } from 'react-hook-form';
import { Button, Skeleton, useToastContext } from '@librechat/client';
@ -20,6 +20,7 @@ import {
useMakePromptProduction,
} from '~/data-provider';
import { useResourcePermissions, useHasAccess, useLocalize, useFocusTrap } from '~/hooks';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import CategorySelector from '../fields/CategorySelector';
import PromptVariables from '../display/PromptVariables';
import PromptVersions from '../display/PromptVersions';
@ -65,18 +66,15 @@ const VersionsPanel = React.memo(
const isProductionVersion = selectedPrompt?._id === group?.productionId;
return (
<div
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="flex h-full w-full flex-col overflow-hidden">
{canEdit && (
<div className="shrink-0 border-b border-border-light px-4 py-3">
<div className="shrink-0 px-4 py-2">
<Button
variant="submit"
size="sm"
aria-label={localize('com_ui_make_production')}
className={cn(
'w-full gap-2 transition-all duration-200',
'w-full gap-1.5 transition-all duration-200',
isProductionVersion &&
'border border-green-500/30 bg-green-50 text-green-700 hover:bg-green-100 dark:bg-green-950/30 dark:text-green-400 dark:hover:bg-green-950/50',
)}
@ -106,7 +104,7 @@ const VersionsPanel = React.memo(
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-3">
<div className="flex-1 overflow-y-auto px-4 py-2">
{isLoadingPrompts &&
Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
@ -115,7 +113,7 @@ const VersionsPanel = React.memo(
))}
{!isLoadingPrompts && prompts.length > 0 && (
<>
<div className="mb-3 flex items-center justify-between">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-medium text-text-secondary">
{localize('com_ui_versions')}
</h2>
@ -180,13 +178,13 @@ const HeaderActions = React.memo(
HeaderActions.displayName = 'HeaderActions';
const PromptForm = () => {
const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
const { hasAccess } = usePromptGroupsContext();
const { hasAccess, groupsQuery } = usePromptGroupsContext() ?? {};
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const promptId = params.promptId || '';
const promptId = promptIdProp || params.promptId || '';
const editorMode = useRecoilValue(store.promptsEditorMode);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
@ -197,7 +195,6 @@ const PromptForm = () => {
const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
const [showSidePanel, setShowSidePanel] = useState(false);
const sidePanelWidth = '320px';
// Reset selection when navigating to a different prompt group
useEffect(() => {
@ -239,8 +236,6 @@ const PromptForm = () => {
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
const { groupsQuery } = usePromptGroupsContext();
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
@ -335,7 +330,7 @@ const PromptForm = () => {
useEffect(() => {
handleLoadingComplete();
}, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
}, [promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
useEffect(() => {
setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
@ -435,11 +430,8 @@ const PromptForm = () => {
}
// Show read-only view if user doesn't have edit permission
if (!canEdit && !permissionsLoading && groupsQuery.data) {
const fetchedPrompt = findPromptGroup(
groupsQuery.data,
(group) => group._id === params.promptId,
);
if (!canEdit && !permissionsLoading && groupsQuery?.data) {
const fetchedPrompt = findPromptGroup(groupsQuery?.data, (group) => group._id === promptId);
if (!fetchedPrompt && !canView) {
return <NoPromptGroup />;
}
@ -457,22 +449,42 @@ const PromptForm = () => {
return (
<FormProvider {...methods}>
<form className="mt-4 flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<form className="flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<h1 className="sr-only">{localize('com_ui_edit_prompt_page')}</h1>
<div className="relative w-full">
<div className="h-full w-full">
<div className="relative w-full overflow-hidden">
<div
className="h-full w-full"
style={{
transform: showSidePanel ? 'translateX(max(-85vw, -380px))' : 'translateX(0)',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div className="flex h-full">
<div className="flex-1 overflow-hidden px-4">
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-3 mt-2 flex items-center justify-between gap-2 sm:hidden">
<OpenSidebar />
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
{/* Header: Title + Actions */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
{isLoadingGroup ? (
<Skeleton className="h-10 w-48 font-bold sm:text-xl md:h-12 md:text-2xl" />
<Skeleton className="h-9 w-48" />
) : (
<>
<div className="flex min-w-0 flex-1 items-center gap-2">
<PromptName
name={groupName}
isLoading={updateGroupMutation.isLoading}
isError={updateGroupMutation.isError}
onSave={(value) => {
if (!canEdit || !group._id) {
return;
@ -493,8 +505,8 @@ const PromptForm = () => {
onClick={() => setShowSidePanel(true)}
aria-label={localize('com_ui_versions')}
>
<Menu className="mr-1.5 size-4" aria-hidden="true" />
<span>{localize('com_ui_versions')}</span>
<Menu className="size-4 sm:mr-1.5" aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_versions')}</span>
</Button>
)}
</div>
@ -511,24 +523,11 @@ const PromptForm = () => {
)}
</div>
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-4 sm:hidden">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
{/* Main Editor Content */}
{isLoadingPrompts ? (
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<div className="mb-2 flex h-full flex-col gap-3">
<PromptEditor
name="prompt"
isEditing={isEditing}
@ -551,7 +550,7 @@ const PromptForm = () => {
{/* Versions Sidebar - Advanced Mode Only */}
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-72 shrink-0 border-l border-border-light lg:block xl:w-80">
<div className="hidden w-72 shrink-0 border-l border-border-medium lg:block xl:w-80">
<VersionsPanel
group={group}
prompts={prompts}
@ -567,56 +566,63 @@ const PromptForm = () => {
</div>
{/* Mobile Overlay */}
{showSidePanel && (
<div
aria-hidden={!showSidePanel}
className={cn(
'fixed inset-0 z-[100] bg-black/20 lg:hidden',
showSidePanel ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: 'opacity 300ms cubic-bezier(0.2, 0, 0, 1)' }}
>
<button
type="button"
className="absolute inset-0 z-40 cursor-default bg-black/20"
style={{ transition: 'opacity 0.3s ease-in-out' }}
className="h-full w-full cursor-default"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close_menu')}
tabIndex={showSidePanel ? 0 : -1}
/>
)}
</div>
{/* Mobile Versions Panel */}
<div
ref={sidePanelRef}
className="absolute inset-y-0 right-0 z-50 lg:hidden"
className={cn(
'fixed right-0 top-0 z-[110] flex h-full flex-col border-l border-border-medium bg-surface-primary-alt shadow-xl lg:hidden',
showSidePanel ? 'translate-x-0' : 'translate-x-full',
)}
style={{
width: sidePanelWidth,
transform: `translateX(${showSidePanel ? '0' : '100%'})`,
transition: 'transform 0.3s ease-in-out',
willChange: 'transform',
width: 'min(85vw, 380px)',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
role="dialog"
aria-modal="true"
aria-label={localize('com_ui_versions')}
inert={!showSidePanel ? '' : undefined}
>
<div className="h-full bg-surface-primary shadow-xl">
<div className="flex items-center justify-between border-b border-border-light px-4 py-3">
<h2 className="text-lg font-semibold text-text-primary">
{localize('com_ui_versions')}
</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close')}
>
<span className="sr-only">{localize('com_ui_close')}</span>
&times;
</Button>
</div>
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
<div className="flex items-center justify-between px-4 py-2">
<h2 className="text-sm font-semibold text-text-primary">
{localize('com_ui_versions')}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close')}
className="size-8"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
</div>
</form>

View file

@ -38,10 +38,10 @@ const PromptLabelsForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
onKeyDown={handleKeyDown}
aria-label={localize('com_ui_add_labels')}
/>
<h3 className="rounded-t-lg border border-border-light px-4 text-base font-semibold text-text-primary">
<h3 className="rounded-t-lg border border-border-medium px-4 text-base font-semibold text-text-primary">
{localize('com_ui_labels')}
</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-light p-4">
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4">
{labels.length ? (
labels.map((label, index) => (
<span

View file

@ -1,4 +1,4 @@
export { PromptsView } from './layouts';
export { InlinePromptsView } from './layouts';
export { CategoryIcon, SkeletonForm } from './utils';
export { PromptName, Command, Description, CategorySelector } from './fields';
export { PreviewPrompt, DeleteVersion, VariableDialog, SharePrompt } from './dialogs';
@ -11,19 +11,5 @@ export {
FilterPrompts,
PanelNavigation,
} from './sidebar';
export {
List as PromptGroupsList,
DashGroupItem,
ChatGroupItem,
ListCard,
NoPromptGroup,
} from './lists';
export {
CreatePromptButton,
AdminSettings,
AdvancedSwitch,
AlwaysMakeProd,
AutoSendPrompt,
BackToChat,
ManagePrompts,
} from './buttons';
export { List as PromptGroupsList, ChatGroupItem, ListCard, NoPromptGroup } from './lists';
export { CreatePromptButton, AdminSettings, AlwaysMakeProd, AutoSendPrompt } from './buttons';

View file

@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { useParams, useNavigate, 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';
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} />
)}
</div>
);
}

View file

@ -1,140 +0,0 @@
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Sidebar, useMediaQuery } from '@librechat/client';
import { Outlet, useParams, useNavigate, useLocation } from 'react-router-dom';
import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
import { useHasAccess, useLocalize, useAuthContext } from '~/hooks';
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
import GroupSidePanel from '../sidebar/GroupSidePanel';
import FilterPrompts from '../sidebar/FilterPrompts';
import { PromptGroupsProvider } from '~/Providers';
import { cn } from '~/utils';
const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
export default function PromptsView() {
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
const isSmallerScreen = useMediaQuery('(max-width: 768px)');
const [panelVisible, setPanelVisible] = useState(!isSmallerScreen);
const openPanelRef = useRef<HTMLButtonElement>(null);
const closePanelRef = useRef<HTMLButtonElement>(null);
const isPromptsPath = useMemo(
() => promptsPathPattern.test(location.pathname),
[location.pathname],
);
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
const togglePanel = useCallback(() => {
setPanelVisible((prev) => {
const newValue = !prev;
requestAnimationFrame(() => {
if (newValue) {
closePanelRef?.current?.focus();
} else {
openPanelRef?.current?.focus();
}
});
return newValue;
});
}, []);
useEffect(() => {
if (isSmallerScreen && isDetailView) {
setPanelVisible(false);
}
}, [isSmallerScreen, isDetailView]);
if (!hasAccess) {
return null;
}
return (
<PromptGroupsProvider>
<div className="flex h-screen w-full flex-col bg-surface-primary p-0 lg:p-2">
{isSmallerScreen && isDetailView ? (
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<button
ref={openPanelRef}
type="button"
onClick={togglePanel}
className="ml-2 flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-surface-primary text-text-primary transition-all hover:bg-surface-hover"
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="prompts-panel"
>
<Sidebar className="h-4 w-4" />
</button>
<div className="flex items-center justify-center gap-2">
{isPromptsPath && <AdvancedSwitch />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
</div>
</div>
) : (
<DashBreadcrumb />
)}
<div className="flex w-full flex-grow flex-row overflow-hidden">
{isSmallerScreen && panelVisible && isDetailView && (
<div
className="fixed inset-0 z-40 bg-black/50 transition-opacity"
onClick={togglePanel}
role="button"
tabIndex={0}
aria-label={localize('com_nav_toggle_sidebar')}
/>
)}
{(!isSmallerScreen || !isDetailView || panelVisible) && (
<div
className={cn(
'transition-transform duration-300 ease-in-out',
isSmallerScreen && isDetailView
? 'fixed left-0 top-0 z-50 h-full w-[320px] bg-surface-primary'
: 'flex',
)}
>
<GroupSidePanel
closePanelRef={closePanelRef}
onClose={isSmallerScreen && isDetailView ? togglePanel : undefined}
>
<div className="mt-1 flex flex-row items-center justify-between px-2">
<FilterPrompts dropdownClassName="z-[100]" />
</div>
</GroupSidePanel>
</div>
)}
<div
className={cn(
'scrollbar-gutter-stable min-w-0 flex-1 overflow-y-auto',
isDetailView ? 'block' : 'hidden md:block',
)}
>
<Outlet />
</div>
</div>
</div>
</PromptGroupsProvider>
);
}

View file

@ -1 +1 @@
export { default as PromptsView } from './PromptsView';
export { default as InlinePromptsView } from './InlinePromptsView';

View file

@ -1,36 +1,89 @@
import { useState, memo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, TooltipAnchor } from '@librechat/client';
import { Eye, Pencil, EarthIcon, User } from 'lucide-react';
import { useState, memo, useRef, useCallback, useId, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useNavigate, useParams } from 'react-router-dom';
import { Ellipsis, Eye, SquarePen, Trash, EarthIcon, User } from 'lucide-react';
import { PermissionBits, ResourceType } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import {
Label,
Button,
Spinner,
OGDialog,
TooltipAnchor,
DropdownPopup,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import { useLocalize, useAuthContext, useSubmitMessage, useResourcePermissions } from '~/hooks';
import { useRecordPromptUsage } from '~/data-provider';
import { useRecordPromptUsage, useDeletePromptGroup } from '~/data-provider';
import { useLiveAnnouncer } from '~/Providers';
import VariableDialog from '../dialogs/VariableDialog';
import PreviewPrompt from '../dialogs/PreviewPrompt';
import { detectVariables } from '~/utils';
import ListCard from './ListCard';
import CategoryIcon from '../utils/CategoryIcon';
import { detectVariables, cn } from '~/utils';
function ChatGroupItem({ group }: { group: TPromptGroup }) {
const PROMPT_PATH = '/prompts';
function ChatGroupItem({
group,
isChatRoute = true,
}: {
group: TPromptGroup;
isChatRoute?: boolean;
}) {
const localize = useLocalize();
const navigate = useNavigate();
const params = useParams();
const { user } = useAuthContext();
const { submitPrompt } = useSubmitMessage();
const recordUsage = useRecordPromptUsage();
const { announcePolite } = useLiveAnnouncer();
const { showToast } = useToastContext();
const menuId = useId();
const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
const [menuOpen, setMenuOpen] = useState(false);
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const groupIsGlobal = group.isPublic === true;
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const canEdit = hasPermission(PermissionBits.EDIT);
const canDelete = hasPermission(PermissionBits.DELETE);
const previewButtonRef = useRef<HTMLButtonElement | null>(null);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const deleteGroup = useDeletePromptGroup({
onSuccess: () => {
setDeleteOpen(false);
announcePolite({
message: localize('com_ui_prompt_deleted_group', { 0: group.name }),
isStatus: true,
});
if (!isChatRoute && params.promptId === group._id) {
navigate(`${PROMPT_PATH}/new`, { replace: true });
}
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_prompt_delete_error') });
},
});
const handleDelete = () => {
if (!group._id) {
return;
}
deleteGroup.mutate({ id: group._id });
};
const onCardClick = useCallback(() => {
if (!isChatRoute) {
navigate(`${PROMPT_PATH}/${group._id}`, { replace: true });
return;
}
const onCardClick = () => {
const text = group.productionPrompt?.prompt;
if (!text?.trim()) {
return;
@ -45,91 +98,133 @@ function ChatGroupItem({ group }: { group: TPromptGroup }) {
if (group._id) {
recordUsage.mutate(group._id);
}
};
}, [group, submitPrompt, recordUsage, isChatRoute, navigate]);
const snippet =
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '');
const ariaLabel = group.category
? localize('com_ui_prompt_group_button', { name: group.name, category: group.category })
: localize('com_ui_prompt_group_button_no_category', { name: group.name });
const dropdownItems = useMemo(() => {
const items = [
{
label: localize('com_ui_preview'),
onClick: () => setPreviewDialogOpen(true),
icon: <Eye className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
},
];
if (canEdit) {
items.push({
label: localize('com_ui_edit'),
onClick: () => navigate(`${PROMPT_PATH}/${group._id}`),
icon: <SquarePen className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
});
}
if (canDelete) {
items.push({
label: localize('com_ui_delete'),
onClick: () => setDeleteOpen(true),
icon: <Trash className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
});
}
return items;
}, [localize, canEdit, canDelete, group._id, navigate]);
return (
<>
<div className="mb-2 rounded-xl border border-border-light bg-transparent px-1 hover:bg-surface-secondary">
<ListCard
name={group.name}
category={group.category ?? ''}
<div
className={cn(
'group/prompt relative mb-1.5 rounded-xl border border-border-light bg-transparent transition-colors hover:bg-surface-secondary',
!isChatRoute && params.promptId === group._id && 'bg-surface-hover',
)}
>
{/* Clickable overlay for card */}
<button
type="button"
className="absolute inset-0 z-0 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
onClick={onCardClick}
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}
icon={
isSharedPrompt || groupIsGlobal ? (
<>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{groupIsGlobal && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_sr_global_prompt')}
/>
)}
</>
) : undefined
}
>
<div className="flex items-center gap-1">
<TooltipAnchor
description={localize('com_ui_preview')}
side="top"
render={
<Button
ref={previewButtonRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_preview')}
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
>
<Eye className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
{canEdit && (
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_edit')}
onClick={(e) => {
e.stopPropagation();
navigate(`/d/prompts/${group._id}`);
}}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
)}
aria-label={ariaLabel}
/>
<div className="flex items-start gap-2.5 px-3 py-2.5">
<CategoryIcon
category={group.category ?? ''}
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold text-text-primary" title={group.name}>
{group.name}
</span>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 items-center"
>
<User className="size-3.5 text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{groupIsGlobal && (
<TooltipAnchor
description={localize('com_ui_sr_global_prompt')}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_sr_global_prompt')}
className="flex shrink-0 items-center"
>
<EarthIcon className="size-3.5 text-green-400" aria-hidden="true" />
</span>
}
/>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-text-secondary">
{snippet}
</p>
</div>
</ListCard>
<div className="relative z-10 shrink-0">
<DropdownPopup
portal={true}
menuId={menuId}
focusLoop={true}
className="z-[125]"
unmountOnHide={true}
isOpen={menuOpen}
setIsOpen={setMenuOpen}
trigger={
<Ariakit.MenuButton
ref={menuButtonRef}
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
'flex size-7 items-center justify-center rounded-md text-text-secondary transition-opacity hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
menuOpen
? 'opacity-100'
: 'opacity-0 focus-visible:opacity-100 group-hover/prompt:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="size-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={dropdownItems}
/>
</div>
</div>
</div>
<PreviewPrompt
group={group}
@ -137,15 +232,29 @@ function ChatGroupItem({ group }: { group: TPromptGroup }) {
onOpenChange={setPreviewDialogOpen}
onCloseAutoFocus={() => {
requestAnimationFrame(() => {
previewButtonRef.current?.focus({ preventScroll: true });
menuButtonRef.current?.focus({ preventScroll: true });
});
}}
/>
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
{isChatRoute && (
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
)}
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<OGDialogTemplate
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-md"
main={<Label>{localize('com_ui_prompt_delete_confirm', { 0: group.name })}</Label>}
selection={
<Button onClick={handleDelete} variant="destructive" disabled={deleteGroup.isLoading}>
{deleteGroup.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
</>
);
}

View file

@ -1,223 +0,0 @@
import { memo, useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { EarthIcon, Pencil, Trash2, User } from 'lucide-react';
import { PermissionBits, ResourceType, type TPromptGroup } from 'librechat-data-provider';
import {
Input,
Label,
Button,
Spinner,
OGDialog,
TooltipAnchor,
OGDialogTrigger,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import { useLocalize, useAuthContext, useResourcePermissions } from '~/hooks';
import { useLiveAnnouncer } from '~/Providers';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import CategoryIcon from '../utils/CategoryIcon';
import { cn } from '~/utils';
function DashGroupItemComponent({ group }: { group: TPromptGroup }) {
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
const { showToast } = useToastContext();
const { announcePolite } = useLiveAnnouncer();
const [nameInputValue, setNameInputValue] = useState(group.name);
const [renameOpen, setRenameOpen] = useState(false);
useEffect(() => {
if (!renameOpen) {
setNameInputValue(group.name);
}
}, [group.name, renameOpen]);
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const canEdit = hasPermission(PermissionBits.EDIT);
const canDelete = hasPermission(PermissionBits.DELETE);
const isGlobalGroup = group.isPublic === true;
const updateGroup = useUpdatePromptGroup({
onSuccess: () => {
setRenameOpen(false);
showToast({ status: 'success', message: localize('com_ui_prompt_renamed') });
announcePolite({ message: localize('com_ui_prompt_renamed'), isStatus: true });
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_prompt_update_error') });
},
});
const deleteGroup = useDeletePromptGroup({
onSuccess: (_response, variables) => {
announcePolite({
message: localize('com_ui_prompt_deleted_group', { 0: group.name }),
isStatus: true,
});
if (variables.id === group._id) {
navigate('/d/prompts');
}
},
});
const { isLoading: isSaving } = updateGroup;
const isDeleting = deleteGroup.isLoading;
const updateGroupRef = useRef(updateGroup);
updateGroupRef.current = updateGroup;
const deleteGroupRef = useRef(deleteGroup);
deleteGroupRef.current = deleteGroup;
const handleSaveRename = useCallback(() => {
updateGroupRef.current.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
}, [group._id, nameInputValue]);
const handleDelete = useCallback(() => {
deleteGroupRef.current.mutate({ id: group._id ?? '' });
}, [group._id]);
const handleContainerClick = useCallback(() => {
navigate(`/d/prompts/${group._id}`, { replace: true });
}, [group._id, navigate]);
const ariaLabel = group.category
? localize('com_ui_prompt_group_button', {
name: group.name,
category: group.category,
})
: localize('com_ui_prompt_group_button_no_category', {
name: group.name,
});
return (
<article
className={cn(
'group/card relative flex w-full items-center overflow-hidden rounded-lg border border-border-light bg-transparent text-left hover:bg-surface-secondary',
params.promptId === group._id && 'bg-surface-hover',
)}
>
<div className="flex w-0 min-w-0 flex-1 items-center gap-2 overflow-hidden p-4">
<CategoryIcon
category={group.category ?? ''}
className="icon-lg shrink-0"
aria-hidden="true"
/>
<a
href={`/d/prompts/${group._id}`}
onClick={(e) => {
e.preventDefault();
handleContainerClick();
}}
className="min-w-0 flex-1 truncate text-base font-semibold text-text-primary after:absolute after:inset-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
title={group.name}
aria-label={ariaLabel}
>
{group.name}
</a>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{isGlobalGroup && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_global_group')}
/>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1 pr-2">
{canEdit && (
<OGDialog open={renameOpen} onOpenChange={setRenameOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_rename')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-md"
main={
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
/>
}
selection={
<Button onClick={handleSaveRename} variant="submit">
{isSaving ? <Spinner /> : localize('com_ui_save')}
</Button>
}
/>
</OGDialog>
)}
{canDelete && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_delete_prompt_name', { name: group.name })}
>
<Trash2 className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-md"
main={<Label>{localize('com_ui_prompt_delete_confirm', { 0: group.name })}</Label>}
selection={
<Button onClick={handleDelete} variant="destructive">
{isDeleting ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
)}
</div>
</article>
);
}
export default memo(DashGroupItemComponent);

View file

@ -1,65 +1,44 @@
import { FileText } from 'lucide-react';
import { Skeleton } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';
import DashGroupItem from './DashGroupItem';
import ChatGroupItem from './ChatGroupItem';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function List({
groups = [],
isChatRoute,
isLoading,
isChatRoute,
}: {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
isChatRoute?: boolean;
}) {
const localize = useLocalize();
return (
<div className="flex h-full flex-col">
<section className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading && !isChatRoute && (
<div className="space-y-2 px-2">
{Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="flex h-14 w-full rounded-lg border-0 p-4" />
))}
<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>
)}
{!isLoading && groups.length === 0 && (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
isChatRoute ? 'my-2' : 'mx-2 my-4',
)}
>
<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>
)}
{isChatRoute ? (
groups.map((group) => <ChatGroupItem key={group._id} group={group} />)
) : (
<div className="space-y-2 px-0 md:px-2">
{groups.map((group) => (
<DashGroupItem key={group._id} group={group} />
))}
</div>
)}
</div>
</section>
</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>
</section>
);
}

View file

@ -15,7 +15,7 @@ export default function NoPromptGroup() {
<Button
className="mt-4"
onClick={() => {
navigate('/d/prompts');
navigate('/prompts');
}}
aria-label={localize('com_ui_back_to_prompts')}
>

View file

@ -1,5 +1,4 @@
export { default as List } from './List';
export { default as ListCard } from './ListCard';
export { default as DashGroupItem } from './DashGroupItem';
export { default as ChatGroupItem } from './ChatGroupItem';
export { default as NoPromptGroup } from './NoPromptGroup';

View file

@ -18,7 +18,7 @@ export default function FilterPrompts({
dropdownClassName?: string;
}) {
const localize = useLocalize();
const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext();
const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext() ?? {};
const { categories } = useCategories({ className: 'h-4 w-4', hasAccess });
const [searchTerm, setSearchTerm] = useState(name || '');
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
@ -77,6 +77,9 @@ export default function FilterPrompts({
}, [name]);
useEffect(() => {
if (!setName) {
return;
}
setName(debouncedSearchTerm);
}, [debouncedSearchTerm, setName]);

View file

@ -1,78 +1,38 @@
import { useMemo, useCallback } from 'react';
import { ArrowLeft } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { Button, Sidebar, TooltipAnchor } from '@librechat/client';
import { usePromptGroupsContext, useDashboardContext } from '~/Providers';
import { useLocalize, useCustomLink } from '~/hooks';
import ManagePrompts from '../buttons/ManagePrompts';
import { usePromptGroupsContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import PanelNavigation from './PanelNavigation';
import List from '../lists/List';
import { cn } from '~/utils';
import store from '~/store';
export default function GroupSidePanel({
children,
className = '',
closePanelRef,
onClose,
isChatRoute: isChatRouteProp,
}: {
children?: React.ReactNode;
className?: string;
closePanelRef?: React.RefObject<HTMLButtonElement>;
onClose?: () => void;
isChatRoute?: boolean;
}) {
const location = useLocation();
const localize = useLocalize();
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
const isChatRoute = isChatRouteProp ?? location.pathname?.startsWith('/c/') ?? false;
const { prevLocationPath } = useDashboardContext();
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const lastConversationId = useMemo(() => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const parts = prevLocationPath.split('/');
return parts[parts.length - 1];
}, [prevLocationPath]);
const chatLinkHandler = useCustomLink('/c/' + lastConversationId, clickCallback);
const promptsLinkHandler = useCustomLink('/d/prompts');
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } =
usePromptGroupsContext();
const context = usePromptGroupsContext();
if (!context) {
return null;
}
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = context;
return (
<div
id="prompts-panel"
className={cn('flex h-full w-full flex-col md:mr-2 md:w-[450px] md:shrink-0', className)}
>
<div id="prompts-panel" className={cn('flex h-full w-full flex-col', className)}>
{onClose && (
<div className="flex items-center justify-between px-2 py-[2px] md:py-2">
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm">
<a
href="/"
onClick={chatLinkHandler}
className="flex items-center gap-1 text-text-secondary hover:text-text-primary"
>
<ArrowLeft className="icon-xs" aria-hidden="true" />
<span>{localize('com_ui_chat')}</span>
</a>
<span className="text-text-tertiary" aria-hidden="true">
/
</span>
<a
href="/d/prompts"
onClick={promptsLinkHandler}
className="text-text-secondary hover:text-text-primary"
>
{localize('com_ui_prompts')}
</a>
</nav>
<div className="flex items-center justify-end px-2 py-[2px] md:py-2">
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
@ -92,27 +52,31 @@ export default function GroupSidePanel({
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-2 overflow-hidden">
{children}
<div className={cn('relative flex h-full flex-col', isChatRoute ? '' : 'px-2 md:px-0')}>
<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}
isChatRoute={isChatRoute}
isLoading={!!groupsQuery.isLoading}
isChatRoute={isChatRoute}
/>
</div>
</div>
<div className={cn(isChatRoute ? '' : 'px-2 pb-3 pt-2 md:px-0')}>
<PanelNavigation
onPrevious={prevPage}
onNext={nextPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isLoading={groupsQuery.isFetching}
isChatRoute={isChatRoute}
<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',
)}
>
{isChatRoute && <ManagePrompts className="select-none" />}
</PanelNavigation>
<div className="pointer-events-auto">
<PanelNavigation
onPrevious={prevPage}
onNext={nextPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isLoading={groupsQuery.isFetching}
isChatRoute={isChatRoute}
/>
</div>
</div>
</div>
</div>
);

View file

@ -1,5 +1,5 @@
import { memo } from 'react';
import { Button, ThemeSelector } from '@librechat/client';
import { Button } from '@librechat/client';
import { useLocalize } from '~/hooks';
function PanelNavigation({
@ -8,7 +8,6 @@ function PanelNavigation({
hasNextPage,
hasPreviousPage,
isLoading,
isChatRoute,
children,
}: {
onPrevious: () => void;
@ -23,10 +22,7 @@ function PanelNavigation({
return (
<div className="flex items-center justify-between">
<div className="flex gap-2 pl-1">
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
{children}
</div>
<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"

View file

@ -1,21 +1,17 @@
import { usePromptGroupsContext } from '~/Providers';
import { SystemRoles } from 'librechat-data-provider';
import { useAuthContext } from '~/hooks';
import { AdminSettings } from '~/components/Prompts';
import AutoSendPrompt from '../buttons/AutoSendPrompt';
import PromptSidePanel from './GroupSidePanel';
import FilterPrompts from './FilterPrompts';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsContext();
const { user } = useAuthContext();
return (
<div className="flex h-auto w-full flex-col px-3 pb-3">
<PromptSidePanel
className="h-auto space-y-2 md:mr-0 md:min-w-0 lg:w-full xl:w-full"
{...groupsNav}
>
<FilterPrompts />
<div className="flex w-full items-center justify-end">
<AutoSendPrompt />
</div>
</PromptSidePanel>
</div>
<PromptSidePanel className="space-y-2">
<FilterPrompts />
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
<AutoSendPrompt />
</PromptSidePanel>
);
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import {
Dices,
BoxIcon,
FileText,
PenLineIcon,
LightbulbIcon,
LineChartIcon,
@ -9,7 +10,6 @@ import {
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
// NEW: Add these for agent categories
Users as UsersIcon,
Beaker as BeakerIcon,
Settings as SettingsIcon,
@ -26,7 +26,6 @@ const categoryIconMap: Record<string, React.ElementType> = {
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
// NEW: Agent categories
general: BoxIcon,
hr: UsersIcon,
rd: BeakerIcon,
@ -39,13 +38,12 @@ const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-500/90 dark:text-yellow-300 ',
idea: 'text-yellow-500/90 dark:text-yellow-300',
write: 'text-purple-400',
travel: 'text-yellow-500/90 dark:text-yellow-300 ',
travel: 'text-yellow-500/90 dark:text-yellow-300',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
// NEW: Agent categories
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
@ -61,10 +59,7 @@ export default function CategoryIcon({
category: string;
className?: string;
}) {
const IconComponent = categoryIconMap[category];
const colorClass = categoryColorMap[category] + ' ' + className;
if (!IconComponent) {
return null;
}
const IconComponent = categoryIconMap[category] ?? FileText;
const colorClass = categoryColorMap[category] ?? 'text-text-secondary';
return <IconComponent className={cn('size-4', colorClass, className)} aria-hidden="true" />;
}

View file

@ -1,14 +1,12 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useMediaQuery, NewChatIcon } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { List } from 'react-virtualized';
import {
useLocalize,
useNewConvo,
useHasAccess,
useAuthContext,
useLocalStorage,
@ -17,18 +15,14 @@ import {
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import SearchBar from '~/components/Nav/SearchBar';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav'));
const ConversationsSection = memo(() => {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
@ -122,32 +116,6 @@ const ConversationsSection = memo(() => {
)}
{search.enabled && <SearchBar isSmallScreen={isSmallScreen} />}
</div>
{isSmallScreen && (
<div
role="button"
tabIndex={0}
aria-label={localize('com_ui_new_chat')}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-2 text-sm text-text-primary outline-none hover:bg-surface-active-alt focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}
}}
>
<NewChatIcon className="mr-2 h-5 w-5" />
<span className="truncate">{localize('com_ui_new_chat')}</span>
</div>
)}
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}

View file

@ -1,8 +1,9 @@
import { memo, useCallback, lazy, Suspense } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue } from 'recoil';
import { SquarePen } from 'lucide-react';
import { QueryKeys } from 'librechat-data-provider';
import { Skeleton, Sidebar, Button, TooltipAnchor, NewChatIcon } from '@librechat/client';
import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client';
import type { NavLink } from '~/common';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useActivePanel, resolveActivePanel } from '~/Providers';
@ -20,13 +21,12 @@ const NewChatButton = memo(function NewChatButton() {
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
return;
if (e.button === 0 && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
},
[queryClient, conversation?.conversationId, newConversation],
);
@ -43,9 +43,7 @@ const NewChatButton = memo(function NewChatButton() {
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-hover"
onClick={handleClick}
>
<div className="flex size-6 items-center justify-center rounded-full bg-text-primary">
<NewChatIcon className="size-3.5 text-white dark:text-black" />
</div>
<SquarePen className="h-5 w-5 text-text-primary" />
</a>
}
/>
@ -99,7 +97,7 @@ const NavIconButton = memo(function NavIconButton({
)}
onClick={handleClick}
>
<link.icon className="h-4 w-4" aria-hidden="true" />
<link.icon className="h-5 w-5" aria-hidden="true" />
</Button>
}
/>
@ -145,6 +143,7 @@ function ExpandedPanel({
}
/>
<NewChatButton />
<div className="mx-2 border-b border-border-light" />
<div className="flex flex-col gap-1 overflow-y-auto">
{links.map((link) => (
<NavIconButton

View file

@ -144,7 +144,7 @@ function UnifiedSidebar() {
width: 'min(85vw, 380px)',
transition: `transform ${TRANSITION_MS}ms ${EASING}`,
}}
{...{ inert: !expanded ? '' : undefined }}
inert={!expanded ? '' : undefined}
>
<SidebarChatProvider>
<ActivePanelProvider>

View file

@ -31,7 +31,11 @@ import { SESSION_KEY, isSafeRedirect, getPostLoginRedirect } from '~/utils';
import useTimeout from './useTimeout';
import store from '~/store';
const AuthContext = createContext<TAuthContext | undefined>(undefined);
const AuthContext = (import.meta.hot?.data?.__AuthContext ??
createContext<TAuthContext | undefined>(undefined)) as React.Context<TAuthContext | undefined>;
if (import.meta.hot) {
import.meta.hot.data.__AuthContext = AuthContext;
}
const AuthContextProvider = ({
authConfig,

View file

@ -17,11 +17,11 @@ import {
useGetAllEffectivePermissionsQuery,
} from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
import type { MCPServerInitState } from '~/store/mcp';
import type { ConfigFieldDetail } from '~/common';
import { useLocalize, useHasAccess, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp';
import type { MCPServerInitState } from '~/store/mcp';
export interface MCPServerDefinition {
serverName: string;

View file

@ -1,6 +1,13 @@
import { useMemo } from 'react';
import { Blocks, MCPIcon, AttachmentIcon } from '@librechat/client';
import { Database, Bookmark, Settings2, ArrowRightToLine, MessageSquareQuote } from 'lucide-react';
import { MCPIcon, AttachmentIcon, OpenAIMinimalIcon } from '@librechat/client';
import {
Bot,
Brain,
Bookmark,
NotebookPen,
ArrowRightToLine,
SlidersHorizontal,
} from 'lucide-react';
import {
Permissions,
EModelEndpoint,
@ -74,6 +81,22 @@ export default function useSideNavLinks({
const Links = useMemo(() => {
const links: NavLink[] = [];
if (
endpointsConfig?.[EModelEndpoint.agents] &&
hasAccessToAgents &&
hasAccessToCreateAgents &&
endpointsConfig[EModelEndpoint.agents].disableBuilder !== true
) {
links.push({
title: 'com_sidepanel_agent_builder',
label: '',
icon: Bot,
id: EModelEndpoint.agents,
Component: AgentPanelSwitch,
});
}
if (
isAssistantsEndpoint(endpoint) &&
((endpoint === EModelEndpoint.assistants &&
@ -87,32 +110,17 @@ export default function useSideNavLinks({
links.push({
title: 'com_sidepanel_assistant_builder',
label: '',
icon: Blocks,
icon: OpenAIMinimalIcon,
id: EModelEndpoint.assistants,
Component: PanelSwitch,
});
}
if (
endpointsConfig?.[EModelEndpoint.agents] &&
hasAccessToAgents &&
hasAccessToCreateAgents &&
endpointsConfig[EModelEndpoint.agents].disableBuilder !== true
) {
links.push({
title: 'com_sidepanel_agent_builder',
label: '',
icon: Blocks,
id: EModelEndpoint.agents,
Component: AgentPanelSwitch,
});
}
if (hasAccessToPrompts) {
links.push({
title: 'com_ui_prompts',
label: '',
icon: MessageSquareQuote,
icon: NotebookPen,
id: 'prompts',
Component: PromptsAccordion,
});
@ -122,24 +130,19 @@ export default function useSideNavLinks({
links.push({
title: 'com_ui_memories',
label: '',
icon: Database,
icon: Brain,
id: 'memories',
Component: MemoryPanel,
});
}
if (
interfaceConfig.parameters === true &&
isParamEndpoint(endpoint ?? '', endpointType ?? '') === true &&
!isAgentsEndpoint(endpoint) &&
keyProvided
) {
if (hasAccessToBookmarks) {
links.push({
title: 'com_sidepanel_parameters',
title: 'com_sidepanel_conversation_tags',
label: '',
icon: Settings2,
id: 'parameters',
Component: Parameters,
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
});
}
@ -151,13 +154,18 @@ export default function useSideNavLinks({
Component: FilesPanel,
});
if (hasAccessToBookmarks) {
if (
interfaceConfig.parameters === true &&
isParamEndpoint(endpoint ?? '', endpointType ?? '') === true &&
!isAgentsEndpoint(endpoint) &&
keyProvided
) {
links.push({
title: 'com_sidepanel_conversation_tags',
title: 'com_sidepanel_parameters',
label: '',
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
icon: SlidersHorizontal,
id: 'parameters',
Component: Parameters,
});
}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { MessageSquare } from 'lucide-react';
import { MessagesSquare } from 'lucide-react';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
@ -53,7 +53,7 @@ export default function useUnifiedSidebarLinks() {
const conversationLink: NavLink = {
title: 'com_ui_chat_history',
label: '',
icon: MessageSquare,
icon: MessagesSquare,
id: 'conversations',
Component: ConversationsSection,
};

View file

@ -400,6 +400,8 @@
"com_info_heic_converting": "Converting HEIC image to JPEG...",
"com_nav_2fa": "Two-Factor Authentication (2FA)",
"com_nav_account_settings": "Account Settings",
"com_nav_advanced_prompts": "Advanced prompts editor",
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
"com_nav_always_make_prod": "Always make new prompt versions production",
"com_nav_archive_created_at": "Date Archived",
"com_nav_archive_name": "Name",
@ -595,7 +597,6 @@
"com_nav_theme_dark": "Dark",
"com_nav_theme_light": "Light",
"com_nav_theme_system": "System",
"com_nav_toggle_sidebar": "Toggle sidebar",
"com_nav_tool_dialog": "Assistant Tools",
"com_nav_tool_dialog_agents": "Agent Tools",
"com_nav_tool_dialog_description": "Assistant must be saved to persist tool selections.",
@ -770,7 +771,6 @@
"com_ui_azure_ad": "Entra ID",
"com_ui_back": "Back",
"com_ui_back_to_builder": "Back to builder",
"com_ui_back_to_chat": "Back to Chat",
"com_ui_back_to_prompts": "Back to Prompts",
"com_ui_backup_code_number": "Code #{{number}}",
"com_ui_backup_codes": "Backup Codes",
@ -889,7 +889,6 @@
"com_ui_custom_header_name": "Custom Header Name",
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_dashboard": "Dashboard",
"com_ui_date": "Date",
"com_ui_date_april": "April",
"com_ui_date_august": "August",
@ -928,7 +927,6 @@
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_preset": "Delete Preset?",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_prompt_name": "Delete Prompt - {{name}}",
"com_ui_delete_shared_link": "Delete shared link?",
"com_ui_delete_shared_link_heading": "Delete Shared Link",
"com_ui_delete_success": "Successfully deleted",
@ -1054,7 +1052,6 @@
"com_ui_generating_image": "Generating image...",
"com_ui_generation_settings": "Generation Settings",
"com_ui_getting_started": "Getting Started",
"com_ui_global_group": "Global prompt",
"com_ui_go_back": "Go back",
"com_ui_go_to_conversation": "Go to conversation",
"com_ui_good_afternoon": "Good afternoon",
@ -1278,6 +1275,7 @@
"com_ui_prompt": "Prompt",
"com_ui_prompt_category_selector_aria": "Prompt's category selector",
"com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
"com_ui_prompt_delete_error": "There was an error deleting the prompt",
"com_ui_prompt_deleted_group": "Prompt group \"{{0}}\" deleted",
"com_ui_prompt_details": "{{name}} prompt details",
"com_ui_prompt_group_button": "{{name}} prompt, {{category}} category",
@ -1288,7 +1286,6 @@
"com_ui_prompt_name": "Prompt Name",
"com_ui_prompt_name_required": "Prompt Name is required",
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
"com_ui_prompt_renamed": "Prompt renamed successfully",
"com_ui_prompt_text": "Text",
"com_ui_prompt_text_required": "Text is required",
"com_ui_prompt_update_error": "There was an error updating the prompt",
@ -1332,8 +1329,6 @@
"com_ui_rename": "Rename",
"com_ui_rename_conversation": "Rename Conversation",
"com_ui_rename_failed": "Failed to rename conversation",
"com_ui_rename_prompt": "Rename Prompt",
"com_ui_rename_prompt_name": "Rename Prompt - {{name}}",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset": "Reset",
"com_ui_reset_adjustments": "Reset adjustments",

View file

@ -1,80 +1,23 @@
import { Navigate } from 'react-router-dom';
import {
PromptsView,
PromptForm,
CreatePromptForm,
EmptyPromptPreview,
} from '~/components/Prompts';
import { Navigate, useParams } from 'react-router-dom';
import DashboardRoute from './Layouts/Dashboard';
function PromptsRedirect() {
const { '*': splat } = useParams();
const target = splat ? `/prompts/${splat}` : '/prompts/new';
return <Navigate to={target} replace={true} />;
}
const dashboardRoutes = {
path: 'd/*',
element: <DashboardRoute />,
children: [
/*
{
element: <FileDashboardView />,
children: [
{
index: true,
element: <EmptyVectorStorePreview />,
},
{
path: ':vectorStoreId',
element: <DataTableFilePreview />,
},
],
},
{
path: 'files/*',
element: <FilesListView />,
children: [
{
index: true,
element: <EmptyFilePreview />,
},
{
path: ':fileId',
element: <FilePreview />,
},
],
},
{
path: 'vector-stores/*',
element: <VectorStoreView />,
children: [
{
index: true,
element: <EmptyVectorStorePreview />,
},
{
path: ':vectorStoreId',
element: <VectorStorePreview />,
},
],
},
*/
{
path: 'prompts/*',
element: <PromptsView />,
children: [
{
index: true,
element: <EmptyPromptPreview />,
},
{
path: 'new',
element: <CreatePromptForm />,
},
{
path: ':promptId',
element: <PromptForm />,
},
],
element: <PromptsRedirect />,
},
{
path: '*',
element: <Navigate to="/d/files" replace={true} />,
element: <Navigate to="/c/new" replace={true} />,
},
],
};

View file

@ -1,104 +0,0 @@
import { useMemo, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { SystemRoles } from 'librechat-data-provider';
import { ArrowLeft, MessageSquareQuote } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '@librechat/client';
import { useLocalize, useCustomLink, useAuthContext } from '~/hooks';
import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
import { useDashboardContext } from '~/Providers';
import store from '~/store';
const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
const getConversationId = (prevLocationPath: string) => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const lastPathnameParts = prevLocationPath.split('/');
return lastPathnameParts[lastPathnameParts.length - 1];
};
export default function DashBreadcrumb() {
const location = useLocation();
const localize = useLocalize();
const { user } = useAuthContext();
const { prevLocationPath } = useDashboardContext();
const lastConversationId = useMemo(() => getConversationId(prevLocationPath), [prevLocationPath]);
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const chatLinkHandler = useCustomLink('/c/' + lastConversationId, clickCallback);
const promptsLinkHandler = useCustomLink('/d/prompts');
const isPromptsPath = useMemo(
() => promptsPathPattern.test(location.pathname),
[location.pathname],
);
return (
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<Breadcrumb className="mt-1 px-2 dark:text-gray-200">
<BreadcrumbList>
<BreadcrumbItem className="hover:dark:text-white">
<BreadcrumbLink
href="/"
className="flex flex-row items-center gap-1"
onClick={chatLinkHandler}
>
<ArrowLeft className="icon-xs" aria-hidden="true" />
<span className="hidden md:flex">{localize('com_ui_back_to_chat')}</span>
<span className="flex md:hidden">{localize('com_ui_chat')}</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
{/*
<BreadcrumbItem className="hover:dark:text-white">
<DropdownMenu>
<DropdownMenuTrigger className="flex cursor-default items-center gap-1">
<BreadcrumbEllipsis className="h-4 w-4" />
<BreadcrumbItem className="hover:dark:text-white">
<span className="text-gray-400">{localize('com_ui_dashboard')}</span>
</BreadcrumbItem>
<span className="sr-only">Toggle menu</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>Documentation</DropdownMenuItem>
<DropdownMenuItem>Themes</DropdownMenuItem>
<DropdownMenuItem>GitHub</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
*/}
<BreadcrumbItem className="hover:dark:text-white">
<BreadcrumbLink
href="/d/prompts"
className="flex flex-row items-center gap-1"
onClick={promptsLinkHandler}
>
<MessageSquareQuote className="h-4 w-4 dark:text-gray-300" aria-hidden="true" />
{localize('com_ui_prompts')}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center justify-center gap-2">
{isPromptsPath && <AdvancedSwitch />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
</div>
</div>
);
}

View file

@ -1,36 +1,12 @@
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthContext, usePreviousLocation } from '~/hooks';
import { DashboardContext } from '~/Providers';
import store from '~/store';
import { useAuthContext } from '~/hooks';
export default function DashboardRoute() {
const queryClient = useQueryClient();
const { isAuthenticated } = useAuthContext();
const prevLocationRef = usePreviousLocation();
const clearConvoState = store.useClearConvoState();
const [prevLocationPath, setPrevLocationPath] = useState('');
useEffect(() => {
setPrevLocationPath(prevLocationRef.current?.pathname || '');
}, [prevLocationRef]);
useEffect(() => {
queryClient.removeQueries([QueryKeys.messages, 'new']);
clearConvoState();
}, [queryClient, clearConvoState]);
if (!isAuthenticated) {
return null;
}
return (
<DashboardContext.Provider value={{ prevLocationPath }}>
<div className="h-screen w-full">
<Outlet />
</div>
</DashboardContext.Provider>
);
return <Outlet />;
}

View file

@ -80,7 +80,7 @@ export default function Root() {
isSmallScreen && sidebarExpanded ? 'translateX(min(85vw, 380px))' : 'none',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
{...{ inert: isSmallScreen && sidebarExpanded ? '' : undefined }}
inert={isSmallScreen && sidebarExpanded ? '' : undefined}
>
<Outlet />
</div>

View file

@ -28,6 +28,11 @@ const AuthLayout = () => (
</AuthContextProvider>
);
const loadInlinePromptsView = () =>
import('~/components/Prompts/layouts/InlinePromptsView').then((m) => ({
Component: m.default,
}));
const baseEl = document.querySelector('base');
const baseHref = baseEl?.getAttribute('href') || '/';
@ -111,6 +116,18 @@ export const router = createBrowserRouter(
path: 'search',
element: <Search />,
},
{
path: 'prompts',
element: <Navigate to="/prompts/new" replace={true} />,
},
{
path: 'prompts/new',
lazy: loadInlinePromptsView,
},
{
path: 'prompts/:promptId',
lazy: loadInlinePromptsView,
},
{
path: 'agents',
element: (

View file

@ -37,7 +37,6 @@ const localStorageAtoms = {
forkSetting: atomWithLocalStorage('forkSetting', ''),
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
rememberDefaultFork: atomWithLocalStorage(LocalStorageKeys.REMEMBER_FORK_OPTION, false),
showThinking: atomWithLocalStorage('showThinking', false),
saveBadgesState: atomWithLocalStorage('saveBadgesState', false),
// Beta features settings

View file

@ -0,0 +1,31 @@
/**
* Babel plugin that replaces `import.meta.hot` with `undefined`.
*
* `babel-plugin-transform-import-meta` handles standard properties (url,
* filename, dirname, resolve) but does NOT handle the Vite-specific `hot`
* property. Jest runs in CommonJS/Node where `import.meta` is unavailable,
* so any reference to `import.meta.hot` causes a SyntaxError.
*
* Replacing it with `undefined` makes the HMR guard blocks dead-code:
* import.meta.hot?.data undefined?.data undefined
* if (import.meta.hot) if (undefined) skipped
*/
module.exports = function transformImportMetaHot() {
return {
name: 'transform-import-meta-hot',
visitor: {
MemberExpression(path) {
const { node } = path;
if (
node.object.type === 'MetaProperty' &&
node.object.meta.name === 'import' &&
node.object.property.name === 'meta' &&
node.property.type === 'Identifier' &&
node.property.name === 'hot'
) {
path.replaceWith({ type: 'Identifier', name: 'undefined' });
}
},
},
};
};

View file

@ -12,19 +12,19 @@ export default function MCPIcon({ className }: { className?: string }) {
<path
d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
<path
d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
<path
d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
</svg>

View file

@ -539,6 +539,7 @@ export type TPromptGroup = {
productionPrompt?: Pick<TPrompt, 'prompt'> | null;
author: string;
authorName: string;
isPublic?: boolean;
createdAt?: Date;
updatedAt?: Date;
_id?: string;