Merge branch 'dev' into feat/context-window-ui

This commit is contained in:
Marco Beretta 2025-12-29 02:07:54 +01:00
commit cb8322ca85
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
407 changed files with 25479 additions and 19894 deletions

View file

@ -1,9 +1,9 @@
import { defaultNS, resources } from '~/locales/i18n';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
strictKeyChecks: true
}
}
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
strictKeyChecks: true;
}
}

View file

@ -18,6 +18,16 @@ const App = () => {
const { setError } = useApiErrorBoundary();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Always attempt network requests, even when navigator.onLine is false
// This is needed because localhost is reachable without WiFi
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {

View file

@ -1,6 +1,13 @@
import { createContext, useContext } from 'react';
import useAddedResponse from '~/hooks/Chat/useAddedResponse';
type TAddedChatContext = ReturnType<typeof useAddedResponse>;
import type { TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { ConvoGenerator } from '~/common';
type TAddedChatContext = {
conversation: TConversation | null;
setConversation: SetterOrUpdater<TConversation | null>;
generateConversation: ConvoGenerator;
};
export const AddedChatContext = createContext<TAddedChatContext>({} as TAddedChatContext);
export const useAddedChatContext = () => useContext(AddedChatContext);

View file

@ -1,7 +1,8 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from './AgentsMapContext';
import { useChatContext } from './ChatContext';
interface DragDropContextValue {
@ -9,6 +10,7 @@ interface DragDropContextValue {
agentId: string | null | undefined;
endpoint: string | null | undefined;
endpointType?: EModelEndpoint | undefined;
useResponsesApi?: boolean;
}
const DragDropContext = createContext<DragDropContextValue | undefined>(undefined);
@ -16,6 +18,7 @@ const DragDropContext = createContext<DragDropContextValue | undefined>(undefine
export function DragDropProvider({ children }: { children: React.ReactNode }) {
const { conversation } = useChatContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const agentsMap = useAgentsMapContext();
const endpointType = useMemo(() => {
return (
@ -24,6 +27,34 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
);
}, [conversation?.endpoint, endpointsConfig]);
const needsAgentFetch = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id) {
return false;
}
const agent = agentsMap?.[conversation.agent_id];
return !agent?.model_parameters;
}, [conversation?.endpoint, conversation?.agent_id, agentsMap]);
const { data: agentData } = useGetAgentByIdQuery(conversation?.agent_id, {
enabled: needsAgentFetch,
});
const useResponsesApi = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
return conversation?.useResponsesApi;
}
const agent = agentData || agentsMap?.[conversation.agent_id];
return agent?.model_parameters?.useResponsesApi;
}, [
conversation?.endpoint,
conversation?.agent_id,
conversation?.useResponsesApi,
agentData,
agentsMap,
]);
/** Context value only created when conversation fields change */
const contextValue = useMemo<DragDropContextValue>(
() => ({
@ -31,8 +62,15 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
agentId: conversation?.agent_id,
endpoint: conversation?.endpoint,
endpointType: endpointType,
useResponsesApi: useResponsesApi,
}),
[conversation?.conversationId, conversation?.agent_id, conversation?.endpoint, endpointType],
[
conversation?.conversationId,
conversation?.agent_id,
conversation?.endpoint,
useResponsesApi,
endpointType,
],
);
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;

View file

@ -1,5 +1,4 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useAddedChatContext } from './AddedChatContext';
import { useChatContext } from './ChatContext';
interface MessagesViewContextValue {
@ -9,7 +8,6 @@ interface MessagesViewContextValue {
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
isSubmittingFamily: boolean;
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
@ -34,13 +32,12 @@ export type { MessagesViewContextValue };
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
const chatContext = useChatContext();
const addedChatContext = useAddedChatContext();
const {
ask,
index,
regenerate,
isSubmitting: isSubmittingRoot,
isSubmitting,
conversation,
latestMessage,
setAbortScroll,
@ -51,8 +48,6 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
setMessages,
} = chatContext;
const { isSubmitting: isSubmittingAdditional } = addedChatContext;
/** Memoize conversation-related values */
const conversationValues = useMemo(
() => ({
@ -65,12 +60,11 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
/** Memoize submission states */
const submissionStates = useMemo(
() => ({
isSubmitting: isSubmittingRoot,
isSubmittingFamily: isSubmittingRoot || isSubmittingAdditional,
abortScroll,
isSubmitting,
setAbortScroll,
}),
[isSubmittingRoot, isSubmittingAdditional, abortScroll, setAbortScroll],
[isSubmitting, abortScroll, setAbortScroll],
);
/** Memoize message operations (these are typically stable references) */
@ -127,11 +121,10 @@ export function useMessagesConversation() {
/** Hook for components that only need submission states */
export function useMessagesSubmission() {
const { isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll } =
useMessagesViewContext();
const { isSubmitting, abortScroll, setAbortScroll } = useMessagesViewContext();
return useMemo(
() => ({ isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll }),
[isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll],
() => ({ isSubmitting, abortScroll, setAbortScroll }),
[isSubmitting, abortScroll, setAbortScroll],
);
}

View file

@ -1,5 +1,5 @@
import { RefObject } from 'react';
import { Constants, FileSources, EModelEndpoint } from 'librechat-data-provider';
import { FileSources, EModelEndpoint, isEphemeralAgentId } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as InputNumberPrimitive from 'rc-input-number';
import type { SetterOrUpdater, RecoilState } from 'recoil';
@ -10,7 +10,7 @@ import type { TranslationKeys } from '~/hooks';
import { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
export function isEphemeralAgent(agentId: string | null | undefined): boolean {
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
return isEphemeralAgentId(agentId);
}
export interface ConfigFieldDetail {
@ -356,6 +356,8 @@ export type TOptions = {
isResubmission?: boolean;
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
overrideFiles?: t.TMessage['files'];
/** Added conversation for multi-convo feature - sent to server as part of submission payload */
addedConvo?: t.TConversation;
};
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;

View file

@ -1,21 +1,23 @@
import React, { useMemo } from 'react';
import { Label } from '@librechat/client';
import React, { useMemo, useState } from 'react';
import { Label, OGDialog, OGDialogTrigger } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import AgentDetailContent from './AgentDetailContent';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
onClick: () => void; // Callback when card is clicked
className?: string; // Additional CSS classes
agent: t.Agent;
onSelect?: (agent: t.Agent) => void;
className?: string;
}
/**
* Card component to display agent information
* Card component to display agent information with integrated detail dialog
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const AgentCard: React.FC<AgentCardProps> = ({ agent, onSelect, className = '' }) => {
const localize = useLocalize();
const { categories } = useAgentCategories();
const [isOpen, setIsOpen] = useState(false);
const categoryLabel = useMemo(() => {
if (!agent.category) return '';
@ -31,82 +33,89 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
return (
<div
className={cn(
'group relative h-40 overflow-hidden rounded-xl border border-border-light',
'cursor-pointer shadow-sm transition-all duration-200 hover:border-border-medium hover:shadow-lg',
'bg-surface-tertiary hover:bg-surface-hover',
'space-y-3 p-4',
className,
)}
onClick={onClick}
aria-label={localize('com_agents_agent_card_label', {
name: agent.name,
description: agent.description ?? '',
})}
aria-describedby={`agent-${agent.id}-description`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* Left column: Avatar and Category */}
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
const displayName = getContactDisplayName(agent);
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open && onSelect) {
onSelect(agent);
}
};
return (
<OGDialog open={isOpen} onOpenChange={handleOpenChange}>
<OGDialogTrigger asChild>
<div
className={cn(
'group relative flex h-32 gap-5 overflow-hidden rounded-xl',
'cursor-pointer select-none px-6 py-4',
'bg-surface-tertiary transition-colors duration-150 hover:bg-surface-hover',
'md:h-36 lg:h-40',
'[&_*]:cursor-pointer',
className,
)}
aria-label={localize('com_agents_agent_card_label', {
name: agent.name,
description: agent.description ?? '',
})}
aria-describedby={agent.description ? `agent-${agent.id}-description` : undefined}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsOpen(true);
}
}}
>
{/* Category badge - top right */}
{categoryLabel && (
<span className="absolute right-4 top-3 rounded-md bg-surface-hover px-2 py-0.5 text-xs text-text-secondary">
{categoryLabel}
</span>
)}
{/* Avatar */}
<div className="flex-shrink-0 self-center">
<div className="overflow-hidden rounded-full shadow-[0_0_15px_rgba(0,0,0,0.3)] dark:shadow-[0_0_15px_rgba(0,0,0,0.5)]">
{renderAgentAvatar(agent, { size: 'sm', showBorder: false })}
</div>
</div>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
{/* Agent name */}
<Label className="line-clamp-2 text-base font-semibold text-text-primary md:text-lg">
{agent.name}
</Label>
{/* Agent description */}
{agent.description && (
<p
id={`agent-${agent.id}-description`}
className="mt-0.5 line-clamp-2 text-sm leading-snug text-text-secondary md:line-clamp-5"
aria-label={localize('com_agents_description_card', {
description: agent.description,
})}
>
{agent.description}
</p>
)}
{/* Author */}
{displayName && (
<div className="mt-1 text-xs text-text-tertiary">
<span className="truncate">
{localize('com_ui_by_author', { 0: displayName || '' })}
</span>
</div>
)}
</div>
{/* Right column: Name, description, and other content */}
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
<div className="space-y-1">
{/* Agent name */}
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
{agent.name}
</Label>
{/* Agent description */}
<p
id={`agent-${agent.id}-description`}
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
{...(agent.description
? { 'aria-label': `Description: ${agent.description}` }
: {})}
>
{agent.description ?? ''}
</p>
</div>
{/* Owner info */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex justify-end">
<div className="flex items-center text-sm text-text-secondary">
<Label>{displayName}</Label>
</div>
</div>
);
}
return null;
})()}
</div>
</div>
</div>
</div>
</OGDialogTrigger>
<AgentDetailContent agent={agent} />
</OGDialog>
);
};

View file

@ -0,0 +1,192 @@
import React from 'react';
import { Link, Pin, PinOff } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { OGDialogContent, Button, useToastContext } from '@librechat/client';
import {
QueryKeys,
Constants,
EModelEndpoint,
PermissionBits,
LocalStorageKeys,
AgentListResponse,
} from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useChatContext } from '~/Providers';
interface SupportContact {
name?: string;
email?: string;
}
interface AgentWithSupport extends t.Agent {
support_contact?: SupportContact;
}
interface AgentDetailContentProps {
agent: AgentWithSupport;
}
/**
* Dialog content for displaying agent details
* Used inside OGDialog with OGDialogTrigger for proper focus management
*/
const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
const localize = useLocalize();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const getDefaultConversation = useDefaultConvo();
const { conversation, newConversation } = useChatContext();
const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites();
const isFavorite = isFavoriteAgent(agent?.id);
const handleFavoriteClick = () => {
if (agent) {
toggleFavoriteAgent(agent.id);
}
};
/**
* Navigate to chat with the selected agent
*/
const handleStartChat = () => {
if (agent) {
const keys = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }];
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
if (listResp != null) {
if (!listResp.data.some((a) => a.id === agent.id)) {
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
}
}
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
/** Template with agent configuration */
const template = {
conversationId: Constants.NEW_CONVO as string,
endpoint: EModelEndpoint.agents,
agent_id: agent.id,
title: localize('com_agents_chat_with', { name: agent.name || localize('com_ui_agent') }),
};
const currentConvo = getDefaultConversation({
conversation: { ...(conversation ?? {}), ...template },
preset: template,
});
newConversation({
template: currentConvo,
preset: template,
});
}
};
/**
* Copy the agent's shareable link to clipboard
*/
const handleCopyLink = () => {
const baseUrl = new URL(window.location.origin);
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
navigator.clipboard
.writeText(chatUrl)
.then(() => {
showToast({
message: localize('com_agents_link_copied'),
});
})
.catch(() => {
showToast({
message: localize('com_agents_link_copy_failed'),
});
});
};
/**
* Format contact information with mailto links when appropriate
*/
const formatContact = () => {
if (!agent?.support_contact) return null;
const { name, email } = agent.support_contact;
if (name && email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{name}
</a>
);
}
if (email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{email}
</a>
);
}
if (name) {
return <span>{name}</span>;
}
return null;
};
return (
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
{/* Agent avatar */}
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
{/* Agent name */}
<div className="mt-3 text-center">
<h2 className="text-2xl font-bold text-text-primary">
{agent?.name || localize('com_agents_loading')}
</h2>
</div>
{/* Contact info */}
{agent?.support_contact && formatContact() && (
<div className="mt-1 text-center text-sm text-text-secondary">
{localize('com_agents_contact')}: {formatContact()}
</div>
)}
{/* Agent description */}
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
{agent?.description}
</div>
{/* Action button */}
<div className="mb-4 mt-6 flex justify-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleFavoriteClick}
title={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
{isFavorite ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
title={localize('com_agents_copy_link')}
aria-label={localize('com_agents_copy_link')}
>
<Link className="h-4 w-4" aria-hidden="true" />
</Button>
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
{localize('com_agents_start_chat')}
</Button>
</div>
</OGDialogContent>
);
};
export default AgentDetailContent;

View file

@ -10,10 +10,10 @@ import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard';
interface AgentGridProps {
category: string; // Currently selected category
searchQuery: string; // Current search query
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
category: string;
searchQuery: string;
onSelectAgent: (agent: t.Agent) => void;
scrollElementRef?: React.RefObject<HTMLElement>;
}
/**
@ -184,7 +184,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
{/* Agent grid - 2 per row with proper semantic structure */}
{currentAgents && currentAgents.length > 0 && (
<div
className="grid grid-cols-1 gap-6 md:grid-cols-2"
className="mx-4 grid grid-cols-1 gap-6 md:grid-cols-2"
role="grid"
aria-label={localize('com_agents_grid_announcement', {
count: currentAgents.length,
@ -193,7 +193,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
>
{currentAgents.map((agent: t.Agent, index: number) => (
<div key={`${agent.id}-${index}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
<AgentCard agent={agent} onSelect={onSelectAgent} />
</div>
))}
</div>

View file

@ -15,7 +15,6 @@ import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus';
import { cn, clearMessagesCache } from '~/utils';
import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import store from '~/store';
@ -45,7 +44,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Get URL parameters
const searchQuery = searchParams.get('q') || '';
const selectedAgentId = searchParams.get('agent_id') || '';
// Animation state
type Direction = 'left' | 'right';
@ -58,10 +56,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Ref for the scrollable container to enable infinite scroll
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Local state
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
// Set page title
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
@ -102,28 +96,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}, [category, categoriesQuery.data, displayCategory]);
/**
* Handle agent card selection
*
* @param agent - The selected agent object
* Handle agent card selection - updates URL for deep linking
*/
const handleAgentSelect = (agent: t.Agent) => {
// Update URL with selected agent
const newParams = new URLSearchParams(searchParams);
newParams.set('agent_id', agent.id);
setSearchParams(newParams);
setSelectedAgent(agent);
setIsDetailOpen(true);
};
/**
* Handle closing the agent detail dialog
*/
const handleDetailClose = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('agent_id');
setSearchParams(newParams);
setSelectedAgent(null);
setIsDetailOpen(false);
};
/**
@ -229,11 +207,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
newConversation();
};
// Check if a detail view should be open based on URL
useEffect(() => {
setIsDetailOpen(!!selectedAgentId);
}, [selectedAgentId]);
// Layout configuration for SidePanelGroup
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
@ -295,7 +268,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-active-alt max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
@ -512,14 +485,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div>
</main>
</SidePanelGroup>

View file

@ -1,75 +1,20 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
DropdownPopup,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { Button, useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = {
[Permissions.USE]: boolean;
};
type LabelControllerProps = {
label: string;
marketplacePerm: Permissions.USE;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
marketplacePerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(marketplacePerm, !getValues(marketplacePerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={marketplacePerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
];
const MarketplaceAdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
const mutation = useUpdateMarketplacePermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -78,133 +23,27 @@ const MarketplaceAdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.MARKETPLACE];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData: {
marketplacePerm: Permissions.USE;
label: string;
}[] = [
{
marketplacePerm: Permissions.USE,
label: localize('com_ui_marketplace_allow_use'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
const trigger = (
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
</Button>
);
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize('com_ui_marketplace') })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ marketplacePerm, label }) => (
<div key={marketplacePerm}>
<LabelController
control={control}
marketplacePerm={marketplacePerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<AdminSettingsDialog
permissionType={PermissionTypes.MARKETPLACE}
sectionKey="com_ui_marketplace"
permissions={permissions}
menuId="marketplace-role-dropdown"
mutation={mutation}
trigger={trigger}
dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary"
showAdminWarning={false}
/>
);
};

View file

@ -97,6 +97,27 @@ jest.mock('~/hooks', () => ({
useLocalize: () => mockLocalize,
useDebounce: jest.fn(),
useAgentCategories: jest.fn(),
useDefaultConvo: jest.fn(() => jest.fn(() => ({}))),
useFavorites: jest.fn(() => ({
isFavoriteAgent: jest.fn(() => false),
toggleFavoriteAgent: jest.fn(),
})),
}));
// Mock Providers
jest.mock('~/Providers', () => ({
useChatContext: jest.fn(() => ({
conversation: null,
newConversation: jest.fn(),
})),
}));
// Mock @librechat/client toast context
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: jest.fn(() => ({
showToast: jest.fn(),
})),
}));
jest.mock('~/data-provider/Agents', () => ({
@ -115,6 +136,13 @@ jest.mock('../SmartLoader', () => ({
useHasData: jest.fn(() => true),
}));
// Mock AgentDetailContent to avoid testing dialog internals
jest.mock('../AgentDetailContent', () => ({
__esModule: true,
// eslint-disable-next-line i18next/no-literal-string
default: () => <div data-testid="agent-detail-content">Agent Detail Content</div>,
}));
// Import the actual modules to get the mocked functions
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories, useDebounce } from '~/hooks';
@ -299,7 +327,12 @@ describe('Accessibility Improvements', () => {
};
it('provides comprehensive ARIA labels', () => {
render(<AgentCard agent={mockAgent as t.Agent} onClick={jest.fn()} />);
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentCard agent={mockAgent as t.Agent} onSelect={jest.fn()} />
</Wrapper>,
);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
@ -308,16 +341,19 @@ describe('Accessibility Improvements', () => {
});
it('supports keyboard interaction', () => {
const onClick = jest.fn();
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentCard agent={mockAgent as t.Agent} onSelect={jest.fn()} />
</Wrapper>,
);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(card, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
// Card should be keyboard accessible - actual dialog behavior is handled by Radix
expect(card).toHaveAttribute('tabIndex', '0');
expect(() => fireEvent.keyDown(card, { key: 'Enter' })).not.toThrow();
expect(() => fireEvent.keyDown(card, { key: ' ' })).not.toThrow();
});
});

View file

@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentCard from '../AgentCard';
import type t from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
@ -11,25 +12,32 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
com_ui_by_author: 'by {{0}}',
com_agents_description_card: '{{description}}',
};
return mockTranslations[key] || key;
});
// Mock useAgentCategories hook
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<string, string>) => {
useLocalize: () => (key: string, values?: Record<string, string | number>) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
com_ui_by_author: 'by {{0}}',
com_agents_description_card: '{{description}}',
};
let translation = mockTranslations[key] || key;
// Replace placeholders with actual values
if (values) {
Object.entries(values).forEach(([placeholder, value]) => {
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
translation = translation.replace(
new RegExp(`\\{\\{${placeholder}\\}\\}`, 'g'),
String(value),
);
});
}
@ -42,8 +50,81 @@ jest.mock('~/hooks', () => ({
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
],
}),
useDefaultConvo: jest.fn(() => jest.fn(() => ({}))),
useFavorites: jest.fn(() => ({
isFavoriteAgent: jest.fn(() => false),
toggleFavoriteAgent: jest.fn(),
})),
}));
// Mock AgentDetailContent to avoid testing dialog internals
jest.mock('../AgentDetailContent', () => ({
__esModule: true,
// eslint-disable-next-line i18next/no-literal-string
default: () => <div data-testid="agent-detail-content">Agent Detail Content</div>,
}));
// Mock Providers
jest.mock('~/Providers', () => ({
useChatContext: jest.fn(() => ({
conversation: null,
newConversation: jest.fn(),
})),
}));
// Mock @librechat/client with proper Dialog behavior
jest.mock('@librechat/client', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require('react');
return {
...jest.requireActual('@librechat/client'),
useToastContext: jest.fn(() => ({
showToast: jest.fn(),
})),
OGDialog: ({ children, open, onOpenChange }: any) => {
// Store onOpenChange in context for trigger to call
return (
<div data-testid="dialog-wrapper" data-open={open}>
{React.Children.map(children, (child: any) => {
if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) {
return React.cloneElement(child, { onOpenChange });
}
// Only render content when open
if (child?.type?.displayName === 'OGDialogContent' && !open) {
return null;
}
return child;
})}
</div>
);
},
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
onClick: (e: any) => {
(children as any).props?.onClick?.(e);
onOpenChange?.(true);
},
});
}
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
},
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
};
});
// Create wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
@ -69,22 +150,30 @@ describe('AgentCard', () => {
},
};
const mockOnClick = jest.fn();
const mockOnSelect = jest.fn();
const Wrapper = createWrapper();
beforeEach(() => {
mockOnClick.mockClear();
mockOnSelect.mockClear();
});
it('renders agent information correctly', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.getByText('Test Support')).toBeInTheDocument();
});
it('displays avatar when provided as object', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
@ -97,7 +186,11 @@ describe('AgentCard', () => {
avatar: '/string-avatar.png' as any, // Legacy support for string avatars
};
render(<AgentCard agent={agentWithStringAvatar} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithStringAvatar} onSelect={mockOnSelect} />
</Wrapper>,
);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
@ -110,51 +203,73 @@ describe('AgentCard', () => {
avatar: undefined,
};
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithoutAvatar as any as t.Agent} onSelect={mockOnSelect} />
</Wrapper>,
);
// Check for Feather icon presence by looking for the svg with lucide-feather class
const featherIcon = document.querySelector('.lucide-feather');
expect(featherIcon).toBeInTheDocument();
});
it('calls onClick when card is clicked', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
it('card is clickable and has dialog trigger', () => {
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const card = screen.getByRole('button');
fireEvent.click(card);
expect(mockOnClick).toHaveBeenCalledTimes(1);
// Card should be clickable - the actual dialog behavior is handled by Radix
expect(card).toBeInTheDocument();
expect(() => fireEvent.click(card)).not.toThrow();
});
it('calls onClick when Enter key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
it('handles Enter key press', () => {
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
// Card should respond to keyboard - the actual dialog behavior is handled by Radix
expect(() => fireEvent.keyDown(card, { key: 'Enter' })).not.toThrow();
});
it('calls onClick when Space key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
it('handles Space key press', () => {
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: ' ' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
// Card should respond to keyboard - the actual dialog behavior is handled by Radix
expect(() => fireEvent.keyDown(card, { key: ' ' })).not.toThrow();
});
it('does not call onClick for other keys', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
it('does not call onSelect for other keys', () => {
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Escape' });
expect(mockOnClick).not.toHaveBeenCalled();
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('applies additional className when provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} className="custom-class" />
</Wrapper>,
);
const card = screen.getByRole('button');
expect(card).toHaveClass('custom-class');
@ -167,11 +282,14 @@ describe('AgentCard', () => {
authorName: undefined,
};
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithoutContact} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
});
it('displays authorName when support_contact is missing', () => {
@ -181,54 +299,21 @@ describe('AgentCard', () => {
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithAuthorName} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('displays support_contact email when name is missing', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: { email: 'contact@example.com' },
authorName: undefined,
};
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
});
it('prioritizes support_contact name over authorName', () => {
const agentWithBoth = {
...mockAgent,
support_contact: { name: 'Support Team' },
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
it('prioritizes name over email in support_contact', () => {
const agentWithNameAndEmail = {
...mockAgent,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
authorName: undefined,
};
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
expect(screen.getByText('by John Doe')).toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
@ -244,7 +329,11 @@ describe('AgentCard', () => {
category: 'general',
};
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithCategory} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('General')).toBeInTheDocument();
});
@ -255,7 +344,11 @@ describe('AgentCard', () => {
category: 'custom',
};
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithCustomCategory} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('Custom Category')).toBeInTheDocument();
});
@ -266,15 +359,35 @@ describe('AgentCard', () => {
category: 'unknown',
};
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={agentWithUnknownCategory} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not display category tag when category is not provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
render(
<Wrapper>
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
</Wrapper>,
);
expect(screen.queryByText('General')).not.toBeInTheDocument();
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
});
it('works without onSelect callback', () => {
render(
<Wrapper>
<AgentCard agent={mockAgent} />
</Wrapper>,
);
const card = screen.getByRole('button');
// Should not throw when clicking without onSelect
expect(() => fireEvent.click(card)).not.toThrow();
});
});

View file

@ -69,8 +69,8 @@ jest.mock('../ErrorDisplay', () => ({
// Mock AgentCard component
jest.mock('../AgentCard', () => ({
__esModule: true,
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
default: ({ agent, onSelect }: { agent: t.Agent; onSelect?: (agent: t.Agent) => void }) => (
<div data-testid={`agent-card-${agent.id}`} onClick={() => onSelect?.(agent)}>
<h3>{agent.name}</h3>
<p>{agent.description}</p>
</div>

View file

@ -11,9 +11,10 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde
const privacyPolicyRender = privacyPolicy?.externalUrl && (
<a
className="text-sm text-green-600 dark:text-green-500"
className="text-sm text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
href={privacyPolicy.externalUrl}
target={privacyPolicy.openNewTab ? '_blank' : undefined}
// Removed for WCAG compliance
// target={privacyPolicy.openNewTab ? '_blank' : undefined}
rel="noreferrer"
>
{localize('com_ui_privacy_policy')}
@ -22,9 +23,10 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde
const termsOfServiceRender = termsOfService?.externalUrl && (
<a
className="text-sm text-green-600 dark:text-green-500"
className="text-sm text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
href={termsOfService.externalUrl}
target={termsOfService.openNewTab ? '_blank' : undefined}
// Removed for WCAG compliance
// target={termsOfService.openNewTab ? '_blank' : undefined}
rel="noreferrer"
>
{localize('com_ui_terms_of_service')}

View file

@ -105,7 +105,7 @@ function Login() {
{localize('com_auth_no_account')}{' '}
<a
href={registerPage()}
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
>
{localize('com_auth_sign_up')}
</a>

View file

@ -147,7 +147,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
{startupConfig.passwordResetEnabled && (
<a
href="/forgot-password"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
>
{localize('com_auth_password_forgot')}
</a>

View file

@ -156,7 +156,6 @@ test('renders registration form', () => {
);
});
// eslint-disable-next-line jest/no-commented-out-tests
// test('calls registerUser.mutate on registration', async () => {
// const mutate = jest.fn();
// const { getByTestId, getByRole, history } = setup({

View file

@ -91,7 +91,7 @@ const BookmarkEditDialog = ({
<OGDialogTemplate
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
showCloseButton={false}
className="w-11/12 md:max-w-2xl"
className="w-11/12 md:max-w-lg"
main={
<BookmarkForm
tags={tags}

View file

@ -85,16 +85,11 @@ const BookmarkForm = ({
};
return (
<form
ref={formRef}
className="mt-6"
aria-label="Bookmark form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
<form ref={formRef} aria-label="Bookmark form" method="POST" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
{/* Tag name input */}
<div className="space-y-2">
<Label htmlFor="bookmark-tag" className="text-sm font-medium text-text-primary">
{localize('com_ui_bookmarks_title')}
</Label>
<Input
@ -118,24 +113,24 @@ const BookmarkForm = ({
);
},
})}
className="w-full"
aria-invalid={!!errors.tag}
placeholder={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
placeholder={localize('com_ui_enter_name')}
aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined}
/>
{errors.tag && (
<span id="bookmark-tag-error" role="alert" className="text-sm font-bold text-red-500">
<span id="bookmark-tag-error" role="alert" className="text-sm text-red-500">
{errors.tag.message}
</span>
)}
</div>
<div className="mt-4 grid w-full items-center gap-2">
{/* Description textarea */}
<div className="space-y-2">
<Label
id="bookmark-description-label"
htmlFor="bookmark-description"
className="text-left text-sm font-medium"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_bookmarks_description')}
</Label>
@ -151,14 +146,20 @@ const BookmarkForm = ({
})}
id="bookmark-description"
disabled={false}
placeholder={localize('com_ui_enter_description')}
className={cn(
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
'min-h-[100px] w-full resize-none rounded-lg border border-border-light',
'bg-transparent px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-tertiary',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy',
)}
aria-labelledby="bookmark-description-label"
/>
</div>
{/* Add to conversation checkbox */}
{conversationId != null && conversationId && (
<div className="mt-2 flex w-full items-center">
<div className="flex items-center gap-2">
<Controller
name="addToConversation"
control={control}
@ -167,7 +168,7 @@ const BookmarkForm = ({
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
className="size-4 cursor-pointer"
value={field.value?.toString()}
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
/>
@ -176,16 +177,14 @@ const BookmarkForm = ({
<button
type="button"
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
className="form-check-label w-full cursor-pointer text-text-primary"
className="cursor-pointer text-sm text-text-primary"
onClick={() =>
setValue('addToConversation', !(getValues('addToConversation') ?? false), {
shouldDirty: true,
})
}
>
<div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')}
</div>
{localize('com_ui_bookmarks_add_to_conversation')}
</button>
</div>
)}

View file

@ -16,7 +16,7 @@ function AddMultiConvo() {
setAddedConvo({
...convo,
title: '',
});
} as TConversation);
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
@ -34,13 +34,12 @@ function AddMultiConvo() {
return (
<TooltipAnchor
id="add-multi-conversation-button"
aria-label={localize('com_ui_add_multi_conversation')}
description={localize('com_ui_add_multi_conversation')}
tabIndex={0}
role="button"
tabIndex={0}
aria-label={localize('com_ui_add_multi_conversation')}
onClick={clickHandler}
data-testid="parameters-button"
data-testid="add-multi-convo-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<PlusCircle size={16} aria-hidden="true" />

View file

@ -7,7 +7,13 @@ import { Constants, buildTree } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import {
useResumableStreamToggle,
useAddedResponse,
useResumeOnLoad,
useAdaptiveSSE,
useChatHelpers,
} from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
@ -32,7 +38,6 @@ function LoadingSpinner() {
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const fileMap = useFileMapContext();
@ -49,10 +54,18 @@ function ChatView({ index = 0 }: { index?: number }) {
});
const chatHelpers = useChatHelpers(index, conversationId);
const addedChatHelpers = useAddedResponse({ rootIndex: index });
const addedChatHelpers = useAddedResponse();
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
useResumableStreamToggle(
chatHelpers.conversation?.endpoint,
chatHelpers.conversation?.endpointType,
);
useAdaptiveSSE(rootSubmission, chatHelpers, false, index);
// Auto-resume if navigating back to conversation with active job
// Wait for messages to load before resuming to avoid race condition
useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },

View file

@ -84,7 +84,7 @@ export default function ExportAndShareMenu({
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Share2
className="icon-md text-text-secondary"
className="icon-lg text-text-primary"
aria-hidden="true"
focusable="false"
/>

View file

@ -45,10 +45,10 @@ export default function Header() {
{!navVisible && (
<motion.div
className="flex items-center gap-2"
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />

View file

@ -1,10 +1,10 @@
import { useMemo } from 'react';
import type { TConversation, TEndpointOption, TPreset } from 'librechat-data-provider';
import { isAgentsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { useGetEndpointsQuery } from '~/data-provider';
import { EndpointIcon } from '~/components/Endpoints';
import { getPresetTitle } from '~/utils';
import { useAgentsMapContext } from '~/Providers';
export default function AddedConvo({
addedConvo,
@ -13,13 +13,23 @@ export default function AddedConvo({
addedConvo: TConversation | null;
setAddedConvo: SetterOrUpdater<TConversation | null>;
}) {
const getSender = useGetSender();
const agentsMap = useAgentsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const title = useMemo(() => {
const sender = getSender(addedConvo as TEndpointOption);
const title = getPresetTitle(addedConvo as TPreset);
return `+ ${sender}: ${title}`;
}, [addedConvo, getSender]);
// Priority: agent name > modelDisplayLabel > modelLabel > model
if (isAgentsEndpoint(addedConvo?.endpoint) && addedConvo?.agent_id) {
const agent = agentsMap?.[addedConvo.agent_id];
if (agent?.name) {
return `+ ${agent.name}`;
}
}
const endpointConfig = endpointsConfig?.[addedConvo?.endpoint ?? ''];
const displayLabel =
endpointConfig?.modelDisplayLabel || addedConvo?.modelLabel || addedConvo?.model || 'AI';
return `+ ${displayLabel}`;
}, [addedConvo, agentsMap, endpointsConfig]);
if (!addedConvo) {
return null;

View file

@ -100,7 +100,8 @@ function Artifacts() {
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
isButtonExpanded && 'rotate-180',
)}
aria-hidden="true" />
aria-hidden="true"
/>
</Ariakit.MenuButton>
<Ariakit.Menu

View file

@ -78,14 +78,11 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
handleStopGenerating,
} = useChatContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const assistantMap = useAssistantsMapContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const endpoint = useMemo(
() => conversation?.endpointType ?? conversation?.endpoint,
@ -131,7 +128,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
setFiles,
textAreaRef,
conversationId,
isSubmitting: isSubmitting || isSubmittingAdded,
isSubmitting,
});
const { submitMessage, submitPrompt } = useSubmitMessage();
@ -327,7 +324,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
</div>
<BadgeRow
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
isSubmitting={isSubmitting || isSubmittingAdded}
isSubmitting={isSubmitting}
conversationId={conversationId}
onChange={setBadges}
isInChat={
@ -346,7 +343,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
/>
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
{isSubmitting && showStopButton ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (

View file

@ -22,7 +22,7 @@ const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
)}
onKeyDownCapture={(e) => {
if (!inputRef.current) {

View file

@ -10,7 +10,8 @@ import {
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
import { useGetFileConfig, useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
@ -26,6 +27,28 @@ function AttachFileChat({
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
const agentsMap = useAgentsMapContext();
const needsAgentFetch = useMemo(() => {
if (!isAgents || !conversation?.agent_id) {
return false;
}
const agent = agentsMap?.[conversation.agent_id];
return !agent?.model_parameters;
}, [isAgents, conversation?.agent_id, agentsMap]);
const { data: agentData } = useGetAgentByIdQuery(conversation?.agent_id, {
enabled: needsAgentFetch,
});
const useResponsesApi = useMemo(() => {
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
return conversation?.useResponsesApi;
}
const agent = agentData || agentsMap?.[conversation.agent_id];
return agent?.model_parameters?.useResponsesApi;
}, [isAgents, conversation?.agent_id, conversation?.useResponsesApi, agentData, agentsMap]);
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
@ -68,6 +91,7 @@ function AttachFileChat({
conversationId={conversationId}
agentId={conversation?.agent_id}
endpointFileConfig={endpointFileConfig}
useResponsesApi={useResponsesApi}
/>
);
}

View file

@ -9,6 +9,7 @@ import {
TerminalSquareIcon,
} from 'lucide-react';
import {
Providers,
EToolResources,
EModelEndpoint,
defaultAgentCapabilities,
@ -36,6 +37,8 @@ import { ephemeralAgentByConvoId } from '~/store';
import { MenuItemProps } from '~/common';
import { cn } from '~/utils';
type FileUploadType = 'image' | 'document' | 'image_document' | 'image_document_video_audio';
interface AttachFileMenuProps {
agentId?: string | null;
endpoint?: string | null;
@ -43,6 +46,7 @@ interface AttachFileMenuProps {
conversationId: string;
endpointType?: EModelEndpoint;
endpointFileConfig?: EndpointFileConfig;
useResponsesApi?: boolean;
}
const AttachFileMenu = ({
@ -52,6 +56,7 @@ const AttachFileMenu = ({
endpointType,
conversationId,
endpointFileConfig,
useResponsesApi,
}: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
@ -83,9 +88,7 @@ const AttachFileMenu = ({
ephemeralAgent,
);
const handleUploadClick = (
fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal',
) => {
const handleUploadClick = (fileType?: FileUploadType) => {
if (!inputRef.current) {
return;
}
@ -94,9 +97,9 @@ const AttachFileMenu = ({
inputRef.current.accept = 'image/*';
} else if (fileType === 'document') {
inputRef.current.accept = '.pdf,application/pdf';
} else if (fileType === 'multimodal') {
} else if (fileType === 'image_document') {
inputRef.current.accept = 'image/*,.pdf,application/pdf';
} else if (fileType === 'google_multimodal') {
} else if (fileType === 'image_document_video_audio') {
inputRef.current.accept = 'image/*,.pdf,application/pdf,video/*,audio/*';
} else {
inputRef.current.accept = '';
@ -106,23 +109,33 @@ const AttachFileMenu = ({
};
const dropdownItems = useMemo(() => {
const createMenuItems = (
onAction: (fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal') => void,
) => {
const createMenuItems = (onAction: (fileType?: FileUploadType) => void) => {
const items: MenuItemProps[] = [];
const currentProvider = provider || endpoint;
let currentProvider = provider || endpoint;
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive
if (currentProvider?.toLowerCase() === Providers.OPENROUTER) {
currentProvider = Providers.OPENROUTER;
}
const isAzureWithResponsesApi =
currentProvider === EModelEndpoint.azureOpenAI && useResponsesApi;
if (
isDocumentSupportedProvider(endpointType) ||
isDocumentSupportedProvider(currentProvider)
isDocumentSupportedProvider(currentProvider) ||
isAzureWithResponsesApi
) {
items.push({
label: localize('com_ui_upload_provider'),
onClick: () => {
setToolResource(undefined);
onAction(
(provider || endpoint) === EModelEndpoint.google ? 'google_multimodal' : 'multimodal',
);
let fileType: Exclude<FileUploadType, 'image' | 'document'> = 'image_document';
if (currentProvider === Providers.GOOGLE || currentProvider === Providers.OPENROUTER) {
fileType = 'image_document_video_audio';
}
onAction(fileType);
},
icon: <FileImageIcon className="icon-md" />,
});
@ -204,6 +217,7 @@ const AttachFileMenu = ({
provider,
endpointType,
capabilities,
useResponsesApi,
setToolResource,
setEphemeralAgent,
sharePointEnabled,
@ -220,7 +234,7 @@ const AttachFileMenu = ({
id="attach-file-menu-button"
aria-label="Attach File Options"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
isPopoverActive && 'bg-surface-hover',
)}
>

View file

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { OGDialog, OGDialogTemplate } from '@librechat/client';
import {
Providers,
inferMimeType,
EToolResources,
EModelEndpoint,
@ -46,7 +47,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
* Use definition for agents endpoint for ephemeral agents
* */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { conversationId, agentId, endpoint, endpointType } = useDragDropContext();
const { conversationId, agentId, endpoint, endpointType, useResponsesApi } = useDragDropContext();
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? ''));
const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions(
agentId,
@ -55,15 +56,28 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
const options = useMemo(() => {
const _options: FileOption[] = [];
const currentProvider = provider || endpoint;
let currentProvider = provider || endpoint;
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive
if (currentProvider?.toLowerCase() === Providers.OPENROUTER) {
currentProvider = Providers.OPENROUTER;
}
/** Helper to get inferred MIME type for a file */
const getFileType = (file: File) => inferMimeType(file.name, file.type);
const isAzureWithResponsesApi =
currentProvider === EModelEndpoint.azureOpenAI && useResponsesApi;
// Check if provider supports document upload
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
const isGoogleProvider = currentProvider === EModelEndpoint.google;
const validFileTypes = isGoogleProvider
if (
isDocumentSupportedProvider(endpointType) ||
isDocumentSupportedProvider(currentProvider) ||
isAzureWithResponsesApi
) {
const supportsImageDocVideoAudio =
currentProvider === EModelEndpoint.google || currentProvider === Providers.OPENROUTER;
const validFileTypes = supportsImageDocVideoAudio
? files.every((file) => {
const type = getFileType(file);
return (
@ -123,6 +137,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
endpoint,
endpointType,
capabilities,
useResponsesApi,
codeAllowedByAgent,
fileSearchAllowedByAgent,
]);

View file

@ -5,7 +5,15 @@ import { useGetFiles } from '~/data-provider';
import { DataTable, columns } from './Table';
import { useLocalize } from '~/hooks';
export default function Files({ open, onOpenChange }) {
export function MyFilesModal({
open,
onOpenChange,
triggerRef,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef?: React.RefObject<HTMLButtonElement | HTMLDivElement | null>;
}) {
const localize = useLocalize();
const { data: files = [] } = useGetFiles<TFile[]>({
@ -18,7 +26,7 @@ export default function Files({ open, onOpenChange }) {
});
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogContent
title={localize('com_nav_my_files')}
className="w-11/12 bg-background text-text-primary shadow-2xl"

View file

@ -92,7 +92,8 @@ export const columns: ColumnDef<TFile>[] = [
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
aria-sort={ariaSort}
aria-label={localize('com_ui_name_sort')} aria-hidden="true"
aria-label={localize('com_ui_name_sort')}
aria-hidden="true"
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_name')}
@ -150,7 +151,8 @@ export const columns: ColumnDef<TFile>[] = [
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
aria-sort={ariaSort}
aria-label={localize('com_ui_date_sort')} aria-hidden="true"
aria-label={localize('com_ui_date_sort')}
aria-hidden="true"
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_date')}
@ -268,7 +270,8 @@ export const columns: ColumnDef<TFile>[] = [
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
aria-sort={ariaSort}
aria-label={localize('com_ui_size_sort')} aria-hidden="true"
aria-label={localize('com_ui_size_sort')}
aria-hidden="true"
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_size')}

View file

@ -1,5 +1,4 @@
import { useState } from 'react';
import { Search } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import {
flexRender,
@ -17,7 +16,6 @@ import type {
} from '@tanstack/react-table';
import { FileContext } from 'librechat-data-provider';
import {
Input,
Table,
Button,
Spinner,
@ -26,6 +24,7 @@ import {
TableCell,
TableHead,
TrashIcon,
FilterInput,
TableHeader,
useMediaQuery,
} from '@librechat/client';
@ -115,23 +114,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
)}
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
</Button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-text-secondary" />
<Input
id="files-filter"
placeholder=" "
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_files_filter_input')}
/>
<label
htmlFor="files-filter"
className="pointer-events-none absolute left-10 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
>
{localize('com_files_filter')}
</label>
</div>
<FilterInput
inputId="files-filter"
label={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
containerClassName="flex-1"
/>
<div className="relative focus-within:z-[100]">
<ColumnVisibilityDropdown
table={table}

View file

@ -33,12 +33,12 @@ export function SortFilterHeader<TData, TValue>({
{
label: localize('com_ui_ascending'),
onClick: () => column.toggleSorting(false),
icon: <ArrowUpIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
icon: <ArrowUpIcon className="icon-sm text-text-secondary" />,
},
{
label: localize('com_ui_descending'),
onClick: () => column.toggleSorting(true),
icon: <ArrowDownIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
icon: <ArrowDownIcon className="icon-sm text-text-secondary" />,
},
];
@ -56,9 +56,7 @@ export function SortFilterHeader<TData, TValue>({
items.push({
label: filterValue,
onClick: () => column.setFilterValue(value),
icon: (
<ListFilter className="h-3.5 w-3.5 text-muted-foreground/70" aria-hidden="true" />
),
icon: <ListFilter className="icon-sm text-text-secondary" aria-hidden="true" />,
show: true,
className: isActive ? 'border-l-2 border-l-border-xheavy' : '',
});
@ -70,7 +68,7 @@ export function SortFilterHeader<TData, TValue>({
items.push({
label: localize('com_ui_show_all'),
onClick: () => column.setFilterValue(undefined),
icon: <FilterX className="h-3.5 w-3.5 text-muted-foreground/70" />,
icon: <FilterX className="icon-sm text-text-secondary" />,
show: true,
});
}
@ -113,9 +111,9 @@ export function SortFilterHeader<TData, TValue>({
>
<span>{title}</span>
{column.getIsFiltered() ? (
<ListFilter className="icon-sm text-muted-foreground/70" aria-hidden="true" />
<ListFilter className="icon-sm" aria-hidden="true" />
) : (
<ListFilter className="icon-sm opacity-30" aria-hidden="true" />
<ListFilter className="icon-sm text-text-secondary" aria-hidden="true" />
)}
{(() => {
const sortState = column.getIsSorted();

View file

@ -278,7 +278,6 @@ describe('AttachFileMenu', () => {
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
{ name: 'Google', endpoint: EModelEndpoint.google },
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
{ name: 'Custom', endpoint: EModelEndpoint.custom },
];
@ -301,6 +300,45 @@ describe('AttachFileMenu', () => {
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
});
it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.azureOpenAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: true,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.azureOpenAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: false,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
});
describe('Agent Capabilities', () => {
@ -512,7 +550,7 @@ describe('AttachFileMenu', () => {
});
describe('Google Provider Special Case', () => {
it('should use google_multimodal file type for Google provider', () => {
it('should use image_document_video_audio file type for Google provider', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
@ -536,7 +574,7 @@ describe('AttachFileMenu', () => {
// The file input should have been clicked (indirectly tested through the implementation)
});
it('should use multimodal file type for non-Google providers', () => {
it('should use image_document file type for non-Google providers', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
@ -555,7 +593,7 @@ describe('AttachFileMenu', () => {
expect(uploadProviderButton).toBeInTheDocument();
fireEvent.click(uploadProviderButton);
// Implementation detail - multimodal type is used
// Implementation detail - image_document type is used
});
});

View file

@ -63,7 +63,6 @@ describe('DragDropModal - Provider Detection', () => {
{ name: 'OpenAI', value: EModelEndpoint.openAI },
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
{ name: 'Google', value: EModelEndpoint.google },
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
{ name: 'Custom', value: EModelEndpoint.custom },
];
@ -72,6 +71,10 @@ describe('DragDropModal - Provider Detection', () => {
expect(isDocumentSupportedProvider(value)).toBe(true);
});
});
it('should NOT recognize Azure OpenAI as supported (requires useResponsesApi)', () => {
expect(isDocumentSupportedProvider(EModelEndpoint.azureOpenAI)).toBe(false);
});
});
describe('real-world scenarios', () => {

View file

@ -1,8 +1,11 @@
import React, { memo, useCallback } from 'react';
import React, { memo, useMemo, useCallback, useRef } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { TooltipAnchor } from '@librechat/client';
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import StackedMCPIcons from '~/components/MCP/StackedMCPIcons';
import { useBadgeRowContext } from '~/Providers';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
@ -13,96 +16,117 @@ function MCPSelectContent() {
localize,
isPinned,
mcpValues,
isInitializing,
placeholderText,
batchToggleServers,
getConfigDialogProps,
getServerStatusIconProps,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const renderSelectedValues = useCallback(
(
values: string[],
placeholder?: string,
items?: (string | { label: string; value: string })[],
) => {
if (values.length === 0) {
return placeholder || localize('com_ui_select_placeholder');
}
if (values.length === 1) {
const selectedItem = items?.find((i) => typeof i !== 'string' && i.value == values[0]);
return selectedItem && typeof selectedItem !== 'string' ? selectedItem.label : values[0];
}
return localize('com_ui_x_selected', { 0: values.length });
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
const isOpen = menuStore.useState('open');
const focusedElementRef = useRef<HTMLElement | null>(null);
const selectedCount = mcpValues?.length ?? 0;
// Wrap toggleServerSelection to preserve focus after state update
const handleToggle = useCallback(
(serverName: string) => {
// Save currently focused element
focusedElementRef.current = document.activeElement as HTMLElement;
toggleServerSelection(serverName);
// Restore focus after React re-renders
requestAnimationFrame(() => {
focusedElementRef.current?.focus();
});
},
[localize],
[toggleServerSelection],
);
const renderItemContent = useCallback(
(serverName: string, defaultContent: React.ReactNode) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isServerInitializing = isInitializing(serverName);
const selectedServers = useMemo(() => {
if (!mcpValues || mcpValues.length === 0) {
return [];
}
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
}, [selectableServers, mcpValues]);
/**
Common wrapper for the main content (check mark + text).
Ensures Check & Text are adjacent and the group takes available space.
*/
const mainContentWrapper = (
<button
type="button"
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
isServerInitializing ? 'opacity-50' : ''
}`}
tabIndex={0}
disabled={isServerInitializing}
>
{defaultContent}
</button>
);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
if (statusIcon) {
return (
<div className="flex w-full items-center justify-between">
{mainContentWrapper}
<div className="ml-2 flex items-center">{statusIcon}</div>
</div>
);
}
return mainContentWrapper;
},
[getServerStatusIconProps, isInitializing],
);
const displayText = useMemo(() => {
if (selectedCount === 0) {
return null;
}
if (selectedCount === 1) {
const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]);
return server?.config?.title || mcpValues?.[0];
}
return localize('com_ui_x_selected', { 0: selectedCount });
}, [selectedCount, selectableServers, mcpValues, localize]);
if (!isPinned && mcpValues?.length === 0) {
return null;
}
const configDialogProps = getConfigDialogProps();
return (
<>
<MultiSelect
items={selectableServers.map((s) => ({
label: s.config.title || s.serverName,
value: s.serverName,
}))}
selectedValues={mcpValues ?? []}
setSelectedValues={batchToggleServers}
renderSelectedValues={renderSelectedValues}
renderItemContent={renderItemContent}
placeholder={placeholderText}
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
selectClassName={cn(
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
)}
/>
<Ariakit.MenuProvider store={menuStore}>
<TooltipAnchor
description={placeholderText}
disabled={isOpen}
render={
<Ariakit.MenuButton
className={cn(
'group relative inline-flex items-center justify-center gap-1.5',
'border border-border-medium text-sm font-medium transition-all',
'h-9 min-w-9 rounded-full bg-transparent px-2.5 shadow-sm',
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
'md:w-fit md:justify-start md:px-3',
isOpen && 'bg-surface-hover',
)}
/>
}
>
<StackedMCPIcons selectedServers={selectedServers} maxIcons={3} iconSize="sm" />
<span className="hidden truncate text-text-primary md:block">
{displayText || placeholderText}
</span>
<ChevronDown
className={cn(
'hidden h-3 w-3 text-text-secondary transition-transform md:block',
isOpen && 'rotate-180',
)}
/>
</TooltipAnchor>
<Ariakit.Menu
portal={true}
gutter={8}
aria-label={localize('com_ui_mcp_servers')}
className={cn(
'z-50 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
'border border-border-light bg-presentation p-1.5 shadow-lg',
'origin-top opacity-0 transition-[opacity,transform] duration-200 ease-out',
'data-[enter]:scale-100 data-[enter]:opacity-100',
'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0',
)}
>
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
{selectableServers.map((server) => (
<MCPServerMenuItem
key={server.serverName}
server={server}
isSelected={mcpValues?.includes(server.serverName) ?? false}
connectionStatus={connectionStatus}
isInitializing={isInitializing}
statusIconProps={getServerStatusIconProps(server.serverName)}
onToggle={handleToggle}
/>
))}
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && (
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
)}

View file

@ -1,10 +1,11 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { MCPIcon, PinIcon } from '@librechat/client';
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPSubMenuProps {
@ -13,14 +14,16 @@ interface MCPSubMenuProps {
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
const localize = useLocalize();
const { mcpServerManager } = useBadgeRowContext();
const {
isPinned,
mcpValues,
setIsPinned,
isInitializing,
placeholderText,
availableMCPServers,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
@ -33,7 +36,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
});
// Don't render if no MCP servers are configured
if (!availableMCPServers || availableMCPServers.length === 0) {
if (!selectableServers || selectableServers.length === 0) {
return null;
}
@ -44,6 +47,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<MCPIcon className="h-5 w-5 flex-shrink-0 text-text-primary" aria-hidden="true" />
<span>{placeholder || placeholderText}</span>
<ChevronRight className="ml-auto h-3 w-3" />
<ChevronRight className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
</div>
<button
type="button"
@ -70,55 +74,36 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
'hover:bg-surface-tertiary hover:shadow-sm',
!isPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isPinned ? 'Unpin' : 'Pin'}
aria-label={isPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
<div className="h-4 w-4">
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
aria-label={localize('com_ui_mcp_servers')}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
'animate-popover-left z-50 ml-3 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
'border border-border-light bg-presentation p-1.5 shadow-lg',
)}
>
{availableMCPServers.map((s) => {
const statusIconProps = getServerStatusIconProps(s.serverName);
const isSelected = mcpValues?.includes(s.serverName) ?? false;
const isServerInitializing = isInitializing(s.serverName);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
return (
<Ariakit.MenuItem
key={s.serverName}
onClick={(event) => {
event.preventDefault();
toggleServerSelection(s.serverName);
}}
disabled={isServerInitializing}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 justify-between text-sm',
isServerInitializing &&
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
isSelected && 'bg-surface-active',
)}
>
<div className="flex flex-grow items-center gap-2">
<Ariakit.MenuItemCheck checked={isSelected} />
<span>{s.config.title || s.serverName}</span>
</div>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem>
);
})}
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
{selectableServers.map((server) => (
<MCPServerMenuItem
key={server.serverName}
server={server}
isSelected={mcpValues?.includes(server.serverName) ?? false}
connectionStatus={connectionStatus}
isInitializing={isInitializing}
statusIconProps={getServerStatusIconProps(server.serverName)}
onToggle={toggleServerSelection}
/>
))}
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}

View file

@ -39,7 +39,7 @@ export default function StreamAudio({ index = 0 }) {
const { pauseGlobalAudio } = usePauseGlobalAudio();
const { conversationId: paramId } = useParams();
const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? '';
const queryParam = paramId === 'new' ? paramId : (latestMessage?.conversationId ?? paramId ?? '');
const queryClient = useQueryClient();
const getMessages = useCallback(

View file

@ -311,7 +311,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
id="tools-dropdown-button"
aria-label="Tools Options"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
isPopoverActive && 'bg-surface-hover',
)}
>

View file

@ -147,9 +147,9 @@ const BookmarkMenu: FC = () => {
return <Spinner aria-label="Spinner" />;
}
if ((tags?.length ?? 0) > 0) {
return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />;
return <BookmarkFilledIcon className="icon-lg" aria-label="Filled Bookmark" />;
}
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
return <BookmarkIcon className="icon-lg" aria-label="Bookmark" />;
};
return (

View file

@ -48,8 +48,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
!parent &&
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary',
menuStore.useState('open')
? 'bg-surface-tertiary hover:bg-surface-tertiary'
: 'bg-surface-secondary hover:bg-surface-tertiary',
? 'bg-surface-active-alt hover:bg-surface-active-alt'
: 'bg-presentation hover:bg-surface-active-alt',
props.className,
)}
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
@ -66,7 +66,7 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
className={cn(
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light',
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
'bg-presentation px-3 py-2 text-sm text-text-primary shadow-lg',
'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]',
searchable && 'p-0',
)}
@ -80,13 +80,13 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
autoSelect
render={combobox}
className={cn(
'peer mt-1 h-10 w-full rounded-lg border-none bg-transparent px-2 text-base',
'peer flex h-10 w-full items-center justify-center rounded-lg border-none bg-transparent px-2 text-base',
'sm:h-8 sm:text-sm',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary',
)}
/>
{comboboxLabel && (
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-surface-secondary peer-[:not(:placeholder-shown)]:text-xs">
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-presentation peer-[:not(:placeholder-shown)]:text-xs sm:top-1.5">
{comboboxLabel}
</label>
)}
@ -168,7 +168,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
blurOnHoverEnd: false,
...props,
className: cn(
'relative flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
props.className,
),
};

View file

@ -65,7 +65,7 @@ function ModelSelectorContent() {
description={localize('com_ui_select_model')}
render={
<button
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary"
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
aria-label={localize('com_ui_select_model')}
>
{selectedIcon && React.isValidElement(selectedIcon) && (

View file

@ -104,10 +104,18 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
});
// State
const [selectedValues, setSelectedValues] = useState<SelectedValues>({
endpoint: endpoint || '',
model: model || '',
modelSpec: spec || '',
const [selectedValues, setSelectedValues] = useState<SelectedValues>(() => {
let initialModel = model || '';
if (isAgentsEndpoint(endpoint) && agent_id) {
initialModel = agent_id;
} else if (isAssistantsEndpoint(endpoint) && assistant_id) {
initialModel = assistant_id;
}
return {
endpoint: endpoint || '',
model: initialModel,
modelSpec: spec || '',
};
});
useSelectorEffects({
agentsMap,

View file

@ -3,13 +3,15 @@ import type { TModelSpec } from 'librechat-data-provider';
import { CustomMenu as Menu } from '../CustomMenu';
import { ModelSpecItem } from './ModelSpecItem';
import { useModelSelectorContext } from '../ModelSelectorContext';
import GroupIcon from './GroupIcon';
interface CustomGroupProps {
groupName: string;
specs: TModelSpec[];
groupIcon?: string;
}
export function CustomGroup({ groupName, specs }: CustomGroupProps) {
export function CustomGroup({ groupName, specs, groupIcon }: CustomGroupProps) {
const { selectedValues } = useModelSelectorContext();
const { modelSpec: selectedSpec } = selectedValues;
@ -25,6 +27,11 @@ export function CustomGroup({ groupName, specs }: CustomGroupProps) {
label={
<div className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm">
<div className="flex items-center gap-2">
{groupIcon && (
<div className="flex-shrink-0">
<GroupIcon iconURL={groupIcon} groupName={groupName} />
</div>
)}
<span className="truncate text-left">{groupName}</span>
</div>
</div>
@ -45,22 +52,27 @@ export function renderCustomGroups(
const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value));
// Group specs by their group field (excluding endpoint-matched groups and ungrouped)
// Also track the groupIcon for each group (first spec with groupIcon wins)
const customGroups = modelSpecs.reduce(
(acc, spec) => {
if (!spec.group || endpointValues.has(spec.group)) {
return acc;
}
if (!acc[spec.group]) {
acc[spec.group] = [];
acc[spec.group] = { specs: [], groupIcon: undefined };
}
acc[spec.group].specs.push(spec);
// Use the first groupIcon found for the group
if (!acc[spec.group].groupIcon && spec.groupIcon) {
acc[spec.group].groupIcon = spec.groupIcon;
}
acc[spec.group].push(spec);
return acc;
},
{} as Record<string, TModelSpec[]>,
{} as Record<string, { specs: TModelSpec[]; groupIcon?: string }>,
);
// Render each custom group
return Object.entries(customGroups).map(([groupName, specs]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} />
return Object.entries(customGroups).map(([groupName, { specs, groupIcon }]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} groupIcon={groupIcon} />
));
}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Spinner } from '@librechat/client';
import { Spinner, TooltipAnchor } from '@librechat/client';
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
@ -14,6 +14,7 @@ import { cn } from '~/utils';
interface EndpointItemProps {
endpoint: Endpoint;
endpointIndex: number;
}
const SettingsButton = ({
@ -27,34 +28,58 @@ const SettingsButton = ({
}) => {
const localize = useLocalize();
const text = localize('com_endpoint_config_key');
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!endpoint.value) {
return;
}
e.stopPropagation();
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (endpoint.value) {
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
}
}
};
return (
<button
type="button"
id={`endpoint-${endpoint.value}-settings`}
onClick={(e) => {
if (!endpoint.value) {
return;
}
e.stopPropagation();
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'flex items-center overflow-visible text-text-primary transition-all duration-300 ease-in-out',
'group/button rounded-md px-1 hover:bg-surface-secondary focus:bg-surface-secondary',
'group/button flex items-center gap-1.5 rounded-md px-1.5',
'text-text-secondary transition-colors duration-150',
'hover:bg-surface-tertiary hover:text-text-primary',
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
className,
)}
aria-label={`${text} ${endpoint.label}`}
>
<div className="flex w-[28px] items-center gap-1 whitespace-nowrap transition-all duration-300 ease-in-out group-hover:w-auto group-focus/button:w-auto">
<SettingsIcon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-300 ease-in-out group-hover:max-w-[100px] group-hover:opacity-100 group-focus/button:max-w-[100px] group-focus/button:opacity-100">
{text}
</span>
</div>
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
<span
aria-hidden="true"
className={cn(
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
'grid-cols-[0fr] opacity-0',
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
)}
>
<span className="min-w-0 truncate pr-0.5">{text}</span>
</span>
</button>
);
};
export function EndpointItem({ endpoint }: EndpointItemProps) {
export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
const localize = useLocalize();
const {
agentsMap,
@ -87,21 +112,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
[endpointRequiresUserKey, endpoint.value],
);
const isAssistantsNotLoaded =
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
const renderIconLabel = () => (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{endpoint.icon && (
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
{endpoint.icon}
</div>
)}
<span
className={cn(
'truncate text-left',
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
)}
>
{endpoint.label}
</span>
<span className="truncate text-left">{endpoint.label}</span>
</div>
);
@ -123,17 +144,14 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
<Menu
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
className="transition-opacity duration-200 ease-in-out"
defaultOpen={endpoint.value === selectedEndpoint}
searchValue={searchValue}
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
combobox={<input placeholder=" " />}
comboboxLabel={placeholder}
onClick={() => handleSelectEndpoint(endpoint)}
label={
<div
onClick={() => handleSelectEndpoint(endpoint)}
className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm"
>
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
{renderIconLabel()}
{isUserProvided && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
@ -142,8 +160,12 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
}
>
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
<div className="flex items-center justify-center p-2">
<Spinner />
<div
className="flex items-center justify-center p-2"
role="status"
aria-label={localize('com_ui_loading')}
>
<Spinner aria-hidden="true" />
</div>
) : (
<>
@ -153,8 +175,21 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
))}
{/* Render endpoint models */}
{filteredModels
? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
: endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)}
? renderEndpointModels(
endpoint,
endpoint.models || [],
selectedModel,
filteredModels,
endpointIndex,
)
: endpoint.models &&
renderEndpointModels(
endpoint,
endpoint.models,
selectedModel,
undefined,
endpointIndex,
)}
</>
)}
</Menu>
@ -165,32 +200,27 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
onClick={() => handleSelectEndpoint(endpoint)}
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm"
>
<div className="group flex w-full min-w-0 items-center justify-between">
{renderIconLabel()}
<div className="flex items-center gap-2">
{endpointRequiresUserKey(endpoint.value) && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{selectedEndpoint === endpoint.value && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="block"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
</div>
{renderIconLabel()}
<div className="flex shrink-0 items-center gap-2">
{endpointRequiresUserKey(endpoint.value) && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{isAssistantsNotLoaded && (
<TooltipAnchor
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
side="top"
render={
<span className="flex items-center">
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{selectedEndpoint === endpoint.value && !isAssistantsNotLoaded && (
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
)}
</div>
</MenuItem>
);
@ -198,7 +228,11 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
}
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
return mappedEndpoints.map((endpoint) => (
<EndpointItem endpoint={endpoint} key={`endpoint-${endpoint.value}-item`} />
return mappedEndpoints.map((endpoint, index) => (
<EndpointItem
endpoint={endpoint}
endpointIndex={index}
key={`endpoint-${endpoint.value}-${index}`}
/>
));
}

View file

@ -109,7 +109,6 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
return (
<MenuItem
ref={itemRef}
key={modelId}
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
className="group flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
>
@ -161,14 +160,16 @@ export function renderEndpointModels(
models: Array<{ name: string; isGlobal?: boolean }>,
selectedModel: string | null,
filteredModels?: string[],
endpointIndex?: number,
) {
const modelsToRender = filteredModels || models.map((model) => model.name);
const indexSuffix = endpointIndex != null ? `-${endpointIndex}` : '';
return modelsToRender.map(
(modelId) =>
(modelId, modelIndex) =>
endpoint && (
<EndpointModelItem
key={modelId}
key={`${endpoint.value}${indexSuffix}-${modelId}-${modelIndex}`}
modelId={modelId}
endpoint={endpoint}
isSelected={selectedModel === modelId}

View file

@ -0,0 +1,60 @@
import React, { memo, useState } from 'react';
import { AlertCircle } from 'lucide-react';
import type { IconMapProps } from '~/common';
import { icons } from '~/hooks/Endpoint/Icons';
interface GroupIconProps {
iconURL: string;
groupName: string;
}
type IconType = (props: IconMapProps) => React.JSX.Element;
const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
// Check if the iconURL is a built-in icon key
if (iconURL in icons) {
const Icon: IconType = (icons[iconURL] ?? icons.unknown) as IconType;
return <Icon size={20} context="menu-item" className="icon-md shrink-0 text-text-primary" />;
}
if (imageError) {
const DefaultIcon: IconType = icons.unknown as IconType;
return (
<div className="relative" style={{ width: 20, height: 20, margin: '2px' }}>
<div className="icon-md shrink-0 overflow-hidden rounded-full">
<DefaultIcon context="menu-item" size={20} />
</div>
{imageError && iconURL && (
<div
className="absolute flex items-center justify-center rounded-full bg-red-500"
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
>
<AlertCircle size={10} className="text-white" />
</div>
)}
</div>
);
}
return (
<div
className="icon-md shrink-0 overflow-hidden rounded-full"
style={{ width: 20, height: 20 }}
>
<img
src={iconURL}
alt={groupName}
className="h-full w-full object-cover"
onError={handleImageError}
/>
</div>
);
};
export default memo(GroupIcon);

View file

@ -29,7 +29,7 @@ export default function HeaderNewChat() {
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl duration-0 hover:bg-surface-active-alt max-md:hidden"
onClick={clickHandler}
>
<NewChatIcon />

View file

@ -33,7 +33,7 @@ export const data: TModelSpec[] = [
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
preset: {
chatGptLabel: 'Vision Helper',
greeting: 'What\'s up!!',
greeting: "What's up!!",
endpoint: EModelEndpoint.openAI,
model: 'gpt-4-turbo',
promptPrefix:

View file

@ -1,3 +1,4 @@
import { startTransition } from 'react';
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -17,9 +18,13 @@ export default function OpenSidebar({
const localize = useLocalize();
const handleClick = () => {
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
// Use startTransition to mark this as a non-urgent update
// This prevents blocking the main thread during the cascade of re-renders
startTransition(() => {
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
});
// Delay focus until after the sidebar animation completes (200ms)
setTimeout(() => {
@ -39,10 +44,7 @@ export default function OpenSidebar({
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="chat-history-nav"
className={cn(
'rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover',
className,
)}
className={cn('rounded-xl duration-0 hover:bg-surface-active-alt', className)}
onClick={handleClick}
>
<Sidebar aria-hidden="true" />

View file

@ -49,16 +49,22 @@ const PresetsMenu: FC = () => {
<Trigger asChild>
<TooltipAnchor
ref={presetsMenuTriggerRef}
id="presets-button"
aria-label={localize('com_endpoint_examples')}
description={localize('com_endpoint_examples')}
tabIndex={0}
role="button"
data-testid="presets-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy size={16} aria-hidden="true" />
</TooltipAnchor>
render={
<Button
size="icon"
variant="outline"
tabIndex={0}
id="presets-button"
data-testid="presets-button"
aria-label={localize('com_endpoint_examples')}
className="rounded-xl p-2 duration-0 hover:bg-surface-active-alt max-md:hidden"
// className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy className="icon-lg" aria-hidden="true" />
</Button>
}
></TooltipAnchor>
</Trigger>
<Portal>
<div
@ -74,7 +80,7 @@ const PresetsMenu: FC = () => {
<Content
side="bottom"
align="center"
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white md:min-w-[400px]"
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary shadow-lg md:min-w-[400px]"
>
<PresetItems
presets={presetsQuery.data}

View file

@ -55,7 +55,7 @@ const MenuItem: FC<MenuItemProps> = ({
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className={cn('flex items-center gap-1 ')}>
<div className={cn('flex items-center gap-1')}>
{icon != null ? icon : null}
<div className={cn('truncate', textClassName)}>
{title}
@ -72,7 +72,7 @@ const MenuItem: FC<MenuItemProps> = ({
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block "
className="icon-md block"
>
<path
fillRule="evenodd"

View file

@ -1,4 +1,4 @@
import { memo, useMemo } from 'react';
import { memo, useMemo, useCallback } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import type {
TMessageContentParts,
@ -7,10 +7,12 @@ import type {
Agents,
} from 'librechat-data-provider';
import { MessageContext, SearchContext } from '~/Providers';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import { mapAttachments } from '~/utils';
import { EditTextPart, EmptyText } from './Parts';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import { mapAttachments } from '~/utils/map';
import { EditTextPart } from './Parts';
import Container from './Container';
import Part from './Part';
type ContentPartsProps = {
@ -32,112 +34,159 @@ type ContentPartsProps = {
| undefined;
};
const ContentParts = memo(
({
content,
messageId,
conversationId,
attachments,
searchResults,
isCreatedByUser,
isLast,
isSubmitting,
isLatestMessage,
edit,
enterEdit,
siblingIdx,
setSiblingIdx,
}: ContentPartsProps) => {
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
/**
* ContentParts renders message content parts, handling both sequential and parallel layouts.
*
* For 90% of messages (single-agent, no parallel execution), this renders sequentially.
* For multi-agent parallel execution, it uses ParallelContentRenderer to show columns.
*/
const ContentParts = memo(function ContentParts({
edit,
isLast,
content,
messageId,
enterEdit,
siblingIdx,
attachments,
isSubmitting,
setSiblingIdx,
searchResults,
conversationId,
isCreatedByUser,
isLatestMessage,
}: ContentPartsProps) {
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
/**
* Render a single content part with proper context.
*/
const renderPart = useCallback(
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
if (!content) {
return null;
}
if (edit === true && enterEdit && setSiblingIdx) {
return (
<>
{content.map((part, idx) => {
if (!part) {
return null;
}
const isTextPart =
part?.type === ContentTypes.TEXT ||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
const isThinkPart =
part?.type === ContentTypes.THINK ||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
if (!isTextPart && !isThinkPart) {
return null;
}
const isToolCall =
part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
if (isToolCall) {
return null;
}
return (
<EditTextPart
index={idx}
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
messageId={messageId}
isSubmitting={isSubmitting}
enterEdit={enterEdit}
siblingIdx={siblingIdx ?? null}
setSiblingIdx={setSiblingIdx}
key={`edit-${messageId}-${idx}`}
/>
);
})}
</>
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded: true,
conversationId,
partIndex: idx,
nextType: content?.[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
/>
</MessageContext.Provider>
);
}
},
[
attachmentMap,
content,
conversationId,
effectiveIsSubmitting,
isCreatedByUser,
isLast,
isLatestMessage,
messageId,
],
);
// Early return: no content
if (!content) {
return null;
}
// Edit mode: render editable text parts
if (edit === true && enterEdit && setSiblingIdx) {
return (
<>
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{content.map((part, idx) => {
if (!part) {
return null;
}
{content.map((part, idx) => {
if (!part) {
return null;
}
const isTextPart =
part?.type === ContentTypes.TEXT ||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
const isThinkPart =
part?.type === ContentTypes.THINK ||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
if (!isTextPart && !isThinkPart) {
return null;
}
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
const isToolCall = part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
if (isToolCall) {
return null;
}
return (
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded: true,
conversationId,
partIndex: idx,
nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1}
showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
</SearchContext.Provider>
return (
<EditTextPart
index={idx}
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
messageId={messageId}
isSubmitting={isSubmitting}
enterEdit={enterEdit}
siblingIdx={siblingIdx ?? null}
setSiblingIdx={setSiblingIdx}
key={`edit-${messageId}-${idx}`}
/>
);
})}
</>
);
},
);
}
const showEmptyCursor = content.length === 0 && effectiveIsSubmitting;
const lastContentIdx = content.length - 1;
// Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata)
const hasParallelContent = content.some((part) => part?.groupId != null);
if (hasParallelContent) {
return (
<ParallelContentRenderer
content={content}
messageId={messageId}
conversationId={conversationId}
attachments={attachments}
searchResults={searchResults}
isSubmitting={effectiveIsSubmitting}
renderPart={renderPart}
/>
);
}
// Sequential content: render parts in order (90% of cases)
const sequentialParts: PartWithIndex[] = [];
content.forEach((part, idx) => {
if (part) {
sequentialParts.push({ part, idx });
}
});
return (
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{showEmptyCursor && (
<Container>
<EmptyText />
</Container>
)}
{sequentialParts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
</SearchContext.Provider>
);
});
export default ContentParts;

View file

@ -1,10 +1,11 @@
import { useRef, useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilValue } from 'recoil';
import { TextareaAutosize, TooltipAnchor } from '@librechat/client';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common';
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import { useMessagesOperations, useMessagesConversation } from '~/Providers';
import { useGetAddedConvo } from '~/hooks/Chat';
import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks';
import Container from './Container';
@ -19,14 +20,10 @@ const EditMessage = ({
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const { addedIndex } = useAddedChatContext();
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
const { conversation } = useMessagesConversation();
const { getMessages, setMessages } = useMessagesOperations();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
@ -37,6 +34,8 @@ const EditMessage = ({
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const getAddedConvo = useGetAddedConvo();
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
text: text ?? '',
@ -62,6 +61,7 @@ const EditMessage = ({
},
{
overrideFiles: message.files,
addedConvo: getAddedConvo() || undefined,
},
);
@ -80,6 +80,7 @@ const EditMessage = ({
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
addedConvo: getAddedConvo() || undefined,
},
);
@ -101,10 +102,6 @@ const EditMessage = ({
messageId,
});
if (message.messageId === latestMultiMessage?.messageId) {
setLatestMultiMessage({ ...latestMultiMessage, text: data.text });
}
const isInMessages = messages.some((message) => message.messageId === messageId);
if (!isInMessages) {
message.text = data.text;

View file

@ -2,7 +2,9 @@ import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import Mermaid from '~/components/Messages/Content/Mermaid';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useFileDownload } from '~/data-provider';
import { useCodeBlockContext } from '~/Providers';
@ -24,10 +26,11 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
const isMath = lang === 'math';
const isMermaid = lang === 'mermaid';
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
const { getNextIndex, resetCounter } = useCodeBlockContext();
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
const blockIndex = useRef(getNextIndex(isMath || isMermaid || isSingleLine)).current;
useEffect(() => {
resetCounter();
@ -35,6 +38,13 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
if (isMath) {
return <>{children}</>;
} else if (isMermaid) {
const content = typeof children === 'string' ? children : String(children);
return (
<MermaidErrorBoundary code={content}>
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
</MermaidErrorBoundary>
);
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
@ -59,6 +69,9 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
if (lang === 'math') {
return children;
} else if (lang === 'mermaid') {
const content = typeof children === 'string' ? children : String(children);
return <Mermaid>{content}</Mermaid>;
} else if (typeof children === 'string' && children.split('\n').length === 1) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>

View file

@ -0,0 +1,269 @@
import { memo, useMemo } from 'react';
import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider';
import { SearchContext } from '~/Providers';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import { EmptyText } from './Parts';
import SiblingHeader from './SiblingHeader';
import Container from './Container';
import { cn } from '~/utils';
export type PartWithIndex = { part: TMessageContentParts; idx: number };
export type ParallelColumn = {
agentId: string;
parts: PartWithIndex[];
};
export type ParallelSection = {
groupId: number;
columns: ParallelColumn[];
};
/**
* Groups content parts by groupId for parallel rendering.
* Parts with same groupId are displayed in columns, grouped by agentId.
*
* @param content - Array of content parts
* @returns Object containing parallel sections and sequential parts
*/
export function groupParallelContent(
content: Array<TMessageContentParts | undefined> | undefined,
): { parallelSections: ParallelSection[]; sequentialParts: PartWithIndex[] } {
if (!content) {
return { parallelSections: [], sequentialParts: [] };
}
const groupMap = new Map<number, PartWithIndex[]>();
// Track placeholder agentIds per groupId (parts with empty type that establish columns)
const placeholderAgents = new Map<number, Set<string>>();
const noGroup: PartWithIndex[] = [];
content.forEach((part, idx) => {
if (!part) {
return;
}
// Read metadata directly from content part (TMessageContentParts includes ContentMetadata)
const { groupId } = part;
// Check for placeholder (empty type) before narrowing - access agentId via casting
const partAgentId = (part as { agentId?: string }).agentId;
if (groupId != null) {
// Track placeholder parts (empty type) to establish columns for pending agents
if (!part.type && partAgentId) {
if (!placeholderAgents.has(groupId)) {
placeholderAgents.set(groupId, new Set());
}
placeholderAgents.get(groupId)!.add(partAgentId);
return; // Don't add to groupMap - we'll handle these separately
}
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId)!.push({ part, idx });
} else {
noGroup.push({ part, idx });
}
});
// Collect all groupIds (from both real content and placeholders)
const allGroupIds = new Set([...groupMap.keys(), ...placeholderAgents.keys()]);
// Build parallel sections with columns grouped by agentId
const sections: ParallelSection[] = [];
for (const groupId of allGroupIds) {
const columnMap = new Map<string, PartWithIndex[]>();
const parts = groupMap.get(groupId) ?? [];
for (const { part, idx } of parts) {
// Read agentId directly from content part (TMessageContentParts includes ContentMetadata)
const agentId = part.agentId ?? 'unknown';
if (!columnMap.has(agentId)) {
columnMap.set(agentId, []);
}
columnMap.get(agentId)!.push({ part, idx });
}
// Add empty columns for placeholder agents that don't have real content yet
const groupPlaceholders = placeholderAgents.get(groupId);
if (groupPlaceholders) {
for (const placeholderAgentId of groupPlaceholders) {
if (!columnMap.has(placeholderAgentId)) {
// Empty array signals this column should show loading state
columnMap.set(placeholderAgentId, []);
}
}
}
// Sort columns: primary agent (no ____N suffix) first, added agents (with suffix) second
// This ensures consistent column ordering regardless of which agent responds first
const sortedAgentIds = Array.from(columnMap.keys()).sort((a, b) => {
const aHasSuffix = a.includes('____');
const bHasSuffix = b.includes('____');
if (aHasSuffix && !bHasSuffix) {
return 1;
}
if (!aHasSuffix && bHasSuffix) {
return -1;
}
return 0;
});
const columns = sortedAgentIds.map((agentId) => ({
agentId,
parts: columnMap.get(agentId)!,
}));
sections.push({ groupId, columns });
}
// Sort sections by the minimum index in each section (sections with only placeholders go last)
sections.sort((a, b) => {
const aParts = a.columns.flatMap((c) => c.parts.map((p) => p.idx));
const bParts = b.columns.flatMap((c) => c.parts.map((p) => p.idx));
const aMin = aParts.length > 0 ? Math.min(...aParts) : Infinity;
const bMin = bParts.length > 0 ? Math.min(...bParts) : Infinity;
return aMin - bMin;
});
return { parallelSections: sections, sequentialParts: noGroup };
}
type ParallelColumnsProps = {
columns: ParallelColumn[];
groupId: number;
messageId: string;
isSubmitting: boolean;
lastContentIdx: number;
conversationId?: string | null;
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
};
/**
* Renders parallel content columns for a single groupId.
*/
export const ParallelColumns = memo(function ParallelColumns({
columns,
groupId,
messageId,
conversationId,
isSubmitting,
lastContentIdx,
renderPart,
}: ParallelColumnsProps) {
return (
<div className={cn('flex w-full flex-col gap-3 md:flex-row', 'sibling-content-group')}>
{columns.map(({ agentId, parts: columnParts }, colIdx) => {
// Show loading cursor if column has no content parts yet (empty array from placeholder)
const showLoadingCursor = isSubmitting && columnParts.length === 0;
return (
<div
key={`column-${messageId}-${groupId}-${agentId || colIdx}`}
className="min-w-0 flex-1 rounded-lg border border-border-light p-3"
>
<SiblingHeader
agentId={agentId}
messageId={messageId}
isSubmitting={isSubmitting}
conversationId={conversationId}
/>
{showLoadingCursor ? (
<Container>
<EmptyText />
</Container>
) : (
columnParts.map(({ part, idx }) => {
const isLastInColumn = idx === columnParts[columnParts.length - 1]?.idx;
const isLastContent = idx === lastContentIdx;
return renderPart(part, idx, isLastInColumn && isLastContent);
})
)}
</div>
);
})}
</div>
);
});
type ParallelContentRendererProps = {
content: Array<TMessageContentParts | undefined>;
messageId: string;
conversationId?: string | null;
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
isSubmitting: boolean;
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
};
/**
* Renders content with parallel sections (columns) and sequential parts.
* Handles the layout of before/parallel/after content sections.
*/
export const ParallelContentRenderer = memo(function ParallelContentRenderer({
content,
messageId,
conversationId,
attachments,
searchResults,
isSubmitting,
renderPart,
}: ParallelContentRendererProps) {
const { parallelSections, sequentialParts } = useMemo(
() => groupParallelContent(content),
[content],
);
const lastContentIdx = content.length - 1;
// Split sequential parts into before/after parallel sections
const { before, after } = useMemo(() => {
if (parallelSections.length === 0) {
return { before: sequentialParts, after: [] };
}
const allParallelIndices = parallelSections.flatMap((s) =>
s.columns.flatMap((c) => c.parts.map((p) => p.idx)),
);
const minParallelIdx = Math.min(...allParallelIndices);
const maxParallelIdx = Math.max(...allParallelIndices);
return {
before: sequentialParts.filter(({ idx }) => idx < minParallelIdx),
after: sequentialParts.filter(({ idx }) => idx > maxParallelIdx),
};
}, [parallelSections, sequentialParts]);
return (
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{/* Sequential content BEFORE parallel sections */}
{before.map(({ part, idx }) => renderPart(part, idx, false))}
{/* Parallel sections - each group renders as columns */}
{parallelSections.map(({ groupId, columns }) => (
<ParallelColumns
key={`parallel-group-${messageId}-${groupId}`}
columns={columns}
groupId={groupId}
messageId={messageId}
renderPart={renderPart}
isSubmitting={isSubmitting}
conversationId={conversationId}
lastContentIdx={lastContentIdx}
/>
))}
{/* Sequential content AFTER parallel sections */}
{after.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
</SearchContext.Provider>
);
});
export default ParallelContentRenderer;

View file

@ -1,14 +1,15 @@
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { ContentTypes } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Lightbulb, MessageSquare } from 'lucide-react';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common';
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
import { useMessagesOperations, useMessagesConversation } from '~/Providers';
import Container from '~/components/Chat/Messages/Content/Container';
import { useGetAddedConvo } from '~/hooks/Chat';
import { cn, removeFocusRings } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -25,12 +26,8 @@ const EditTextPart = ({
part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate;
}) => {
const localize = useLocalize();
const { addedIndex } = useAddedChatContext();
const { conversation } = useMessagesConversation();
const { ask, getMessages, setMessages } = useMessagesOperations();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
const { conversationId = '' } = conversation ?? {};
const message = useMemo(
@ -40,6 +37,8 @@ const EditTextPart = ({
const chatDirection = useRecoilValue(store.chatDirection);
const getAddedConvo = useGetAddedConvo();
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? '');
@ -87,6 +86,7 @@ const EditTextPart = ({
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
addedConvo: getAddedConvo() || undefined,
},
);
@ -105,10 +105,6 @@ const EditTextPart = ({
messageId,
});
if (messageId === latestMultiMessage?.messageId) {
setLatestMultiMessage({ ...latestMultiMessage, text: data.text });
}
const isInMessages = messages.some((msg) => msg.messageId === messageId);
if (!isInMessages) {
return enterEdit(true);

View file

@ -15,7 +15,7 @@ export default function ProgressCircle({
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
>
<circle
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
className="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
strokeWidth="7.826086956521739"
fill="transparent"
r={radius}

View file

@ -0,0 +1,140 @@
import { useMemo } from 'react';
import { GitBranchPlus } from 'lucide-react';
import { useToastContext } from '@librechat/client';
import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider';
import type { TMessage, Agent } from 'librechat-data-provider';
import { useBranchMessageMutation } from '~/data-provider/Messages';
import MessageIcon from '~/components/Share/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type SiblingHeaderProps = {
/** The agentId from the content part (could be real agent ID or endpoint__model format) */
agentId?: string;
/** The messageId of the parent message */
messageId?: string;
/** The conversationId */
conversationId?: string | null;
/** Whether a submission is in progress */
isSubmitting?: boolean;
};
/**
* Header component for sibling content parts in parallel agent responses.
* Displays the agent/model icon and name for each parallel response.
*/
export default function SiblingHeader({
agentId,
messageId,
conversationId,
isSubmitting,
}: SiblingHeaderProps) {
const agentsMap = useAgentsMapContext();
const localize = useLocalize();
const { showToast } = useToastContext();
const branchMessage = useBranchMessageMutation(conversationId ?? null, {
onSuccess: () => {
showToast({
message: localize('com_ui_branch_created'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_branch_error'),
status: 'error',
});
},
});
const handleBranch = () => {
if (!messageId || !agentId || isSubmitting || branchMessage.isLoading) {
return;
}
branchMessage.mutate({ messageId, agentId });
};
const { displayName, displayEndpoint, displayModel, agent } = useMemo(() => {
// First, try to look up as a real agent
if (agentId) {
// Strip ____N suffix if present (used to distinguish parallel agents with same ID)
const baseAgentId = stripAgentIdSuffix(agentId);
const foundAgent = agentsMap?.[baseAgentId] as Agent | undefined;
if (foundAgent) {
return {
displayName: foundAgent.name,
displayEndpoint: EModelEndpoint.agents,
displayModel: foundAgent.model,
agent: foundAgent,
};
}
// Try to parse as ephemeral agent ID (endpoint__model___sender format)
const parsed = parseEphemeralAgentId(agentId);
if (parsed) {
return {
displayName: parsed.sender || parsed.model || 'AI',
displayEndpoint: parsed.endpoint,
displayModel: parsed.model,
agent: undefined,
};
}
// agentId exists but couldn't be parsed as ephemeral - use it as-is for display
return {
displayName: baseAgentId,
displayEndpoint: EModelEndpoint.agents,
displayModel: undefined,
agent: undefined,
};
}
// Use message model/endpoint as last resort
return {
displayName: 'Agent',
displayEndpoint: EModelEndpoint.agents,
displayModel: undefined,
agent: undefined,
};
}, [agentId, agentsMap]);
return (
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border-light pb-2">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
<MessageIcon
message={
{
endpoint: displayEndpoint,
model: displayModel,
isCreatedByUser: false,
} as TMessage
}
agent={agent || undefined}
/>
</div>
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
</div>
{messageId && agentId && !isSubmitting && (
<button
type="button"
onClick={handleBranch}
disabled={isSubmitting || branchMessage.isLoading}
className={cn(
'flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md',
'text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary',
'focus:outline-none focus:ring-2 focus:ring-border-medium focus:ring-offset-1',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
aria-label={localize('com_ui_branch_message')}
title={localize('com_ui_branch_message')}
>
<GitBranchPlus className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
}

View file

@ -216,7 +216,7 @@ function FeedbackButtons({
function buttonClasses(isActive: boolean, isLast: boolean) {
return cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',

View file

@ -227,7 +227,7 @@ export default function Fork({
});
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',

View file

@ -82,7 +82,7 @@ const HoverButton = memo(
className = '',
}: HoverButtonProps) => {
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
@ -213,7 +213,10 @@ const HoverButtons = ({
}
icon={isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
isLast={isLast}
className={`ml-0 flex items-center gap-1.5 text-xs ${isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : ''}`}
className={cn(
'ml-0 flex items-center gap-1.5 text-xs',
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
/>
{/* Edit Button */}

View file

@ -1,12 +1,8 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { useMessageProcess } from '~/hooks';
import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
import store from '~/store';
const MessageContainer = React.memo(
({
@ -29,16 +25,10 @@ const MessageContainer = React.memo(
);
export default function Message(props: TMessageProps) {
const {
showSibling,
conversation,
handleScroll,
siblingMessage,
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { conversation, handleScroll } = useMessageProcess({
message: props.message,
});
const { message, currentEditId, setCurrentEditId } = props;
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
if (!message || typeof message !== 'object') {
return null;
@ -49,34 +39,9 @@ export default function Message(props: TMessageProps) {
return (
<>
<MessageContainer handleScroll={handleScroll}>
{showSibling ? (
<div className="m-auto my-2 flex justify-center p-4 py-2 md:gap-6">
<div
className={cn(
'flex w-full flex-row flex-wrap justify-between gap-1 md:flex-nowrap md:gap-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-5xl xl:max-w-6xl',
)}
>
<MessageRender
{...props}
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
/>
<MessageRender
{...props}
isMultiMessage
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} />
</div>
)}
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} />
</div>
</MessageContainer>
<MultiMessage
key={messageId}

View file

@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts';
import { fontSizeAtom } from '~/store/fontSize';
@ -75,15 +75,25 @@ export default function Message(props: TMessageProps) {
],
);
const { hasParallelContent } = useContentMetadata(message);
if (!message) {
return null;
}
const getChatWidthClass = () => {
if (maximizeChatSpace) {
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
}
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
chat: getChatWidthClass(),
};
return (
@ -99,20 +109,25 @@ export default function Message(props: TMessageProps) {
aria-label={getMessageAriaLabel(message, localize)}
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
</div>
)}
<div
className={cn(
'relative flex w-11/12 flex-col',
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
{name}
</h2>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
{name}
</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
@ -21,6 +21,7 @@ function MessagesViewContent({
const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const scrollToBottomRef = useRef<HTMLButtonElement>(null);
const {
conversation,
@ -87,8 +88,9 @@ function MessagesViewContent({
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
nodeRef={scrollToBottomRef}
>
<ScrollToBottom scrollHandler={handleSmoothToRef} />
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={handleSmoothToRef} />
</CSSTransition>
</div>
</div>

View file

@ -24,7 +24,7 @@ export default function SiblingSwitch({
};
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',

View file

@ -8,18 +8,16 @@ import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers';
import { useLocalize, useMessageActions } from '~/hooks';
import { cn, getMessageAriaLabel, logger } from '~/utils';
import store from '~/store';
type MessageRenderProps = {
message?: TMessage;
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
isSubmitting?: boolean;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@ -28,14 +26,12 @@ type MessageRenderProps = {
const MessageRender = memo(
({
message: msg,
isCard = false,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
isSubmitting = false,
}: MessageRenderProps) => {
const localize = useLocalize();
const {
@ -47,17 +43,14 @@ const MessageRender = memo(
enterEdit,
conversation,
messageLabel,
isSubmitting,
latestMessage,
handleFeedback,
handleContinue,
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
} = useMessageActions({
message: msg,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
@ -70,9 +63,6 @@ const MessageRender = memo(
[hasNoChildren, msg?.depth, latestMessage?.depth],
);
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
@ -95,36 +85,28 @@ const MessageRender = memo(
],
);
const clickHandler = useMemo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(
'latest_message',
`Message Card click: Setting ${msg?.messageId} as latest message`,
);
logger.dir(msg);
setLatestMessage(msg!);
}
: undefined,
[showCardRender, isLatestMessage, msg, setLatestMessage],
);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
return null;
}
const getChatWidthClass = () => {
if (maximizeChatSpace) {
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
}
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
chat: getChatWidthClass(),
};
const conditionalClasses = {
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
cardRender: showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
};
@ -134,38 +116,29 @@ const MessageRender = memo(
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
isCard ? baseClasses.card : baseClasses.chat,
conditionalClasses.latestCard,
conditionalClasses.cardRender,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
onClick={clickHandler}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
clickHandler();
}
}}
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
)}
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
</div>
)}
<div
className={cn(
'relative flex w-11/12 flex-col',
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
@ -194,9 +167,8 @@ const MessageRender = memo(
/>
</MessageContext.Provider>
</div>
{hasNoChildren && (isSubmittingFamily === true || effectiveIsSubmitting) ? (
<PlaceholderRow isCard={isCard} />
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch

View file

@ -1,9 +1,6 @@
import { memo } from 'react';
const PlaceholderRow = memo(({ isCard }: { isCard?: boolean }) => {
if (!isCard) {
return null;
}
const PlaceholderRow = memo(() => {
return <div className="mt-1 h-[27px] bg-transparent" />;
});

View file

@ -14,8 +14,6 @@ export function TemporaryChat() {
const temporaryBadge = {
id: 'temporary',
icon: MessageCircleDashed,
label: 'com_ui_temporary' as const,
atom: store.isTemporary,
isAvailable: true,
};
@ -37,26 +35,20 @@ export function TemporaryChat() {
return (
<div className="relative flex flex-wrap items-center gap-2">
<TooltipAnchor
description={localize(temporaryBadge.label)}
description={localize('com_ui_temporary')}
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
aria-label={localize('com_ui_temporary')}
aria-pressed={isTemporary}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out',
isTemporary
? 'bg-surface-active shadow-md'
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:shadow-inner',
? 'bg-surface-active'
: 'bg-presentation shadow-sm hover:bg-surface-active-alt',
)}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
aria-hidden="true"
/>
)}
<MessageCircleDashed className="icon-lg" aria-hidden="true" />
</button>
}
/>

View file

@ -7,6 +7,7 @@ import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtuali
import type { TConversation } from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { useActiveJobs } from '~/data-provider';
import { groupConversationsByDate, cn } from '~/utils';
import Convo from './Convo';
import store from '~/store';
@ -120,18 +121,28 @@ const MemoizedConvo = memo(
conversation,
retainView,
toggleNav,
isGenerating,
}: {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isGenerating: boolean;
}) => {
return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />;
return (
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isGenerating={isGenerating}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint
prevProps.conversation.endpoint === nextProps.conversation.endpoint &&
prevProps.isGenerating === nextProps.isGenerating
);
},
);
@ -149,11 +160,19 @@ const Conversations: FC<ConversationsProps> = ({
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
const resumableEnabled = useRecoilValue(store.resumableStreams);
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
const showAgentMarketplace = useShowMarketplace();
// Fetch active job IDs for showing generation indicators
const { data: activeJobsData } = useActiveJobs(resumableEnabled);
const activeJobIds = useMemo(
() => new Set(activeJobsData?.activeJobIds ?? []),
[activeJobsData?.activeJobIds],
);
// Determine if FavoritesList will render content
const shouldShowFavorites =
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
@ -250,7 +269,7 @@ const Conversations: FC<ConversationsProps> = ({
if (item.type === 'loading') {
return (
<MeasuredRow {...rowProps}>
<MeasuredRow key={key} {...rowProps}>
<LoadingSpinner />
</MeasuredRow>
);
@ -258,7 +277,7 @@ const Conversations: FC<ConversationsProps> = ({
if (item.type === 'favorites') {
return (
<MeasuredRow {...rowProps}>
<MeasuredRow key={key} {...rowProps}>
<FavoritesList
isSmallScreen={isSmallScreen}
toggleNav={toggleNav}
@ -270,7 +289,7 @@ const Conversations: FC<ConversationsProps> = ({
if (item.type === 'chats-header') {
return (
<MeasuredRow {...rowProps}>
<MeasuredRow key={key} {...rowProps}>
<ChatsHeader
isExpanded={isChatsExpanded}
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
@ -285,16 +304,22 @@ const Conversations: FC<ConversationsProps> = ({
// Without favorites: [chats-header, first-header] → index 1
const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
return (
<MeasuredRow {...rowProps}>
<MeasuredRow key={key} {...rowProps}>
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
</MeasuredRow>
);
}
if (item.type === 'convo') {
const isGenerating = activeJobIds.has(item.convo.conversationId ?? '');
return (
<MeasuredRow {...rowProps}>
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
<MeasuredRow key={key} {...rowProps}>
<MemoizedConvo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isGenerating={isGenerating}
/>
</MeasuredRow>
);
}
@ -311,6 +336,7 @@ const Conversations: FC<ConversationsProps> = ({
isChatsExpanded,
setIsChatsExpanded,
shouldShowFavorites,
activeJobIds,
],
);

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
@ -19,9 +19,15 @@ interface ConversationProps {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isGenerating?: boolean;
}
export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) {
export default function Conversation({
conversation,
retainView,
toggleNav,
isGenerating = false,
}: ConversationProps) {
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
@ -37,6 +43,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
const [titleInput, setTitleInput] = useState(title || '');
const [renaming, setRenaming] = useState(false);
const [isPopoverActive, setIsPopoverActive] = useState(false);
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
const [hasInteracted, setHasInteracted] = useState(false);
const previousTitle = useRef(title);
@ -95,6 +103,12 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
setRenaming(false);
};
const handleMouseEnter = useCallback(() => {
if (!hasInteracted) {
setHasInteracted(true);
}
}, [hasInteracted]);
const handleNavigation = (ctrlOrMetaKey: boolean) => {
if (ctrlOrMetaKey) {
toggleNav();
@ -143,6 +157,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
aria-label={localize('com_ui_conversation_label', {
title: title || localize('com_ui_untitled'),
})}
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
onClick={(e) => {
if (renaming) {
return;
@ -183,12 +199,35 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
isSmallScreen={isSmallScreen}
localize={localize}
>
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
size={20}
context="menu-item"
/>
{isGenerating ? (
<svg
className="h-5 w-5 flex-shrink-0 animate-spin text-text-primary"
viewBox="0 0 24 24"
fill="none"
aria-label={localize('com_ui_generating')}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
size={20}
context="menu-item"
/>
)}
</ConvoLink>
)}
<div
@ -203,7 +242,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
// but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete.
// aria-hidden={!(isPopoverActive || isActiveConvo)}
>
{!renaming && <ConvoOptions {...convoOptionsProps} />}
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
{!renaming && (hasInteracted || isActiveConvo) && <ConvoOptions {...convoOptionsProps} />}
</div>
</div>
);

View file

@ -122,11 +122,9 @@ function ConvoOptions({
if (!convoId) {
return;
}
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
const thread_id = messages?.[messages.length - 1]?.thread_id;
const endpoint = messages?.[messages.length - 1]?.endpoint;
deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' });
},
[conversationId, deleteMutation, queryClient],
@ -148,12 +146,10 @@ function ConvoOptions({
setTimeout(() => {
setAnnouncement('');
}, 10000);
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
setIsPopoverActive(false);
},

View file

@ -6,7 +6,7 @@ import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import SharedLinkButton from './SharedLinkButton';
import { cn } from '~/utils';
import { buildShareLinkUrl, cn } from '~/utils';
import store from '~/store';
export default function ShareButton({
@ -40,8 +40,7 @@ export default function ShareButton({
useEffect(() => {
if (share?.shareId !== undefined) {
const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
setSharedLink(link);
setSharedLink(buildShareLinkUrl(share.shareId));
}
}, [share]);

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useRef } from 'react';
import { Trans } from 'react-i18next';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import {
@ -20,6 +20,7 @@ import {
useDeleteSharedLinkMutation,
} from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { buildShareLinkUrl } from '~/utils';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
@ -85,9 +86,7 @@ export default function SharedLinkButton({
},
});
const generateShareLink = useCallback((shareId: string) => {
return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
}, []);
const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId);
const updateSharedLink = async () => {
if (!shareId) {

View file

@ -71,7 +71,14 @@ export default function MCPConfigDialog({
});
}, [serverStatus, serverName, localize]);
// Helper function to render status badge based on connection state
/**
* Render status badge with unified color system:
* - Blue: Connecting/In-progress
* - Amber: Needs action (OAuth required)
* - Gray: Disconnected (neutral/inactive)
* - Green: Connected (success)
* - Red: Error
*/
const renderStatusBadge = () => {
if (!serverStatus) {
return null;
@ -79,46 +86,51 @@ export default function MCPConfigDialog({
const { connectionState, requiresOAuth } = serverStatus;
// Connecting: blue (in progress)
if (connectionState === 'connecting') {
return (
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Spinner className="h-3 w-3" />
<Spinner className="size-3" />
<span>{localize('com_ui_connecting')}</span>
</div>
);
}
// Disconnected: check if needs action
if (connectionState === 'disconnected') {
if (requiresOAuth) {
// Needs OAuth: amber (requires action)
return (
<div className="flex items-center gap-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-950 dark:text-amber-400">
<KeyRound className="h-3 w-3" aria-hidden="true" />
<span>{localize('com_ui_oauth')}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-2 rounded-full bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-600 dark:bg-orange-950 dark:text-orange-400">
<PlugZap className="h-3 w-3" aria-hidden="true" />
<span>{localize('com_ui_offline')}</span>
<KeyRound className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
</div>
);
}
// Simply disconnected: gray (neutral)
return (
<div className="flex items-center gap-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<PlugZap className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_disconnected')}</span>
</div>
);
}
// Error: red
if (connectionState === 'error') {
return (
<div className="flex items-center gap-2 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-950 dark:text-red-400">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
<AlertTriangle className="size-3" aria-hidden="true" />
<span>{localize('com_ui_error')}</span>
</div>
);
}
// Connected: green
if (connectionState === 'connected') {
return (
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-950 dark:text-green-400">
<div className="size-1.5 rounded-full bg-green-500" />
<span>{localize('com_ui_active')}</span>
</div>
);

View file

@ -0,0 +1,113 @@
import * as Ariakit from '@ariakit/react';
import { Check } from 'lucide-react';
import { MCPIcon } from '@librechat/client';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
import MCPServerStatusIcon from './MCPServerStatusIcon';
import {
getStatusColor,
getStatusTextKey,
shouldShowActionButton,
type ConnectionStatusMap,
} from './mcpServerUtils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPServerMenuItemProps {
server: MCPServerDefinition;
isSelected: boolean;
connectionStatus?: ConnectionStatusMap;
isInitializing?: (serverName: string) => boolean;
statusIconProps?: MCPServerStatusIconProps | null;
onToggle: (serverName: string) => void;
}
export default function MCPServerMenuItem({
server,
isSelected,
connectionStatus,
isInitializing,
statusIconProps,
onToggle,
}: MCPServerMenuItemProps) {
const localize = useLocalize();
const displayName = server.config?.title || server.serverName;
const statusColor = getStatusColor(server.serverName, connectionStatus, isInitializing);
const statusTextKey = getStatusTextKey(server.serverName, connectionStatus, isInitializing);
const statusText = localize(statusTextKey as Parameters<typeof localize>[0]);
const showActionButton = shouldShowActionButton(statusIconProps);
// Include status in aria-label so screen readers announce it
const accessibleLabel = `${displayName}, ${statusText}`;
return (
<Ariakit.MenuItemCheckbox
hideOnClick={false}
name="mcp-servers"
value={server.serverName}
checked={isSelected}
setValueOnChange={false}
onChange={() => onToggle(server.serverName)}
aria-label={accessibleLabel}
className={cn(
'group flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2',
'outline-none transition-all duration-150',
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
isSelected && 'bg-surface-active-alt',
)}
>
{/* Server Icon with Status Dot */}
<div className="relative flex-shrink-0">
{server.config?.iconPath ? (
<img
src={server.config.iconPath}
className="h-8 w-8 rounded-lg object-cover"
alt={displayName}
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-tertiary">
<MCPIcon className="h-5 w-5 text-text-secondary" />
</div>
)}
{/* Status dot - decorative, status is announced via aria-label on MenuItem */}
<div
aria-hidden="true"
className={cn(
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-secondary',
statusColor,
)}
/>
</div>
{/* Server Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
</div>
{server.config?.description && (
<p className="truncate text-xs text-text-secondary">{server.config.description}</p>
)}
</div>
{/* Action Button - only show when actionable */}
{showActionButton && statusIconProps && (
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<MCPServerStatusIcon {...statusIconProps} />
</div>
)}
{/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */}
<span
aria-hidden="true"
className={cn(
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border-xheavy bg-transparent',
)}
>
{isSelected && <Check className="h-4 w-4" />}
</span>
</Ariakit.MenuItemCheckbox>
);
}

View file

@ -1,8 +1,9 @@
import React from 'react';
import { Spinner, TooltipAnchor } from '@librechat/client';
import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X, CircleCheck } from 'lucide-react';
import type { MCPServerStatus, TPlugin } from 'librechat-data-provider';
import { Spinner } from '@librechat/client';
import { PlugZap, SlidersHorizontal, X } from 'lucide-react';
import type { MCPServerStatus } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
let localize: ReturnType<typeof useLocalize>;
@ -16,34 +17,48 @@ interface InitializingStatusProps extends StatusIconProps {
canCancel: boolean;
}
interface MCPServerStatusIconProps {
export interface MCPServerStatusIconProps {
serverName: string;
serverStatus?: MCPServerStatus;
tool?: TPlugin;
onConfigClick: (e: React.MouseEvent) => void;
isInitializing: boolean;
canCancel: boolean;
onCancel: (e: React.MouseEvent) => void;
hasCustomUserVars?: boolean;
/** When true, renders as a small status dot for compact layouts */
compact?: boolean;
}
/**
* Renders the appropriate status icon for an MCP server based on its state
* Renders the appropriate status icon for an MCP server based on its state.
*
* Unified icon system:
* - PlugZap: Connect/Authenticate (for disconnected servers that need connection)
* - SlidersHorizontal: Configure (for connected servers with custom vars)
* - Spinner: Loading state (during connection)
* - X: Cancel (during OAuth flow, shown on hover over spinner)
*/
export default function MCPServerStatusIcon({
serverName,
serverStatus,
tool,
onConfigClick,
isInitializing,
canCancel,
onCancel,
hasCustomUserVars = false,
compact = false,
}: MCPServerStatusIconProps) {
localize = useLocalize();
// Compact mode: render as a small status dot
if (compact) {
return <CompactStatusDot serverStatus={serverStatus} isInitializing={isInitializing} />;
}
// Loading state: show spinner (with cancel option if available)
if (isInitializing) {
return (
<InitializingStatusIcon
<LoadingStatusIcon
serverName={serverName}
onConfigClick={onConfigClick}
onCancel={onCancel}
@ -56,178 +71,126 @@ export default function MCPServerStatusIcon({
return null;
}
const { connectionState, requiresOAuth } = serverStatus;
const { connectionState } = serverStatus;
// Connecting: show spinner only (no action available)
if (connectionState === 'connecting') {
return <ConnectingStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
return <ConnectingSpinner serverName={serverName} />;
}
if (connectionState === 'disconnected') {
if (requiresOAuth) {
return <DisconnectedOAuthStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
}
return <DisconnectedStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
// Disconnected or Error: show connect button (PlugZap icon)
if (connectionState === 'disconnected' || connectionState === 'error') {
return <ConnectButton serverName={serverName} onConfigClick={onConfigClick} />;
}
if (connectionState === 'error') {
return <ErrorStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
}
if (connectionState === 'connected') {
// Only show config button if there are customUserVars to configure
if (hasCustomUserVars) {
const isAuthenticated = tool?.authenticated || requiresOAuth;
return (
<AuthenticatedStatusIcon
serverName={serverName}
onConfigClick={onConfigClick}
isAuthenticated={isAuthenticated}
/>
);
}
return (
<ConnectedStatusIcon
serverName={serverName}
requiresOAuth={requiresOAuth}
onConfigClick={onConfigClick}
/>
);
// Connected: only show config button if there are custom vars to configure
if (connectionState === 'connected' && hasCustomUserVars) {
return <ConfigureButton serverName={serverName} onConfigClick={onConfigClick} />;
}
// Connected without custom vars: no action needed, status shown via dot
return null;
}
function InitializingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
interface CompactStatusDotProps {
serverStatus?: MCPServerStatus;
isInitializing: boolean;
}
function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProps) {
if (isInitializing) {
return (
<div className="flex size-3.5 items-center justify-center rounded-full border-2 border-surface-secondary bg-blue-500">
<div className="size-1.5 animate-pulse rounded-full bg-white" />
</div>
);
}
if (!serverStatus) {
return <div className="size-3 rounded-full border-2 border-surface-secondary bg-gray-400" />;
}
const { connectionState, requiresOAuth } = serverStatus;
let colorClass = 'bg-gray-400';
if (connectionState === 'connected') {
colorClass = 'bg-green-500';
} else if (connectionState === 'connecting') {
colorClass = 'bg-blue-500';
} else if (connectionState === 'error') {
colorClass = 'bg-red-500';
} else if (connectionState === 'disconnected' && requiresOAuth) {
colorClass = 'bg-amber-500';
}
return (
<div className={cn('size-3 rounded-full border-2 border-surface-secondary', colorClass)} />
);
}
function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
if (canCancel) {
return (
<button
type="button"
onClick={onCancel}
className="group flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
className="group flex size-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
aria-label={localize('com_ui_cancel')}
title={localize('com_ui_cancel')}
>
<div className="relative h-4 w-4">
<Spinner className="h-4 w-4 group-hover:opacity-0" />
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
<div className="relative size-4">
<Spinner className="size-4 text-text-primary group-hover:opacity-0" />
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
</div>
</button>
);
}
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<div className="flex size-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
className="size-4 text-text-primary"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
);
}
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
function ConnectingSpinner({ serverName }: { serverName: string }) {
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<div className="flex size-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
className="size-4 text-text-primary"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
);
}
function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */
function ConnectButton({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_connect_server', { 0: serverName })}
>
<KeyRound className="h-4 w-4 text-amber-500" aria-hidden="true" />
<PlugZap className="size-4 text-text-secondary" aria-hidden="true" />
</button>
);
}
function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */
function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<PlugZap className="h-4 w-4 text-orange-500" aria-hidden="true" />
<SlidersHorizontal className="size-4 text-text-secondary" aria-hidden="true" />
</button>
);
}
function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<AlertTriangle className="h-4 w-4 text-red-500" aria-hidden="true" />
</button>
);
}
interface AuthenticatedStatusProps extends StatusIconProps {
isAuthenticated: boolean;
}
function AuthenticatedStatusIcon({
serverName,
onConfigClick,
isAuthenticated,
}: AuthenticatedStatusProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<SettingsIcon
className={`h-4 w-4 ${isAuthenticated ? 'text-green-500' : 'text-gray-400'}`}
aria-hidden="true"
/>
</button>
);
}
interface ConnectedStatusProps {
serverName: string;
requiresOAuth?: boolean;
onConfigClick: (e: React.MouseEvent) => void;
}
function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) {
if (requiresOAuth) {
return (
<TooltipAnchor
role="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
description={localize('com_nav_mcp_status_connected')}
side="top"
>
<CircleCheck className="h-4 w-4 text-green-500" />
</TooltipAnchor>
);
}
return (
<TooltipAnchor
className="flex h-6 w-6 items-center justify-center rounded p-1"
description={localize('com_nav_mcp_status_connected')}
side="top"
>
<CircleCheck className="h-4 w-4 text-green-500" />
</TooltipAnchor>
);
}

View file

@ -0,0 +1,101 @@
import { useMemo } from 'react';
import { MCPIcon } from '@librechat/client';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import { getSelectedServerIcons } from './mcpServerUtils';
import { cn } from '~/utils';
interface StackedMCPIconsProps {
selectedServers: MCPServerDefinition[];
maxIcons?: number;
iconSize?: 'sm' | 'md';
variant?: 'default' | 'submenu';
}
const sizeConfig = {
sm: {
icon: 'h-[18px] w-[18px]',
container: 'h-[22px] w-[22px]',
overlap: '-ml-2.5',
},
md: {
icon: 'h-5 w-5',
container: 'h-6 w-6',
overlap: '-ml-3',
},
};
const variantConfig = {
default: {
border: 'border-border-medium',
bg: 'bg-surface-secondary',
},
submenu: {
border: 'border-surface-primary',
bg: 'bg-surface-primary',
},
};
export default function StackedMCPIcons({
selectedServers,
maxIcons = 3,
iconSize = 'md',
variant = 'default',
}: StackedMCPIconsProps) {
const { icons, overflowCount } = useMemo(
() => getSelectedServerIcons(selectedServers, maxIcons),
[selectedServers, maxIcons],
);
if (icons.length === 0) {
return (
<MCPIcon
aria-hidden="true"
className={cn('flex-shrink-0 text-text-primary', sizeConfig.md.icon)}
/>
);
}
const sizes = sizeConfig[iconSize];
const colors = variantConfig[variant];
return (
<div className="flex items-center" aria-hidden="true">
{icons.map((icon, index) => (
<div
key={icon.key}
title={icon.displayName}
className={cn(
'relative flex items-center justify-center rounded-full border',
colors.border,
colors.bg,
sizes.container,
index > 0 && sizes.overlap,
)}
style={{ zIndex: icons.length - index }}
>
{icon.iconPath ? (
<img
src={icon.iconPath}
alt={icon.displayName}
className={cn('rounded-full object-cover', sizes.icon)}
/>
) : (
<MCPIcon className={cn('text-text-primary', sizes.icon)} />
)}
</div>
))}
{overflowCount > 0 && (
<div
className={cn(
'relative flex items-center justify-center rounded-full border border-surface-primary bg-surface-tertiary text-xs font-medium text-text-secondary',
sizes.container,
sizes.overlap,
)}
style={{ zIndex: 0 }}
>
+{overflowCount}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,196 @@
import type { MCPServerStatus } from 'librechat-data-provider';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
export type { MCPServerStatus };
export interface SelectedIconInfo {
key: string;
serverName: string;
iconPath: string | null;
displayName: string;
}
export type ConnectionStatusMap = Record<string, MCPServerStatus>;
/**
* Generates a list of icons to display for selected MCP servers.
* - Custom icons are shown individually
* - Multiple default icons are consolidated into one
* - Limited to maxIcons with overflow count
*/
export function getSelectedServerIcons(
selectedServers: MCPServerDefinition[],
maxIcons: number = 3,
): { icons: SelectedIconInfo[]; overflowCount: number; defaultServerNames: string[] } {
const customIcons: SelectedIconInfo[] = [];
const defaultServerNames: string[] = [];
for (const server of selectedServers) {
const displayName = server.config?.title || server.serverName;
if (server.config?.iconPath) {
customIcons.push({
key: server.serverName,
serverName: server.serverName,
iconPath: server.config.iconPath,
displayName,
});
} else {
defaultServerNames.push(server.serverName);
}
}
// Add one default icon entry if any server uses default icon
// Custom icons are prioritized first, default icon comes last
const allIcons: SelectedIconInfo[] =
defaultServerNames.length > 0
? [
...customIcons,
{
key: '_default_',
serverName: defaultServerNames[0],
iconPath: null,
displayName: 'MCP',
},
]
: customIcons;
const visibleIcons = allIcons.slice(0, maxIcons);
const overflowCount = Math.max(0, allIcons.length - maxIcons);
return { icons: visibleIcons, overflowCount, defaultServerNames };
}
/**
* Unified status color system following UX best practices:
* - Green: Connected/Active (success)
* - Blue: Connecting/In-progress (processing)
* - Amber: Needs user action (OAuth required, config missing)
* - Gray: Disconnected/Inactive (neutral - server is simply off)
* - Red: Error (failed, needs retry)
*
* Key insight: "Disconnected" is neutral (gray), not a warning.
* Amber is reserved for states requiring user intervention.
*/
export function getStatusColor(
serverName: string,
connectionStatus?: ConnectionStatusMap,
isInitializing?: (serverName: string) => boolean,
): string {
// In-progress states: blue
if (isInitializing?.(serverName)) {
return 'bg-blue-500';
}
const status = connectionStatus?.[serverName];
if (!status) {
return 'bg-gray-400';
}
const { connectionState, requiresOAuth } = status;
// Connecting: blue (in progress)
if (connectionState === 'connecting') {
return 'bg-blue-500';
}
// Connected: green (success)
if (connectionState === 'connected') {
return 'bg-green-500';
}
// Error: red
if (connectionState === 'error') {
return 'bg-red-500';
}
// Disconnected: check if needs action or just inactive
if (connectionState === 'disconnected') {
// Needs OAuth = amber (requires user action)
if (requiresOAuth) {
return 'bg-amber-500';
}
// Simply disconnected = gray (neutral/inactive)
return 'bg-gray-400';
}
return 'bg-gray-400';
}
export function getStatusTextKey(
serverName: string,
connectionStatus?: ConnectionStatusMap,
isInitializing?: (serverName: string) => boolean,
): string {
if (isInitializing?.(serverName)) {
return 'com_nav_mcp_status_initializing';
}
const status = connectionStatus?.[serverName];
if (!status) {
return 'com_nav_mcp_status_unknown';
}
const { connectionState, requiresOAuth } = status;
// Special case: disconnected but needs OAuth shows different text
if (connectionState === 'disconnected' && requiresOAuth) {
return 'com_nav_mcp_status_needs_auth';
}
const keyMap: Record<string, string> = {
connected: 'com_nav_mcp_status_connected',
connecting: 'com_nav_mcp_status_connecting',
disconnected: 'com_nav_mcp_status_disconnected',
error: 'com_nav_mcp_status_error',
};
return keyMap[connectionState] || 'com_nav_mcp_status_unknown';
}
/**
* Determines if a server requires user action to connect.
* Used to show action buttons and amber status color.
*/
export function serverNeedsAction(
serverStatus?: MCPServerStatus,
_hasCustomUserVars?: boolean,
): boolean {
if (!serverStatus) return false;
const { connectionState, requiresOAuth } = serverStatus;
// Needs OAuth authentication
if (connectionState === 'disconnected' && requiresOAuth) return true;
// Has error - needs retry
if (connectionState === 'error') return true;
return false;
}
/**
* Determines if an action button should be shown for a server status.
* Returns true only when the button would be actionable (not just informational).
*/
export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProps | null): boolean {
if (!statusIconProps) return false;
const { serverStatus, canCancel, hasCustomUserVars, isInitializing } = statusIconProps;
// Show cancel button during OAuth flow
if (isInitializing && canCancel) return true;
// Don't show spinner-only state (no action available)
if (isInitializing) return false;
if (!serverStatus) return false;
const { connectionState, requiresOAuth } = serverStatus;
// Show for disconnected/error (can reconnect/configure)
if (connectionState === 'disconnected' || connectionState === 'error') return true;
// Don't show connecting spinner (no action)
if (connectionState === 'connecting') return false;
// Connected: only show if there's config to manage
if (connectionState === 'connected') return hasCustomUserVars || requiresOAuth;
return false;
}

View file

@ -1,4 +1,4 @@
import React, { useRef, useState, useMemo, useEffect } from 'react';
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import { InfoIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
@ -19,6 +19,10 @@ type CodeBlockProps = Pick<
classProp?: string;
};
interface FloatingCodeBarProps extends CodeBarProps {
isVisible: boolean;
}
const CodeBar: React.FC<CodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
@ -51,16 +55,14 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
}
}}
>
{isCopied ? (
<>
<CheckMark className="h-[18px] w-[18px]" />
{error === true ? '' : localize('com_ui_copied')}
</>
) : (
<>
<Clipboard />
{error === true ? '' : localize('com_ui_copy_code')}
</>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</div>
@ -70,6 +72,75 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
},
);
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const handleCopy = useCallback(() => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
const wasFocused = document.activeElement === copyButtonRef.current;
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
if (wasFocused) {
requestAnimationFrame(() => {
copyButtonRef.current?.focus();
});
}
setTimeout(() => {
const focusedElement = document.activeElement as HTMLElement | null;
setIsCopied(false);
requestAnimationFrame(() => {
focusedElement?.focus();
});
}, 3000);
}
}, [codeRef]);
return (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
className={cn(
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</>
)}
</div>
);
},
);
const CodeBlock: React.FC<CodeBlockProps> = ({
lang,
blockIndex,
@ -80,6 +151,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
error,
}) => {
const codeRef = useRef<HTMLElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isBarVisible, setIsBarVisible] = useState(false);
const toolCallsMap = useToolCallsMapContext();
const { messageId, partIndex } = useMessageContext();
const key = allowExecution
@ -97,6 +170,29 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
}
}, [fetchedToolCalls]);
// Handle focus within the container (for keyboard navigation)
const handleFocus = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleBlur = useCallback((e: React.FocusEvent) => {
// Check if focus is moving to another element within the container
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
setIsBarVisible(false);
}
}, []);
const handleMouseEnter = useCallback(() => {
setIsBarVisible(true);
}, []);
const handleMouseLeave = useCallback(() => {
// Only hide if no element inside has focus
if (!containerRef.current?.contains(document.activeElement)) {
setIsBarVisible(false);
}
}, []);
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
const next = () => {
@ -118,7 +214,14 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
const language = isNonCode ? 'json' : lang;
return (
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
<div
ref={containerRef}
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
<CodeBar
lang={lang}
error={error}
@ -137,6 +240,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
{codeChildren}
</code>
</div>
<FloatingCodeBar
lang={lang}
error={error}
codeRef={codeRef}
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
isVisible={isBarVisible}
/>
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
<div className="bg-gray-700 p-4 text-xs">

View file

@ -0,0 +1,744 @@
import React, { useEffect, useMemo, useState, useRef, useCallback, memo } from 'react';
import copy from 'copy-to-clipboard';
import {
X,
ZoomIn,
Expand,
ZoomOut,
ChevronUp,
RefreshCw,
RotateCcw,
ChevronDown,
} from 'lucide-react';
import {
Button,
Spinner,
OGDialog,
Clipboard,
CheckMark,
OGDialogClose,
OGDialogTitle,
OGDialogContent,
} from '@librechat/client';
import { useLocalize, useDebouncedMermaid } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidProps {
/** Mermaid diagram content */
children: string;
/** Unique identifier */
id?: string;
/** Custom theme */
theme?: string;
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.25;
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const localize = useLocalize();
const [blobUrl, setBlobUrl] = useState<string>('');
const [isCopied, setIsCopied] = useState(false);
const [showCode, setShowCode] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Separate showCode state for dialog to avoid re-renders
const [dialogShowCode, setDialogShowCode] = useState(false);
const lastValidSvgRef = useRef<string | null>(null);
const expandButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
// Zoom and pan state
const [zoom, setZoom] = useState(1);
// Dialog zoom and pan state (separate from inline view)
const [dialogZoom, setDialogZoom] = useState(1);
const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 });
const [isDialogPanning, setIsDialogPanning] = useState(false);
const dialogPanStartRef = useRef({ x: 0, y: 0 });
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const streamingCodeRef = useRef<HTMLPreElement>(null);
// Get SVG from debounced hook (handles streaming gracefully)
const { svg, isLoading, error } = useDebouncedMermaid({
content: children,
id,
theme,
key: retryCount,
});
// Auto-scroll streaming code to bottom
useEffect(() => {
if (isLoading && streamingCodeRef.current) {
streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight;
}
}, [children, isLoading]);
// Store last valid SVG for showing during updates
useEffect(() => {
if (svg) {
lastValidSvgRef.current = svg;
}
}, [svg]);
// Process SVG and create blob URL
const processedSvg = useMemo(() => {
if (!svg) {
return null;
}
let finalSvg = svg;
// Firefox fix: Ensure viewBox is set correctly
if (!svg.includes('viewBox') && svg.includes('height=') && svg.includes('width=')) {
const widthMatch = svg.match(/width="(\d+)"/);
const heightMatch = svg.match(/height="(\d+)"/);
if (widthMatch && heightMatch) {
const width = widthMatch[1];
const height = heightMatch[1];
finalSvg = svg.replace('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
}
}
// Ensure SVG has proper XML namespace
if (!finalSvg.includes('xmlns')) {
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
return finalSvg;
}, [svg]);
// Create blob URL for the SVG
useEffect(() => {
if (!processedSvg) {
return;
}
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
setBlobUrl(url);
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
}, [processedSvg]);
const handleCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
setIsCopied(true);
requestAnimationFrame(() => {
copyButtonRef.current?.focus();
});
setTimeout(() => {
// Save currently focused element before state update causes re-render
const focusedElement = document.activeElement as HTMLElement | null;
setIsCopied(false);
// Restore focus to whatever was focused (React re-render may have disrupted it)
requestAnimationFrame(() => {
focusedElement?.focus();
});
}, 3000);
}, [children]);
const [isDialogCopied, setIsDialogCopied] = useState(false);
const handleDialogCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
setIsDialogCopied(true);
requestAnimationFrame(() => {
dialogCopyButtonRef.current?.focus();
});
setTimeout(() => {
setIsDialogCopied(false);
requestAnimationFrame(() => {
dialogCopyButtonRef.current?.focus();
});
}, 3000);
}, [children]);
// Zoom controls copy with focus restoration
const [isZoomCopied, setIsZoomCopied] = useState(false);
const handleZoomCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
setIsZoomCopied(true);
requestAnimationFrame(() => {
zoomCopyButtonRef.current?.focus();
});
setTimeout(() => {
setIsZoomCopied(false);
requestAnimationFrame(() => {
zoomCopyButtonRef.current?.focus();
});
}, 3000);
}, [children]);
// Dialog zoom controls copy
const handleDialogZoomCopy = useCallback(() => {
copy(children.trim(), { format: 'text/plain' });
requestAnimationFrame(() => {
dialogZoomCopyButtonRef.current?.focus();
});
}, [children]);
const handleRetry = () => {
setRetryCount((prev) => prev + 1);
};
// Toggle code with focus restoration
const handleToggleCode = useCallback(() => {
setShowCode((prev) => !prev);
requestAnimationFrame(() => {
showCodeButtonRef.current?.focus();
});
}, []);
// Toggle dialog code with focus restoration
const handleToggleDialogCode = useCallback(() => {
setDialogShowCode((prev) => !prev);
requestAnimationFrame(() => {
dialogShowCodeButtonRef.current?.focus();
});
}, []);
// Zoom handlers
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleResetZoom = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
// Dialog zoom handlers
const handleDialogZoomIn = useCallback(() => {
setDialogZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
}, []);
const handleDialogZoomOut = useCallback(() => {
setDialogZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
}, []);
const handleDialogResetZoom = useCallback(() => {
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
}, []);
const handleDialogWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setDialogZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}
}, []);
const handleDialogMouseDown = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const isButton = target.tagName === 'BUTTON' || target.closest('button');
if (e.button === 0 && !isButton) {
setIsDialogPanning(true);
dialogPanStartRef.current = { x: e.clientX - dialogPan.x, y: e.clientY - dialogPan.y };
}
},
[dialogPan],
);
const handleDialogMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDialogPanning) {
setDialogPan({
x: e.clientX - dialogPanStartRef.current.x,
y: e.clientY - dialogPanStartRef.current.y,
});
}
},
[isDialogPanning],
);
const handleDialogMouseUp = useCallback(() => {
setIsDialogPanning(false);
}, []);
const handleDialogMouseLeave = useCallback(() => {
setIsDialogPanning(false);
}, []);
// Mouse wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
}
}, []);
// Pan handlers
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only start panning on left click and not on buttons/icons inside buttons
const target = e.target as HTMLElement;
const isButton = target.tagName === 'BUTTON' || target.closest('button');
if (e.button === 0 && !isButton) {
setIsPanning(true);
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
}
},
[pan],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isPanning) {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
}
},
[isPanning],
);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const handleMouseLeave = useCallback(() => {
setIsPanning(false);
}, []);
// Header component (shared across states)
const Header = ({
showActions = false,
showExpandButton = false,
}: {
showActions?: boolean;
showExpandButton?: boolean;
}) => (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
<span>{localize('com_ui_mermaid')}</span>
{showActions && (
<div className="ml-auto flex gap-2">
{showExpandButton && (
<Button
ref={expandButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={() => {
setDialogShowCode(false);
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
setIsDialogOpen(true);
}}
title={localize('com_ui_expand')}
>
<Expand className="h-4 w-4" />
{localize('com_ui_expand')}
</Button>
)}
<Button
ref={showCodeButtonRef}
variant="ghost"
size="sm"
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={copyButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
</div>
)}
</div>
);
// Zoom controls - inline JSX to avoid stale closure issues
const zoomControls = (
<div className="absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomOut();
}}
disabled={zoom <= MIN_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomIn();
}}
disabled={zoom >= MAX_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleResetZoom();
}}
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={zoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</button>
</div>
);
// Dialog zoom controls
const dialogZoomControls = (
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomOut();
}}
disabled={dialogZoom <= MIN_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
{Math.round(dialogZoom * 100)}%
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomIn();
}}
disabled={dialogZoom >= MAX_ZOOM}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogResetZoom();
}}
disabled={dialogZoom === 1 && dialogPan.x === 0 && dialogPan.y === 0}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
title={localize('com_ui_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-border-medium" />
<button
ref={dialogZoomCopyButtonRef}
type="button"
onClick={(e) => {
e.stopPropagation();
handleDialogZoomCopy();
}}
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
<Clipboard className="h-4 w-4" />
</button>
</div>
);
// Full-screen dialog - rendered inline, not as function component to avoid recreation
const expandedDialog = (
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} triggerRef={expandButtonRef}>
<OGDialogContent
showCloseButton={false}
className="h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] gap-0 overflow-hidden border-border-light bg-surface-primary-alt p-0"
>
<OGDialogTitle className="flex h-10 items-center justify-between bg-gray-700 px-4 font-sans text-xs text-gray-200">
<span>{localize('com_ui_mermaid')}</span>
<div className="flex gap-2">
<Button
ref={dialogShowCodeButtonRef}
variant="ghost"
size="sm"
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleToggleDialogCode}
>
{dialogShowCode ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{dialogShowCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={dialogCopyButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleDialogCopy}
>
{isDialogCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
<OGDialogClose className="rounded-sm p-1 text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white">
<X className="h-4 w-4" />
<span className="sr-only">{localize('com_ui_close')}</span>
</OGDialogClose>
</div>
</OGDialogTitle>
{dialogShowCode && (
<div className="border-b border-border-medium bg-surface-secondary p-4">
<pre className="max-h-[150px] overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
<div
className={cn(
'relative flex-1 overflow-hidden p-4',
'bg-surface-primary-alt',
isDialogPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ height: dialogShowCode ? 'calc(85vh - 200px)' : 'calc(85vh - 50px)' }}
onWheel={handleDialogWheel}
onMouseDown={handleDialogMouseDown}
onMouseMove={handleDialogMouseMove}
onMouseUp={handleDialogMouseUp}
onMouseLeave={handleDialogMouseLeave}
>
<div
className="flex h-full w-full items-center justify-center"
style={{
transform: `translate(${dialogPan.x}px, ${dialogPan.y}px)`,
transition: isDialogPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-h-full max-w-full select-none object-contain"
style={{
transform: `scale(${dialogZoom})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
</div>
{dialogZoomControls}
</div>
</OGDialogContent>
</OGDialog>
);
// Loading state - show last valid diagram with loading indicator, or spinner
if (isLoading) {
// If we have a previous valid render, show it with a subtle loading indicator
if (lastValidSvgRef.current && blobUrl) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions />
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4',
'rounded-b-md bg-surface-primary-alt',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ minHeight: '250px', maxHeight: '600px' }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
<Spinner className="h-3 w-3" />
</div>
<div
className="flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px)`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-w-full select-none opacity-70"
style={{
maxHeight: '500px',
transform: `scale(${zoom})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
</div>
{zoomControls}
</div>
</div>
);
}
// No previous render, show streaming code
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<div className="flex items-center gap-2 rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
<Spinner className="h-3 w-3 text-gray-200" />
<span>{localize('com_ui_mermaid')}</span>
</div>
<pre
ref={streamingCodeRef}
className="max-h-[350px] min-h-[150px] overflow-auto whitespace-pre-wrap rounded-b-md bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary"
>
{children}
</pre>
</div>
);
}
// Error state
if (error) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions />
<div className="rounded-b-md border-t border-red-500/30 bg-red-500/10 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-red-500 dark:text-red-400">
{localize('com_ui_mermaid_failed')}
</span>
<button
type="button"
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
>
<RefreshCw className="h-3 w-3" />
{localize('com_ui_retry')}
</button>
</div>
<pre className="overflow-auto text-xs text-red-600 dark:text-red-300">
{error.message}
</pre>
{showCode && (
<div className="mt-4 border-t border-border-medium pt-4">
<div className="mb-2 text-xs text-text-secondary">
{localize('com_ui_mermaid_source')}
</div>
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
</div>
</div>
);
}
// Success state
if (!blobUrl) {
return null;
}
return (
<>
{expandedDialog}
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions showExpandButton />
{showCode && (
<div className="border-b border-border-medium bg-surface-secondary p-4">
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
</div>
)}
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4',
'bg-surface-primary-alt',
!showCode && 'rounded-b-md',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ minHeight: '250px', maxHeight: '600px' }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div
className="flex min-h-[200px] items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px)`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-w-full select-none"
style={{
maxHeight: '500px',
transform: `scale(${zoom})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
</div>
{zoomControls}
</div>
</div>
</>
);
});
Mermaid.displayName = 'Mermaid';
export default Mermaid;

View file

@ -0,0 +1,59 @@
import React from 'react';
interface MermaidErrorBoundaryProps {
children: React.ReactNode;
/** The mermaid code to display as fallback */
code: string;
}
interface MermaidErrorBoundaryState {
hasError: boolean;
}
/**
* Error boundary specifically for Mermaid diagrams.
* Falls back to displaying the raw mermaid code if rendering fails.
*/
class MermaidErrorBoundary extends React.Component<
MermaidErrorBoundaryProps,
MermaidErrorBoundaryState
> {
constructor(props: MermaidErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): MermaidErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Mermaid rendering error:', error, errorInfo);
}
componentDidUpdate(prevProps: MermaidErrorBoundaryProps) {
// Reset error state if code changes (e.g., user edits the message)
if (prevProps.code !== this.props.code && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
{'mermaid'}
</div>
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
{this.props.code}
</pre>
</div>
);
}
return this.props.children;
}
}
export default MermaidErrorBoundary;

View file

@ -86,7 +86,9 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
<>
<button
type="button"
className={cn('ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white')}
className={cn(
'ml-auto flex gap-2 rounded-sm px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
)}
onClick={debouncedExecute}
disabled={execute.isLoading}
>

View file

@ -3,22 +3,20 @@ import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useLocalize, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize';
import { cn, getMessageAriaLabel, logger } from '~/utils';
import store from '~/store';
type ContentRenderProps = {
message?: TMessage;
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
isSubmitting?: boolean;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
@ -27,14 +25,12 @@ type ContentRenderProps = {
const ContentRender = memo(
({
message: msg,
isCard = false,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily = false,
isSubmitting = false,
}: ContentRenderProps) => {
const localize = useLocalize();
const { attachments, searchResults } = useAttachments({
@ -49,18 +45,15 @@ const ContentRender = memo(
enterEdit,
conversation,
messageLabel,
isSubmitting,
latestMessage,
handleContinue,
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
copyToClipboard,
regenerateMessage,
} = useMessageActions({
message: msg,
searchResults,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
@ -72,9 +65,10 @@ const ContentRender = memo(
!(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessage?.depth],
);
const hasNoChildren = !(msg?.children?.length ?? 0);
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
@ -95,36 +89,28 @@ const ContentRender = memo(
],
);
const clickHandler = useMemo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(
'latest_message',
`Message Card click: Setting ${msg?.messageId} as latest message`,
);
logger.dir(msg);
setLatestMessage(msg!);
}
: undefined,
[showCardRender, isLatestMessage, msg, setLatestMessage],
);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
return null;
}
const getChatWidthClass = () => {
if (maximizeChatSpace) {
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
}
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
chat: getChatWidthClass(),
};
const conditionalClasses = {
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
cardRender: showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
};
@ -134,38 +120,29 @@ const ContentRender = memo(
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
isCard ? baseClasses.card : baseClasses.chat,
conditionalClasses.latestCard,
conditionalClasses.cardRender,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
onClick={clickHandler}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
clickHandler();
}
}}
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
)}
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
</div>
)}
<div
className={cn(
'relative flex w-11/12 flex-col',
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
@ -176,18 +153,17 @@ const ContentRender = memo(
siblingIdx={siblingIdx}
messageId={msg.messageId}
attachments={attachments}
isSubmitting={isSubmitting}
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={effectiveIsSubmitting}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{(isSubmittingFamily || isSubmitting) && !(msg.children?.length ?? 0) ? (
<PlaceholderRow isCard={isCard} />
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
@ -197,8 +173,8 @@ const ContentRender = memo(
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
isEditing={edit}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
import ContentRender from './ContentRender';
@ -26,14 +26,9 @@ const MessageContainer = React.memo(
);
export default function MessageContent(props: TMessageProps) {
const {
showSibling,
conversation,
handleScroll,
siblingMessage,
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { conversation, handleScroll, isSubmitting } = useMessageProcess({
message: props.message,
});
const { message, currentEditId, setCurrentEditId } = props;
if (!message || typeof message !== 'object') {
@ -45,29 +40,9 @@ export default function MessageContent(props: TMessageProps) {
return (
<>
<MessageContainer handleScroll={handleScroll}>
{showSibling ? (
<div className="m-auto my-2 flex justify-center p-4 py-2 md:gap-6">
<div className="flex w-full flex-row flex-wrap justify-between gap-1 md:max-w-5xl md:flex-nowrap md:gap-2 lg:max-w-5xl xl:max-w-6xl">
<ContentRender
{...props}
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
/>
<ContentRender
{...props}
isMultiMessage
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
/>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<ContentRender {...props} />
</div>
)}
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} isSubmitting={isSubmitting} />
</div>
</MessageContainer>
<MultiMessage
key={messageId}

View file

@ -1,12 +1,13 @@
import React from 'react';
import { forwardRef } from 'react';
type Props = {
scrollHandler: React.MouseEventHandler<HTMLButtonElement>;
};
export default function ScrollToBottom({ scrollHandler }: Props) {
const ScrollToBottom = forwardRef<HTMLButtonElement, Props>(({ scrollHandler }, ref) => {
return (
<button
ref={ref}
onClick={scrollHandler}
className="premium-scroll-button absolute bottom-5 right-1/2 cursor-pointer border border-border-light bg-surface-secondary"
aria-label="Scroll to bottom"
@ -22,4 +23,8 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
</svg>
</button>
);
}
});
ScrollToBottom.displayName = 'ScrollToBottom';
export default ScrollToBottom;

View file

@ -1,14 +1,12 @@
import { useState, memo } from 'react';
import { useRecoilState } from 'recoil';
import { useState, memo, useRef } from 'react';
import * as Select from '@ariakit/react/select';
import { FileText, LogOut } from 'lucide-react';
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import FilesView from '~/components/Chat/Input/Files/FilesView';
import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
import Settings from './Settings';
import store from '~/store';
function AccountSettings() {
const localize = useLocalize();
@ -18,14 +16,16 @@ function AccountSettings() {
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
});
const [showSettings, setShowSettings] = useState(false);
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
const [showFiles, setShowFiles] = useState(false);
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
return (
<Select.SelectProvider>
<Select.Select
ref={accountSettingsButtonRef}
aria-label={localize('com_nav_account_settings')}
data-testid="nav-user"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-active-alt aria-[expanded=true]:bg-surface-active-alt"
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
@ -40,7 +40,7 @@ function AccountSettings() {
</div>
</Select.Select>
<Select.SelectPopover
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
className="popover-ui w-[305px] rounded-lg md:w-[244px]"
style={{
transformOrigin: 'bottom',
translate: '0 -4px',
@ -96,7 +96,13 @@ function AccountSettings() {
{localize('com_nav_log_out')}
</Select.SelectItem>
</Select.SelectPopover>
{showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />}
{showFiles && (
<MyFilesModal
open={showFiles}
onOpenChange={setShowFiles}
triggerRef={accountSettingsButtonRef}
/>
)}
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
</Select.SelectProvider>
);

View file

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { useState, useId, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import type * as t from '~/common';
import type { FC } from 'react';
import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -16,56 +16,105 @@ type BookmarkNavProps = {
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
const localize = useLocalize();
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { data } = useGetConversationTags();
const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize],
);
const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
const handleTagClick = useCallback(
(tag: string) => {
if (tags.includes(tag)) {
setTags(tags.filter((t) => t !== tag));
} else {
setTags([...tags, tag]);
}
},
[tags, setTags],
);
const handleClear = useCallback(() => {
setTags([]);
}, [setTags]);
const dropdownItems: t.MenuItemProps[] = useMemo(() => {
const items: t.MenuItemProps[] = [
{
id: 'clear-all',
label: localize('com_ui_clear_all'),
icon: <CrossCircledIcon className="size-4" />,
hideOnClick: false,
onClick: handleClear,
},
];
if (bookmarks.length === 0) {
items.push({
id: 'no-bookmarks',
label: localize('com_ui_no_bookmarks'),
icon: '🤔',
disabled: true,
});
} else {
for (const bookmark of bookmarks) {
const isSelected = tags.includes(bookmark.tag);
items.push({
id: bookmark.tag,
label: bookmark.tag,
hideOnClick: false,
icon: isSelected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
),
onClick: () => handleTagClick(bookmark.tag),
});
}
}
return items;
}, [bookmarks, tags, localize, handleTagClick, handleClear]);
return (
<Menu as="div" className="group relative">
{({ open }) => (
<>
<TooltipAnchor
description={label}
render={
<MenuButton
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-hover md:rounded-xl',
open ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
{tags.length > 0 ? (
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
) : (
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
)}
</MenuButton>
}
/>
<MenuItems
anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
>
{data && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems
// List of selected tags(string)
tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
setTags={setTags}
/>
</BookmarkContext.Provider>
)}
</MenuItems>
</>
)}
</Menu>
<DropdownPopup
portal={true}
menuId={menuId}
focusLoop={true}
isOpen={isMenuOpen}
unmountOnHide={true}
setIsOpen={setIsMenuOpen}
keyPrefix="bookmark-nav-"
trigger={
<TooltipAnchor
description={label}
render={
<Ariakit.MenuButton
id="bookmark-nav-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-active-alt md:rounded-xl',
isMenuOpen ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
{tags.length > 0 ? (
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
) : (
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
)}
</Ariakit.MenuButton>
}
/>
}
items={dropdownItems}
/>
);
};

View file

@ -1,76 +0,0 @@
import { type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
import { useLocalize } from '~/hooks';
const BookmarkNavItems: FC<{
tags: string[];
setTags: (tags: string[]) => void;
}> = ({ tags = [], setTags }) => {
const { bookmarks } = useBookmarkContext();
const localize = useLocalize();
const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag);
} else {
return [...tags, tag];
}
};
const handleSubmit = (tag?: string) => {
if (tag === undefined) {
return;
}
const updatedSelected = getUpdatedSelected(tag);
setTags(updatedSelected);
return;
};
const clear = () => {
setTags([]);
return;
};
if (bookmarks.length === 0) {
return (
<div className="flex flex-col">
<BookmarkItem
tag={localize('com_ui_clear_all')}
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
/>
<BookmarkItem
tag={localize('com_ui_no_bookmarks')}
data-testid="bookmark-item-no-bookmarks"
handleSubmit={() => Promise.resolve()}
selected={false}
icon={'🤔'}
/>
</div>
);
}
return (
<div className="flex flex-col">
<BookmarkItems
tags={tags}
handleSubmit={handleSubmit}
header={
<BookmarkItem
tag={localize('com_ui_clear_all')}
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
/>
}
/>
</div>
);
};
export default BookmarkNavItems;

View file

@ -4,14 +4,13 @@ import { LayoutGrid } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import { Skeleton } from '@librechat/client';
import { useNavigate } from 'react-router-dom';
import { useQueries } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useQueries, useQueryClient } from '@tanstack/react-query';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { useFavorites, useLocalize, useShowMarketplace, useNewConvo } from '~/hooks';
import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import useSelectMention from '~/hooks/Input/useSelectMention';
import { useGetEndpointsQuery } from '~/data-provider';
import { useAssistantsMapContext } from '~/Providers';
import FavoriteItem from './FavoriteItem';
import store from '~/store';
@ -121,13 +120,13 @@ export default function FavoritesList({
}) {
const navigate = useNavigate();
const localize = useLocalize();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
const showAgentMarketplace = useShowMarketplace();
const { newConversation } = useNewConvo();
const assistantsMap = useAssistantsMapContext();
const agentsMap = useAgentsMapContext();
const conversation = useRecoilValue(store.conversationByIndex(0));
const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery();
@ -168,59 +167,56 @@ export default function FavoritesList({
newChatButton?.focus();
}, []);
// Ensure favorites is always an array (could be corrupted in localStorage)
const safeFavorites = useMemo(() => (Array.isArray(favorites) ? favorites : []), [favorites]);
const agentIds = safeFavorites.map((f) => f.agentId).filter(Boolean) as string[];
const allAgentIds = useMemo(
() => safeFavorites.map((f) => f.agentId).filter(Boolean) as string[],
[safeFavorites],
);
const agentQueries = useQueries({
queries: agentIds.map((agentId) => ({
const missingAgentIds = useMemo(() => {
if (agentsMap === undefined) {
return [];
}
return allAgentIds.filter((id) => !agentsMap[id]);
}, [allAgentIds, agentsMap]);
const missingAgentQueries = useQueries({
queries: missingAgentIds.map((agentId) => ({
queryKey: [QueryKeys.agent, agentId],
queryFn: () => dataService.getAgentById({ agent_id: agentId }),
staleTime: 1000 * 60 * 5,
enabled: missingAgentIds.length > 0,
})),
});
const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading);
const combinedAgentsMap = useMemo(() => {
if (agentsMap === undefined) {
return undefined;
}
const combined: Record<string, t.Agent> = {};
for (const [key, value] of Object.entries(agentsMap)) {
if (value) {
combined[key] = value;
}
}
missingAgentQueries.forEach((query) => {
if (query.data) {
combined[query.data.id] = query.data;
}
});
return combined;
}, [agentsMap, missingAgentQueries]);
const isAgentsLoading =
(allAgentIds.length > 0 && agentsMap === undefined) ||
(missingAgentIds.length > 0 && missingAgentQueries.some((q) => q.isLoading));
useEffect(() => {
if (!isAgentsLoading && onHeightChange) {
onHeightChange();
}
}, [isAgentsLoading, onHeightChange]);
const agentsMap = useMemo(() => {
const map: Record<string, t.Agent> = {};
const addToMap = (agent: t.Agent) => {
if (agent && agent.id && !map[agent.id]) {
map[agent.id] = agent;
}
};
const marketplaceData = queryClient.getQueriesData<InfiniteData<t.AgentListResponse>>([
QueryKeys.marketplaceAgents,
]);
marketplaceData.forEach(([_, data]) => {
data?.pages.forEach((page) => {
page.data.forEach(addToMap);
});
});
const agentsListData = queryClient.getQueriesData<t.AgentListResponse>([QueryKeys.agents]);
agentsListData.forEach(([_, data]) => {
if (data && Array.isArray(data.data)) {
data.data.forEach(addToMap);
}
});
agentQueries.forEach((query) => {
if (query.data) {
map[query.data.id] = query.data;
}
});
return map;
}, [agentQueries, queryClient]);
const draggedFavoritesRef = useRef(safeFavorites);
@ -306,7 +302,7 @@ export default function FavoritesList({
)}
{safeFavorites.map((fav, index) => {
if (fav.agentId) {
const agent = agentsMap[fav.agentId];
const agent = combinedAgentsMap?.[fav.agentId];
if (!agent) {
return null;
}

View file

@ -21,7 +21,7 @@ export default function MobileNav({
const { title = 'New Chat' } = conversation || {};
return (
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-white pl-1 dark:bg-gray-800 dark:text-white md:hidden">
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-presentation pl-1 dark:text-white md:hidden">
<button
type="button"
data-testid="mobile-header-new-chat-button"
@ -29,7 +29,7 @@ export default function MobileNav({
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
}
aria-live="polite"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
@ -62,7 +62,7 @@ export default function MobileNav({
<button
type="button"
aria-label={localize('com_ui_new_chat')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);

View file

@ -1,10 +1,21 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import {
useCallback,
useEffect,
useState,
useMemo,
memo,
lazy,
Suspense,
useRef,
startTransition,
} from 'react';
import { useRecoilValue } from 'recoil';
import { AnimatePresence, motion } from 'framer-motion';
import { motion } from 'framer-motion';
import { Skeleton, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } 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,
useHasAccess,
@ -12,7 +23,7 @@ import {
useLocalStorage,
useNavScrolling,
} from '~/hooks';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import SearchBar from './SearchBar';
import NewChat from './NewChat';
@ -22,8 +33,10 @@ import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
export const NAV_WIDTH = {
MOBILE: 320,
DESKTOP: 260,
} as const;
const SearchBarSkeleton = memo(() => (
<div className={cn('flex h-10 items-center py-2')}>
@ -63,8 +76,8 @@ const Nav = memo(
}) => {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
@ -120,13 +133,17 @@ const Nav = memo(
}, [data]);
const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
// Use startTransition to mark this as a non-urgent update
// This prevents blocking the main thread during the cascade of re-renders
startTransition(() => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
if (newUser) {
setNewUser(false);
}
});
if (newUser) {
setNewUser(false);
}
}, [newUser, setNavVisible, setNewUser]);
const itemToggleNav = useCallback(() => {
@ -141,9 +158,6 @@ const Nav = memo(
if (savedNavVisible === null) {
toggleNavVisible();
}
setNavWidth(NAV_WIDTH_MOBILE);
} else {
setNavWidth(NAV_WIDTH_DESKTOP);
}
}, [isSmallScreen, toggleNavVisible]);
@ -199,61 +213,90 @@ const Nav = memo(
}
}, [search.query, search.isTyping, isLoading, isFetching]);
// Always render sidebar to avoid mount/unmount costs
// Use transform for GPU-accelerated animation (no layout thrashing)
const sidebarWidth = isSmallScreen ? NAV_WIDTH.MOBILE : NAV_WIDTH.DESKTOP;
// Sidebar content (shared between mobile and desktop)
const sidebarContent = (
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5"
aria-hidden={!navVisible}
>
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={conversationsRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
<AccountSettings />
</Suspense>
</nav>
</div>
);
// Mobile: Fixed positioned sidebar that slides over content
// Uses CSS transitions (not Framer Motion) to sync perfectly with content animation
if (isSmallScreen) {
return (
<>
<div
data-testid="nav"
className={cn(
'nav fixed left-0 top-0 z-[70] h-full bg-surface-primary-alt',
navVisible && 'active',
)}
style={{
width: sidebarWidth,
transform: navVisible ? 'translateX(0)' : `translateX(-${sidebarWidth}px)`,
transition: 'transform 0.2s ease-out',
}}
>
{sidebarContent}
</div>
<NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />
</>
);
}
// Desktop: Inline sidebar with width transition
return (
<>
<AnimatePresence initial={false}>
{navVisible && (
<motion.div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
initial={{ width: 0 }}
animate={{ width: navWidth }}
exit={{ width: 0 }}
transition={{ duration: 0.2 }}
key="nav"
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5"
>
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={conversationsRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
<AccountSettings />
</Suspense>
</nav>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
<div
className="flex-shrink-0 overflow-hidden"
style={{ width: navVisible ? sidebarWidth : 0, transition: 'width 0.2s ease-out' }}
>
<motion.div
data-testid="nav"
className={cn('nav h-full bg-surface-primary-alt', navVisible && 'active')}
style={{ width: sidebarWidth }}
initial={false}
animate={{
x: navVisible ? 0 : -sidebarWidth,
}}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{sidebarContent}
</motion.div>
</div>
);
},
);

View file

@ -66,13 +66,13 @@ export default function NewChat({
data-testid="close-sidebar-button"
aria-label={localize('com_nav_close_sidebar')}
aria-expanded={true}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
onClick={handleToggleNav}
>
<Sidebar aria-hidden="true" className="max-md:hidden" />
<MobileSidebar
aria-hidden="true"
className="m-1 inline-flex size-10 items-center justify-center md:hidden"
className="icon-lg m-1 inline-flex items-center justify-center md:hidden"
/>
</Button>
}
@ -88,7 +88,7 @@ export default function NewChat({
variant="outline"
data-testid="nav-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
onClick={clickHandler}
>
<NewChatIcon className="icon-lg text-text-primary" />

View file

@ -109,7 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
return (
<div
ref={ref}
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-active-alt hover:bg-surface-active-alt"
>
<Search
aria-hidden="true"

View file

@ -84,6 +84,13 @@ const toggleSwitchConfigs = [
hoverCardText: 'com_nav_info_default_temporary_chat',
key: 'defaultTemporaryChat',
},
{
stateAtom: store.resumableStreams,
localizationKey: 'com_nav_resumable_streams',
switchId: 'resumableStreams',
hoverCardText: 'com_nav_info_resumable_streams',
key: 'resumableStreams',
},
];
function Chat() {

View file

@ -12,6 +12,7 @@ import {
ExternalLink,
} from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
OGDialog,
useToastContext,
@ -62,14 +63,6 @@ export default function SharedLinks() {
refetchOnMount: false,
});
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: sortField as 'title' | 'createdAt',
sortDirection: sortOrder,
}));
}, []);
const handleFilterChange = useCallback((value: string) => {
const encodedValue = encodeURIComponent(value.trim());
setQueryParams((prev) => ({
@ -120,7 +113,7 @@ export default function SharedLinks() {
if (validRows.length === 0) {
showToast({
message: localize('com_ui_no_valid_items'),
message: localize('com_ui_no_valid_items' as TranslationKeys),
severity: NotificationSeverity.WARNING,
});
return;
@ -134,15 +127,15 @@ export default function SharedLinks() {
showToast({
message: localize(
validRows.length === 1
? 'com_ui_shared_link_delete_success'
: 'com_ui_shared_link_bulk_delete_success',
? ('com_ui_shared_link_delete_success' as TranslationKeys)
: ('com_ui_shared_link_bulk_delete_success' as TranslationKeys),
),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared links:', error);
showToast({
message: localize('com_ui_bulk_delete_error'),
message: localize('com_ui_bulk_delete_error' as TranslationKeys),
severity: NotificationSeverity.ERROR,
});
}
@ -168,26 +161,28 @@ export default function SharedLinks() {
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
header: ({ column }) => {
const sortState = column.getIsSorted();
let SortIcon = ArrowUpDown;
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
if (sortState === 'desc') {
SortIcon = ArrowDown;
ariaSort = 'descending';
} else if (sortState === 'asc') {
SortIcon = ArrowUp;
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-sort={ariaSort}
aria-label={localize('com_ui_name_sort')}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
@ -218,26 +213,28 @@ export default function SharedLinks() {
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
header: ({ column }) => {
const sortState = column.getIsSorted();
let SortIcon = ArrowUpDown;
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
if (sortState === 'desc') {
SortIcon = ArrowDown;
ariaSort = 'descending';
} else if (sortState === 'asc') {
SortIcon = ArrowUp;
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-label={localize('com_ui_creation_date_sort')}
aria-sort={ariaSort}
aria-label={localize('com_ui_creation_date_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_ui_date')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
@ -300,7 +297,7 @@ export default function SharedLinks() {
),
},
],
[isSmallScreen, localize, queryParams, handleSort],
[isSmallScreen, localize],
);
return (

View file

@ -17,6 +17,7 @@ import {
OGDialogContent,
} from '@librechat/client';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import {
useConversationsInfiniteQuery,
useDeleteConversationMutation,
@ -56,14 +57,6 @@ export default function ArchivedChatsTable({
refetchOnMount: false,
});
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
setQueryParams((prev) => ({
...prev,
sortBy: sortField as 'title' | 'createdAt',
sortDirection: sortOrder,
}));
}, []);
const handleFilterChange = useCallback((value: string) => {
const encodedValue = encodeURIComponent(value.trim());
setQueryParams((prev) => ({
@ -133,25 +126,28 @@ export default function ArchivedChatsTable({
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
header: ({ column }) => {
const sortState = column.getIsSorted();
let SortIcon = ArrowUpDown;
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
if (sortState === 'desc') {
SortIcon = ArrowDown;
ariaSort = 'descending';
} else if (sortState === 'asc') {
SortIcon = ArrowUp;
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-sort={ariaSort}
aria-label={localize('com_nav_archive_name_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
@ -180,26 +176,28 @@ export default function ArchivedChatsTable({
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
header: ({ column }) => {
const sortState = column.getIsSorted();
let SortIcon = ArrowUpDown;
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
if (sortState === 'desc') {
SortIcon = ArrowDown;
ariaSort = 'descending';
} else if (sortState === 'asc') {
SortIcon = ArrowUp;
ariaSort = 'ascending';
}
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
onClick={() =>
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-label={localize('com_nav_archive_created_at_sort')}
aria-sort={ariaSort}
aria-label={localize('com_nav_archive_created_at_sort' as TranslationKeys)}
aria-current={sortState ? 'true' : 'false'}
>
{localize('com_nav_archive_created_at')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
</Button>
);
},
@ -270,7 +268,7 @@ export default function ArchivedChatsTable({
},
},
],
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
[isSmallScreen, localize, unarchiveMutation],
);
return (

View file

@ -1,7 +1,7 @@
export * from './ExportConversation';
export * from './SettingsTabs/';
export { default as MobileNav } from './MobileNav';
export { default as Nav } from './Nav';
export { default as Nav, NAV_WIDTH } from './Nav';
export { default as NavLink } from './NavLink';
export { default as NewChat } from './NewChat';
export { default as SearchBar } from './SearchBar';

Some files were not shown because too many files have changed in this diff Show more