✨ feat: add morphing state icons across the client (#14697)

* feat(client-ui): add morphing icon support

* feat(client-ui): morph shared state icons

* feat(chat-ui): morph chat action icons

* feat(client-ui): morph remaining action icons

* fix(client-ui): type morph icon test factory

* test(client): type agent detail fixture

* test(client-ui): guard morph icon accessibility defaults

* test(chat-ui): assert mermaid view toggle via morph icon

* style(chat-ui): sort mermaid header imports

* test(api): wait for rebuilt MCP session to finish connecting

The SSE conflict recovery test treated a new session id as a finished
reconnect. initialize assigns that id before connectClient emits
connected, so isConnected() can still be false. Wait for a live
connection on the rebuilt session instead.

* fix(client): clean up rebase fallout
This commit is contained in:
Marco Beretta 2026-09-04 03:27:14 +02:00 • committed by GitHub
parent 2fe872d2ea
commit bd31ecd129
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 667 additions and 291 deletions

View file

@ -49,7 +49,7 @@ module.exports = {
'jest-file-loader',
},
transformIgnorePatterns: [
'/node_modules/(?!(@ariakit/react-components|@ariakit/react-utils|@ariakit/react-store|@ariakit/components|@ariakit/store|@ariakit/utils|@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/',
'/node_modules/(?!(@ariakit/react-components|@ariakit/react-utils|@ariakit/react-store|@ariakit/components|@ariakit/store|@ariakit/utils|@zattoo/use-double-click|@dicebear|@react-dnd|react-dnd.*|dnd-core|filenamify|filename-reserved-regex|heic-to|lowlight|highlight\\.js|fault|lucide-react|lucide|morphicons|react-markdown|unified|bail|trough|devlop|is-.*|parse-entities|stringify-entities|character-.*|trim-lines|style-to-object|inline-style-parser|html-url-attributes|escape-string-regexp|longest-streak|zwitch|ccount|markdown-table|comma-separated-tokens|space-separated-tokens|web-namespaces|property-information|remark-.*|rehype-.*|recma-.*|hast.*|mdast-.*|unist-.*|vfile.*|micromark.*|estree-util-.*|decode-named-character-reference)/)/',
],
setupFiles: ['<rootDir>/test/polyfills.js'],
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],

View file

@ -80,7 +80,8 @@
"js-cookie": "^3.0.5",
"librechat-data-provider": "*",
"lodash": "^4.17.23",
"lucide-react": "^0.394.0",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"match-sorter": "^8.1.0",
"mdast-util-directive": "^3.0.0",
"mdast-util-from-markdown": "^2.0.1",
@ -94,6 +95,7 @@
"micromark-util-character": "^2.1.0",
"micromark-util-symbol": "^2.0.0",
"monaco-editor": "^0.56.0",
"morphicons": "^1.4.1",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",

View file

@ -1,10 +1,12 @@
import React, { useRef } from 'react';
import { Link, Pin, PinOff } from 'lucide-react';
import { Link } from 'lucide-react';
import { Pin, PinOff } from 'lucide';
import { useQueryClient } from '@tanstack/react-query';
import {
OGDialog,
OGDialogContent,
Button,
MorphIcon,
TooltipAnchor,
useToastContext,
} from '@librechat/client';
@ -181,7 +183,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
onClick={handleFavoriteClick}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
{isFavorite ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
<MorphIcon icon={isFavorite ? PinOff : Pin} className="h-4 w-4" />
</Button>
}
/>

View file

@ -1,7 +1,14 @@
import React from 'react';
import { Link, Pin, PinOff } from 'lucide-react';
import { Link } from 'lucide-react';
import { Pin, PinOff } from 'lucide';
import { useQueryClient } from '@tanstack/react-query';
import { OGDialogContent, Button, TooltipAnchor, useToastContext } from '@librechat/client';
import {
Button,
MorphIcon,
TooltipAnchor,
OGDialogContent,
useToastContext,
} from '@librechat/client';
import {
QueryKeys,
Constants,
@ -129,7 +136,7 @@ const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
onClick={handleFavoriteClick}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
{isFavorite ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
<MorphIcon icon={isFavorite ? PinOff : Pin} className="h-4 w-4" />
</Button>
}
/>

View file

@ -4,10 +4,12 @@ import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TConversation } from 'librechat-data-provider';
import '@testing-library/jest-dom';
import type t from 'librechat-data-provider';
import AgentDetailContent from '../AgentDetailContent';
const mockNewConversation = jest.fn();
let mockConversation: Partial<TConversation> | undefined;
const mockIsFavoriteAgent = jest.fn(() => false);
jest.mock('librechat-data-provider', () => ({
QueryKeys: {
@ -28,23 +30,27 @@ jest.mock('librechat-data-provider', () => ({
},
}));
jest.mock('@librechat/client', () => ({
OGDialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock('@librechat/client', () => {
const { createPinMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
return {
OGDialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
MorphIcon: createPinMorphIconMock(),
useToastContext: () => ({
showToast: jest.fn(),
}),
};
});
jest.mock('~/hooks', () => ({
useDefaultConvo: () => jest.fn((value) => value.conversation),
useFavorites: () => ({
isFavoriteAgent: jest.fn(() => false),
isFavoriteAgent: mockIsFavoriteAgent,
toggleFavoriteAgent: jest.fn(),
}),
useLocalize: () => (key: string, values?: Record<string, string>) => {
@ -92,31 +98,40 @@ const renderWithClient = (children: React.ReactNode) => {
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>);
};
const baseAgent = {
const baseAgent: t.Agent = {
id: 'agent-1',
name: 'Agent One',
description: 'Agent description',
created_at: 0,
avatar: null,
provider: 'openai',
model: 'gpt-4',
model_parameters: {},
} as React.ComponentProps<typeof AgentDetailContent>['agent'];
model_parameters: {
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
};
describe('AgentDetailContent', () => {
beforeEach(() => {
jest.clearAllMocks();
mockConversation = undefined;
mockIsFavoriteAgent.mockReturnValue(false);
});
it('renders support contact with mailto link', () => {
renderWithClient(
<AgentDetailContent
agent={
{
...baseAgent,
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User' },
} as any
}
agent={{
...baseAgent,
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User' },
}}
/>,
);
@ -131,12 +146,10 @@ describe('AgentDetailContent', () => {
it('falls back to owner contact when support contact is missing', () => {
renderWithClient(
<AgentDetailContent
agent={
{
...baseAgent,
owner_contact: { name: 'Owner User' },
} as any
}
agent={{
...baseAgent,
owner_contact: { name: 'Owner User' },
}}
/>,
);
@ -171,4 +184,16 @@ describe('AgentDetailContent', () => {
}),
});
});
it('shows pin icon when agent is not a favorite and pin-off when it is', () => {
const { unmount } = renderWithClient(<AgentDetailContent agent={baseAgent} />);
expect(screen.getByRole('button', { name: 'Pin' })).toBeInTheDocument();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'pin');
unmount();
mockIsFavoriteAgent.mockReturnValue(true);
renderWithClient(<AgentDetailContent agent={baseAgent} />);
expect(screen.getByRole('button', { name: 'Unpin' })).toBeInTheDocument();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'pin-off');
});
});

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Button } from '@librechat/client';
import { Download, CircleCheckBig } from 'lucide-react';
import { Download, CircleCheckBig } from 'lucide';
import { Button, MorphIcon } from '@librechat/client';
import type { Artifact } from '~/common';
import {
useAttachmentLink,
@ -94,11 +94,7 @@ const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
onClick={handleDownload}
aria-label={localize('com_ui_download_artifact')}
>
{isDownloaded ? (
<CircleCheckBig size={16} aria-hidden="true" />
) : (
<Download size={16} aria-hidden="true" />
)}
<MorphIcon icon={isDownloaded ? CircleCheckBig : Download} size={16} />
</Button>
);
};

View file

@ -22,6 +22,20 @@ jest.mock('~/Providers/EditorContext', () => ({
useCodeState: () => ({ currentCode: undefined }),
}));
/* MorphIcon renders a single morphing <svg> with no per-icon class, so map
* the lucide icon data it was handed back to a stable name instead. */
jest.mock('@librechat/client', () => {
const { createMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
const { Download, CircleCheckBig } = jest.requireActual('lucide');
return {
...jest.requireActual('@librechat/client'),
MorphIcon: createMorphIconMock([
[Download, 'download'],
[CircleCheckBig, 'circle-check-big'],
]),
};
});
jest.mock('~/components/Chat/Messages/Content/Parts/LogLink', () => ({
useAttachmentLink: () => ({ handleDownload: mockFileDownload }),
isLocallyStoredSource: (source?: string) =>
@ -118,7 +132,7 @@ describe('DownloadArtifact', () => {
// The preview HTML must NOT be serialized into a blob download.
expect(createObjectURL).not.toHaveBeenCalled();
// A delivered file flips the button to the success checkmark.
expect(container.querySelector('.lucide-circle-check-big')).not.toBeNull();
expect(container.querySelector('[data-icon="circle-check-big"]')).not.toBeNull();
});
it('does NOT show success when the original-file download fails', async () => {
@ -130,8 +144,8 @@ describe('DownloadArtifact', () => {
fireEvent.click(screen.getByRole('button'));
});
expect(mockFileDownload).toHaveBeenCalledTimes(1);
expect(container.querySelector('.lucide-circle-check-big')).toBeNull();
expect(container.querySelector('.lucide-download')).not.toBeNull();
expect(container.querySelector('[data-icon="circle-check-big"]')).toBeNull();
expect(container.querySelector('[data-icon="download"]')).not.toBeNull();
});
it('serializes content as a blob for a non-file-backed (LLM-authored) artifact', async () => {

View file

@ -1,6 +1,6 @@
import React from 'react';
import { TooltipAnchor } from '@librechat/client';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ChevronDown, ChevronUp } from 'lucide';
import { MorphIcon, TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -40,11 +40,7 @@ const CollapseChat = ({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary focus-visible:ring-opacity-50',
)}
>
{isCollapsed ? (
<ChevronUp className="h-full w-full" aria-hidden="true" />
) : (
<ChevronDown className="h-full w-full" aria-hidden="true" />
)}
<MorphIcon icon={isCollapsed ? ChevronUp : ChevronDown} className="h-full w-full" />
</button>
}
/>

View file

@ -1,8 +1,9 @@
import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { X, Clock, Pencil } from 'lucide-react';
import { useSetAtom, useAtomValue } from 'jotai';
import { useToastContext } from '@librechat/client';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
import { Zap, ZapOff, ChevronUp, ChevronDown } from 'lucide';
import { MorphIcon, useToastContext } from '@librechat/client';
import type { TFile, TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { SteerReceiptState } from '~/components/Chat/Steering/Receipt';
@ -517,11 +518,10 @@ const InFlightSteer = memo(function InFlightSteer({
sending && 'opacity-70',
)}
>
{preempting ? (
<ZapOff className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
) : (
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
)}
<MorphIcon
icon={preempting ? ZapOff : Zap}
className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500"
/>
<span className="sr-only">
{localize(preempting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_in_flight')}
</span>
@ -565,11 +565,7 @@ const InFlightSteer = memo(function InFlightSteer({
aria-controls={contentId}
className="inline-flex items-center gap-1 rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
>
{expanded ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
<MorphIcon icon={expanded ? ChevronUp : ChevronDown} className="h-3.5 w-3.5" />
{expanded ? localize('com_ui_show_less') : localize('com_ui_show_more')}
</button>
)}

View file

@ -1,7 +1,9 @@
import { Fragment, useEffect, useId, useMemo, useState, useSyncExternalStore } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { Zap, ZapOff, Clock, ArrowUp, CircleHelp, MoreHorizontal } from 'lucide-react';
import { Zap, ZapOff, Clock } from 'lucide';
import { MorphIcon } from '@librechat/client';
import { ArrowUp, CircleHelp, MoreHorizontal } from 'lucide-react';
import type { Ref } from 'react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
@ -283,12 +285,12 @@ export function useDefaultToggleEntry(steering: SteeringControls): MenuEntry {
next === 'queue'
? localize('com_ui_turn_on_queueing')
: localize('com_ui_turn_on_steering'),
icon:
next === 'queue' ? (
<Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />
) : (
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
),
icon: (
<MorphIcon
icon={next === 'queue' ? Clock : Zap}
className={cn('h-4 w-4', next === 'queue' ? 'text-cyan-500' : 'text-amber-500')}
/>
),
info: localize('com_nav_info_during_run_action'),
onClick: () => steering.setDefaultAction(next),
};
@ -311,10 +313,8 @@ export function useInterruptToggleEntry(): MenuEntry {
label: interruptsByDefault
? localize('com_ui_wait_for_tool_steps')
: localize('com_ui_always_interrupt'),
icon: interruptsByDefault ? (
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
) : (
<ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />
icon: (
<MorphIcon icon={interruptsByDefault ? Zap : ZapOff} className="h-4 w-4 text-amber-500" />
),
info: localize(
!interruptsByDefault && defaultAction === 'queue'

View file

@ -27,18 +27,24 @@ jest.mock('~/hooks', () => ({
useSteerReclaim: jest.requireActual('~/hooks/Chat/useSteerCancel').useSteerReclaim,
}));
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: mockShowToast }),
/** Trigger-only stand-in: the receipt renders its marks as the hover card's
* custom trigger, so the mock must render children under the accessible
* name rather than swallowing them. */
InfoHoverCard: ({ text, children }: { text: string; children?: React.ReactNode }) => (
<button type="button" aria-label={text}>
{children}
</button>
),
ESide: { Top: 'top', Bottom: 'bottom' },
}));
jest.mock('~/../test/mockMorphIcon', () => jest.requireActual('~/../test/mockMorphIcon'));
jest.mock('@librechat/client', () => {
const { createSteerMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
return {
useToastContext: () => ({ showToast: mockShowToast }),
/** Trigger-only stand-in: the receipt renders its marks as the hover card's
* custom trigger, so the mock must render children under the accessible
* name rather than swallowing them. */
InfoHoverCard: ({ text, children }: { text: string; children?: React.ReactNode }) => (
<button type="button" aria-label={text}>
{children}
</button>
),
ESide: { Top: 'top', Bottom: 'bottom' },
MorphIcon: createSteerMorphIconMock(),
};
});
jest.mock('~/data-provider', () => ({
useCancelSteerMutation: () => ({ mutateAsync: mockCancelMutateAsync }),
@ -730,9 +736,11 @@ describe('InFlightSteers', () => {
]);
const toggle = screen.getByRole('button', { name: 'com_ui_show_more' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle.querySelector('[data-icon="chevron-down"]')).not.toBeNull();
fireEvent.click(toggle);
const collapse = screen.getByRole('button', { name: 'com_ui_show_less' });
expect(collapse).toHaveAttribute('aria-expanded', 'true');
expect(collapse.querySelector('[data-icon="chevron-up"]')).not.toBeNull();
} finally {
scrollHeight.mockRestore();
}
@ -813,6 +821,10 @@ describe('InFlightSteers — interrupt-now escalation', () => {
expect(screen.queryByTestId('steer-escalate-now')).toBeNull();
expect(document.activeElement).toBe(screen.getByLabelText('com_ui_more_options'));
expect(screen.getByRole('status')).toHaveTextContent('com_ui_steer_in_flight_preempt');
// Preempting steers use ZapOff; non-preempt uses Zap.
expect(
screen.getByTestId('in-flight-steer').querySelector('[data-icon="zap-off"]'),
).not.toBeNull();
});
it('flips the receipt to interrupting on the click, confirming its check only on the ACK', async () => {

View file

@ -17,9 +17,15 @@ const mockRestoreToComposer = jest.fn(() => true);
const mockEditToComposer = jest.fn();
const mockShowToast = jest.fn();
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
// SteerMenu (rendered under PendingSteerChips) uses MorphIcon; map icon
// identity so inverted Zap/Clock ternaries fail tests instead of going silent.
jest.mock('@librechat/client', () => {
const { createSteerMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
return {
useToastContext: () => ({ showToast: mockShowToast }),
MorphIcon: createSteerMorphIconMock(),
};
});
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,

View file

@ -1,6 +1,8 @@
import React from 'react';
import { Pin, PinOff } from 'lucide';
import { MorphIcon } from '@librechat/client';
import { VisuallyHidden } from '@ariakit/react';
import { CheckCircle2, EarthIcon, Pin, PinOff } from 'lucide-react';
import { CheckCircle2, EarthIcon } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { useModelSelectorContext } from '../ModelSelectorContext';
@ -128,11 +130,7 @@ function EndpointModelItemComponent({
'group-focus-within:visible group-hover:visible group-data-[active-item]:visible [@media(hover:hover)]:invisible',
)}
>
{isFavorite ? (
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
) : (
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
)}
<MorphIcon icon={isFavorite ? PinOff : Pin} className="h-4 w-4 text-text-secondary" />
</button>
{isSelected && (
<>

View file

@ -1,6 +1,8 @@
import React from 'react';
import { Pin, PinOff } from 'lucide';
import { CheckCircle2 } from 'lucide-react';
import { MorphIcon } from '@librechat/client';
import { VisuallyHidden } from '@ariakit/react';
import { CheckCircle2, Pin, PinOff } from 'lucide-react';
import type { TModelSpec } from 'librechat-data-provider';
import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks';
import { useModelSelectorContext } from '../ModelSelectorContext';
@ -79,11 +81,7 @@ export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpec
'group-focus-within:visible group-hover:visible group-data-[active-item]:visible [@media(hover:hover)]:invisible',
)}
>
{isFavorite ? (
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
) : (
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
)}
<MorphIcon icon={isFavorite ? PinOff : Pin} className="h-4 w-4 text-text-secondary" />
</button>
{isSelected && (
<>

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { PanelLeftOpen, PanelLeftClose } from 'lucide';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button, TooltipAnchor } from '@librechat/client';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import { X, ArrowDownToLine, RotateCcw } from 'lucide-react';
import { Button, MorphIcon, TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
const imageSizeCache = new Map<string, string>();
@ -328,11 +329,10 @@ export default function DialogImage({
className="h-10 w-10 p-0 text-white hover:bg-white/10"
aria-label={imageDetailsLabel}
>
{isPromptOpen ? (
<PanelLeftOpen className="size-5" aria-hidden="true" />
) : (
<PanelLeftClose className="size-5" aria-hidden="true" />
)}
<MorphIcon
icon={isPromptOpen ? PanelLeftOpen : PanelLeftClose}
className="size-5"
/>
</Button>
}
/>

View file

@ -1,7 +1,8 @@
import { memo, useMemo, useState, useCallback, useRef, useId, useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { Copy, Check } from 'lucide';
import { ScrollText, ChevronDown, ChevronUp } from 'lucide-react';
import { Button, Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Button, MorphIcon, TooltipAnchor } from '@librechat/client';
import type { SummaryContentPart } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { fontSizeAtom } from '~/store/fontSize';
@ -120,11 +121,7 @@ const SummaryButton = memo(
<span className="sr-only">
{isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_summary')}
</span>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard size="19" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={18} />
</Button>
)}
</div>
@ -196,11 +193,7 @@ const FloatingSummaryBar = memo(
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard size="18" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={18} />
</button>
}
/>

View file

@ -1,7 +1,8 @@
import { useState, useMemo, memo, useCallback, useRef, useId, type MouseEvent } from 'react';
import { useAtomValue } from 'jotai';
import { Lightbulb, ChevronDown, ChevronUp } from 'lucide-react';
import { Button, Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Lightbulb, ChevronDown } from 'lucide-react';
import { Button, MorphIcon, TooltipAnchor } from '@librechat/client';
import { Copy, Check, ChevronUp as ChevronUpNode, ChevronDown as ChevronDownNode } from 'lucide';
import type { FocusEvent, FC } from 'react';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
@ -157,11 +158,7 @@ export const ThinkingButton = memo(
? localize('com_ui_copied_to_clipboard')
: localize('com_ui_copy_thoughts_to_clipboard')}
</span>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard size="19" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={18} />
</Button>
)}
</div>
@ -257,11 +254,10 @@ export const FloatingThinkingBar = memo(
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isExpanded ? (
<ChevronUp className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<ChevronDown className="h-[18px] w-[18px]" aria-hidden="true" />
)}
<MorphIcon
icon={isExpanded ? ChevronUpNode : ChevronDownNode}
className="h-[18px] w-[18px]"
/>
</button>
}
/>
@ -280,11 +276,7 @@ export const FloatingThinkingBar = memo(
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard size="18" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={18} />
</button>
}
/>

View file

@ -1,10 +1,10 @@
import React, { useState, useMemo, memo } from 'react';
import { Copy, Check } from 'lucide';
import { useRecoilState } from 'recoil';
import {
Button,
EditIcon,
CheckMark,
Clipboard,
MorphIcon,
ContinueIcon,
TooltipAnchor,
RegenerateIcon,
@ -214,7 +214,7 @@ const HoverButtons = ({
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
icon={isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
icon={<MorphIcon icon={isCopied ? Check : Copy} size={19} />}
isLast={isLast}
disabled={!canCopy}
className={cn(

View file

@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { Button, Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
import { Copy, Check } from 'lucide';
import { Button, MorphIcon, TooltipAnchor } from '@librechat/client';
import type { TMessage, SearchResultData } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard, hasCopyableText } from '~/hooks';
import { revealOnRowHoverClasses } from './styles';
@ -47,11 +48,7 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
disabled={!canCopy}
onClick={() => copyToClipboard(setIsCopied)}
>
{isCopied ? (
<CheckMark className="h-[19px] w-[19px]" />
) : (
<Clipboard className="h-[19px] w-[19px]" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={19} />
</Button>
}
/>

View file

@ -1,11 +1,11 @@
import React from 'react';
import { Check } from 'lucide-react';
import { TooltipAnchor } from '@librechat/client';
import type { LucideIcon } from 'lucide-react';
import { Check } from 'lucide';
import { MorphIcon, TooltipAnchor } from '@librechat/client';
import type { IconInput } from '@librechat/client';
import cn from '~/utils/cn';
interface ActionButtonProps {
icon: LucideIcon;
icon: IconInput;
isActive: boolean;
label: string;
activeLabel: string;
@ -19,7 +19,7 @@ interface ActionButtonProps {
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
(
{
icon: Icon,
icon,
isActive,
label,
activeLabel,
@ -48,22 +48,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
className,
)}
>
<span className="relative flex size-[18px] items-center justify-center" aria-hidden="true">
<Icon
size={18}
className={cn(
'absolute transition-all duration-300 ease-out',
isActive ? 'rotate-[-90deg] scale-0 opacity-0' : 'rotate-0 scale-100 opacity-100',
)}
/>
<Check
size={18}
className={cn(
'transition-all duration-300 ease-out',
isActive ? 'rotate-0 scale-100 opacity-100' : 'rotate-90 scale-0 opacity-0',
)}
/>
</span>
<MorphIcon icon={isActive ? Check : icon} size={18} />
{!iconOnly && (
<span className="relative overflow-hidden">
<span

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Copy } from 'lucide-react';
import { Copy } from 'lucide';
import ActionButton from '~/components/Messages/Content/ActionButton';
import { useLocalize } from '~/hooks';

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Download } from 'lucide-react';
import { Download } from 'lucide';
import ActionButton from '~/components/Messages/Content/ActionButton';
import { useLocalize } from '~/hooks';

View file

@ -1,11 +1,11 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import { X } from 'lucide-react';
import copy from 'copy-to-clipboard';
import { X, ChevronUp, ChevronDown } from 'lucide-react';
import { Copy, Check, ChevronUp, ChevronDown } from 'lucide';
import {
Button,
OGDialog,
Clipboard,
CheckMark,
MorphIcon,
OGDialogClose,
OGDialogTitle,
OGDialogContent,
@ -103,11 +103,7 @@ const MermaidDialog: React.FC<MermaidDialogProps> = memo(
className="size-8 min-w-0 gap-1 rounded-sm p-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0 sm:h-auto sm:w-auto sm:min-w-[6rem] sm:px-1 sm:py-0"
onClick={handleToggleCode}
>
{showCode ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
<MorphIcon icon={showCode ? ChevronUp : ChevronDown} className="h-4 w-4" />
<span className="hidden sm:inline">
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</span>
@ -120,11 +116,7 @@ const MermaidDialog: React.FC<MermaidDialogProps> = memo(
className="size-8 min-w-0 gap-1 rounded-sm p-0 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-border-heavy focus-visible:ring-offset-0 sm:h-auto sm:w-auto sm:px-1 sm:py-0"
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard className="size-4" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} size={18} />
<span className="hidden sm:inline">{localize('com_ui_copy_code')}</span>
</Button>
<OGDialogClose className="rounded-sm p-1 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy">

View file

@ -18,11 +18,19 @@ jest.mock('~/components/Messages/Content/CopyButton', () => {
};
});
jest.mock('@librechat/client', () => {
const { createMermaidViewMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
return {
...jest.requireActual('@librechat/client'),
MorphIcon: createMermaidViewMorphIconMock(),
};
});
describe('MermaidHeader', () => {
it('uses code and preview icons for the exclusive view action', async () => {
const user = userEvent.setup();
const onToggleCode = jest.fn();
const { container, rerender } = render(
const { rerender } = render(
<MermaidHeader
codeContent={'graph TD\nA-->B'}
showCode={false}
@ -32,7 +40,7 @@ describe('MermaidHeader', () => {
const showCodeButton = screen.getByRole('button', { name: 'com_ui_show_code' });
expect(showCodeButton).toHaveAttribute('aria-pressed', 'false');
expect(container.querySelector('.lucide-code-xml')).not.toBeNull();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'code-2');
await user.click(showCodeButton);
expect(onToggleCode).toHaveBeenCalledTimes(1);
@ -42,6 +50,6 @@ describe('MermaidHeader', () => {
const showPreviewButton = screen.getByRole('button', { name: 'com_ui_preview' });
expect(showPreviewButton).toHaveAttribute('aria-pressed', 'true');
expect(container.querySelector('.lucide-eye')).not.toBeNull();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'eye');
});
});

View file

@ -1,7 +1,8 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import { Eye, Code2 } from 'lucide';
import copy from 'copy-to-clipboard';
import { TooltipAnchor } from '@librechat/client';
import { Code2, Expand, Eye } from 'lucide-react';
import { Expand } from 'lucide-react';
import { MorphIcon, TooltipAnchor } from '@librechat/client';
import type { MermaidDimensions } from '~/utils/diagram/export';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize } from '~/hooks';
@ -106,11 +107,7 @@ const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
className={iconBtnClass}
onClick={handleToggleCode}
>
{showCode ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<Code2 className="h-4 w-4" aria-hidden="true" />
)}
<MorphIcon icon={showCode ? Eye : Code2} className="h-4 w-4" />
</button>
}
/>

View file

@ -1,7 +1,8 @@
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { Copy, Check } from 'lucide';
import { MorphIcon } from '@librechat/client';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { Clipboard, CheckMark } from '@librechat/client';
import { MIN_ZOOM, MAX_ZOOM } from './useMermaidZoom';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
@ -94,7 +95,7 @@ const ZoomControls: React.FC<ZoomControlsProps> = memo(
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
title={localize('com_ui_copy_code')}
>
{isCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
<MorphIcon icon={isCopied ? Check : Copy} className="h-4 w-4" />
</button>
</div>
);

View file

@ -2,8 +2,9 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import debounce from 'lodash/debounce';
import { useRecoilCallback } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { TerminalSquareIcon, Check, X } from 'lucide-react';
import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import { SquareTerminal, Check, X } from 'lucide';
import { MorphIcon, Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import type { IconNode } from '@librechat/client';
import type { CodeBarProps } from '~/common';
import { useToolCallMutation } from '~/data-provider';
import { cn, normalizeLanguage } from '~/utils';
@ -13,6 +14,13 @@ import store from '~/store';
type RunState = 'idle' | 'loading' | 'success' | 'error';
const stateIcons: Record<RunState, IconNode> = {
idle: SquareTerminal,
loading: SquareTerminal,
success: Check,
error: X,
};
const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
({ lang, codeRef, blockIndex, iconOnly = false }) => {
const localize = useLocalize();
@ -100,11 +108,7 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
const isIdle = runState === 'idle';
const label = localize('com_ui_run_code');
const iconClass = (active: boolean) =>
cn(
'absolute transition-all duration-300 ease-out',
active ? 'rotate-0 scale-100 opacity-100' : 'scale-0 opacity-0 rotate-90',
);
const stateIcon = stateIcons[runState];
const button = (
<button
@ -125,7 +129,14 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
)}
>
<span className="relative flex size-[18px] items-center justify-center" aria-hidden="true">
<TerminalSquareIcon size={18} className={iconClass(isIdle)} />
<MorphIcon
icon={stateIcon}
size={18}
className={cn(
'absolute transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100',
)}
/>
<span
className={cn(
'absolute transition-opacity duration-300',
@ -134,8 +145,6 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
>
<Spinner className="animate-spin" size={18} />
</span>
<Check size={18} className={iconClass(isSuccess)} />
<X size={18} className={iconClass(isError)} />
</span>
{!iconOnly && (
<span className="relative hidden overflow-hidden md:block">

View file

@ -0,0 +1,38 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import CopyButton from '../CopyButton';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('@librechat/client', () => {
const { createCopyCheckMorphIconMock } = jest.requireActual('~/../test/mockMorphIcon');
return {
MorphIcon: createCopyCheckMorphIconMock(),
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
};
});
describe('CopyButton', () => {
it('renders the copy icon when not copied', () => {
render(<CopyButton isCopied={false} onClick={jest.fn()} />);
expect(screen.getByRole('button', { name: 'com_ui_copy' })).toBeInTheDocument();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'copy');
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-size', '18');
});
it('renders the check icon when copied', () => {
render(<CopyButton isCopied onClick={jest.fn()} />);
expect(screen.getByRole('button', { name: 'com_ui_copied' })).toBeInTheDocument();
expect(screen.getByTestId('morph-icon')).toHaveAttribute('data-icon', 'check');
});
it('invokes onClick when pressed', () => {
const onClick = jest.fn();
render(<CopyButton isCopied={false} onClick={onClick} />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_copy' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,12 +1,12 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { Copy, Check } from 'lucide';
import rehypeKatex from 'rehype-katex';
import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { Copy, Check } from 'lucide-react';
import { Button, TooltipAnchor, useToastContext } from '@librechat/client';
import { Button, MorphIcon, TooltipAnchor, useToastContext } from '@librechat/client';
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
import { PromptVariableGfm } from '../editor/Markdown';
import { useLocalize } from '~/hooks';
@ -67,11 +67,7 @@ const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
}
aria-live="polite"
>
{isCopied ? (
<Check className="size-4 text-text-secondary" aria-hidden="true" />
) : (
<Copy className="size-4 text-text-secondary" aria-hidden="true" />
)}
<MorphIcon icon={isCopied ? Check : Copy} className="size-4 text-text-secondary" />
</Button>
}
/>

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link, CopyCheck } from 'lucide';
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
import { Share2Icon, Users, Link, CopyCheck, UserCheck, AlertCircle } from 'lucide-react';
import { Share2Icon, Users, UserCheck, AlertCircle } from 'lucide-react';
import {
Alert,
Label,
@ -8,6 +9,7 @@ import {
Spinner,
Skeleton,
OGDialog,
MorphIcon,
OGDialogHeader,
OGDialogFooter,
OGDialogTitle,
@ -431,11 +433,7 @@ export default function GenericGrantAccessDialog({
className={cn('shrink-0 gap-2', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_url_to_clipboard')}
>
{isCopying ? (
<CopyCheck className="size-4" aria-hidden="true" />
) : (
<Link className="size-4" aria-hidden="true" />
)}
<MorphIcon icon={isCopying ? CopyCheck : Link} className="size-4" />
{isCopying
? config?.getCopyUrlMessage()
: localize('com_ui_copy_url_to_clipboard')}

View file

@ -1,7 +1,7 @@
import { useRef } from 'react';
import { Button } from '@librechat/client';
import { KeyRound, CircleCheck } from 'lucide';
import { useFormContext } from 'react-hook-form';
import { KeyRound, CircleCheck } from 'lucide-react';
import { Button, MorphIcon } from '@librechat/client';
import {
AuthType,
RerankerTypes,
@ -12,6 +12,7 @@ import {
import type { AgentForm } from '~/common';
import { useLocalize, useSearchApiKeyForm } from '~/hooks';
import ApiKeyDialog from './ApiKeyDialog';
import { cn } from '~/utils';
export default function Action({
authTypes = [],
@ -62,11 +63,10 @@ export default function Action({
aria-haspopup="dialog"
className="w-full justify-center gap-2"
>
{isToolAuthenticated ? (
<CircleCheck className="h-4 w-4 text-green-500" aria-hidden="true" />
) : (
<KeyRound className="h-4 w-4" aria-hidden="true" />
)}
<MorphIcon
icon={isToolAuthenticated ? CircleCheck : KeyRound}
className={cn('h-4 w-4', isToolAuthenticated && 'text-green-500')}
/>
{localize(
isToolAuthenticated
? 'com_ui_manage_web_search_api_keys'

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import { Copy, CopyCheck } from 'lucide';
import {
SystemRoles,
Permissions,
@ -12,6 +12,7 @@ import {
Input,
Button,
Spinner,
MorphIcon,
TrashIcon,
useToastContext,
OGDialog,
@ -171,7 +172,7 @@ export default function MCPServerDialog({
className="p-0"
aria-label={localize('com_ui_copy_link')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
<MorphIcon icon={isCopying ? CopyCheck : Copy} className="size-4" />
</Button>
</div>
</div>

View file

@ -1,8 +1,16 @@
import { useMemo, useState } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import { Copy, CopyCheck } from 'lucide';
import { useFormContext, useWatch } from 'react-hook-form';
import { Permissions, PermissionTypes, TokenExchangeMethodEnum } from 'librechat-data-provider';
import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client';
import {
Label,
Input,
Radio,
Checkbox,
MorphIcon,
SecretInput,
useToastContext,
} from '@librechat/client';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
@ -329,7 +337,7 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border-light text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
aria-label={localize('com_ui_copy_link')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
<MorphIcon icon={isCopying ? CopyCheck : Copy} className="size-4" />
</button>
</div>
</div>

View file

@ -1,8 +1,9 @@
import React, { memo, useMemo, useState, useCallback, useRef } from 'react';
import { Copy, Check } from 'lucide';
import { useNavigate } from 'react-router-dom';
import { apiBaseUrl } from 'librechat-data-provider';
import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import { ArrowLeft, Copy, Check, FileText, FileQuestion } from 'lucide-react';
import { ArrowLeft, FileText, FileQuestion } from 'lucide-react';
import { Spinner, MorphIcon, TooltipAnchor, useToastContext } from '@librechat/client';
import { useGetSkillFileContentQuery } from '~/data-provider';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { parseFrontmatter } from '../utils';
@ -90,7 +91,7 @@ function SkillFileViewer({ skillId, relativePath }: SkillFileViewerProps) {
className="rounded-md p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
aria-label={localize('com_ui_copy_to_clipboard')}
>
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
<MorphIcon icon={isCopied ? Check : Copy} className="size-4" />
</button>
}
/>

View file

@ -3,11 +3,11 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import supersub from 'remark-supersub';
import { Check, SquarePen } from 'lucide';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { EditIcon, Check } from 'lucide-react';
import { Controller, useFormContext } from 'react-hook-form';
import { TextareaAutosize, Button, TooltipAnchor } from '@librechat/client';
import { TextareaAutosize, Button, MorphIcon, TooltipAnchor } from '@librechat/client';
import type { RegisterOptions } from 'react-hook-form';
import type { PluggableList } from 'unified';
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
@ -53,8 +53,6 @@ const SkillContentEditor: React.FC<SkillContentEditorProps> = ({
formState: { errors },
} = useFormContext();
const EditorIcon = isEditing ? Check : EditIcon;
return (
<div className="flex max-h-[85vh] flex-col sm:max-h-[85vh]">
<h2 className="sr-only">{localize('com_ui_skill_content')}</h2>
@ -77,7 +75,10 @@ const SkillContentEditor: React.FC<SkillContentEditorProps> = ({
aria-label={isEditing ? localize('com_ui_save') : localize('com_ui_edit')}
className="size-8 p-0 hover:bg-surface-tertiary"
>
<EditorIcon className="size-4 text-text-secondary" aria-hidden="true" />
<MorphIcon
icon={isEditing ? Check : SquarePen}
className="size-4 text-text-secondary"
/>
</Button>
}
/>
@ -133,7 +134,7 @@ const SkillContentEditor: React.FC<SkillContentEditorProps> = ({
)}
<div className="pointer-events-none sticky bottom-1/2 z-10 flex translate-y-1/2 items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-1.5 shadow-md">
<EditIcon className="size-4 text-text-secondary" aria-hidden="true" />
<MorphIcon icon={SquarePen} className="size-4 text-text-secondary" />
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_click_to_edit')}
</span>

View file

@ -0,0 +1,82 @@
import React from 'react';
export type MockMorphIconProps = {
icon?: unknown;
className?: string;
size?: number | string;
'data-testid'?: string;
};
/**
* Build a MorphIcon mock that maps lucide IconNode identity to a stable
* `data-icon` attribute so tests can assert which icon was selected.
*
* Prefer this over a bare `<svg data-testid="morph-icon" />` stub that
* swallows the `icon` prop and hides inverted ternaries.
*/
export function createMorphIconMock(iconNames: Map<unknown, string> | Array<[unknown, string]>) {
const names = iconNames instanceof Map ? iconNames : new Map(iconNames);
return function MockMorphIcon({ icon, className, size, ...rest }: MockMorphIconProps) {
return (
<svg
data-testid="morph-icon"
data-icon={names.get(icon) ?? 'unknown'}
data-size={size != null ? String(size) : undefined}
className={className}
{...rest}
/>
);
};
}
/** Common lucide icon map for copy/check morph sites. */
export function createCopyCheckMorphIconMock() {
const { Copy, Check } = jest.requireActual('lucide') as Record<string, unknown>;
return createMorphIconMock([
[Copy, 'copy'],
[Check, 'check'],
]);
}
/** Pin favorite morph sites. */
export function createPinMorphIconMock() {
const { Pin, PinOff } = jest.requireActual('lucide') as Record<string, unknown>;
return createMorphIconMock([
[Pin, 'pin'],
[PinOff, 'pin-off'],
]);
}
/** In-flight steers: preempt Zap + expand chevrons. */
export function createSteerMorphIconMock() {
const { Zap, ZapOff, ChevronUp, ChevronDown, Clock } = jest.requireActual('lucide') as Record<
string,
unknown
>;
return createMorphIconMock([
[Zap, 'zap'],
[ZapOff, 'zap-off'],
[ChevronUp, 'chevron-up'],
[ChevronDown, 'chevron-down'],
[Clock, 'clock'],
]);
}
/** Mermaid header: exclusive code/preview view toggle. */
export function createMermaidViewMorphIconMock() {
const { Eye, Code2 } = jest.requireActual('lucide') as Record<string, unknown>;
return createMorphIconMock([
[Eye, 'eye'],
[Code2, 'code-2'],
]);
}
/** Subagent expand/collapse. */
export function createSubagentMorphIconMock() {
const { Minimize2, Maximize2 } = jest.requireActual('lucide') as Record<string, unknown>;
return createMorphIconMock([
[Minimize2, 'minimize-2'],
[Maximize2, 'maximize-2'],
]);
}

54
package-lock.json generated
View file

@ -976,7 +976,8 @@
"js-cookie": "^3.0.5",
"librechat-data-provider": "*",
"lodash": "^4.17.23",
"lucide-react": "^0.394.0",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"match-sorter": "^8.1.0",
"mdast-util-directive": "^3.0.0",
"mdast-util-from-markdown": "^2.0.1",
@ -990,6 +991,7 @@
"micromark-util-character": "^2.1.0",
"micromark-util-symbol": "^2.0.0",
"monaco-editor": "^0.56.0",
"morphicons": "^1.4.1",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
@ -1178,15 +1180,6 @@
"@babel/core": "^7.10.0"
}
},
"client/node_modules/lucide-react": {
"version": "0.394.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.394.0.tgz",
"integrity": "sha512-PzTbJ0bsyXRhH59k5qe7MpTd5MxlpYZUcM9kGSwvPGAfnn0J6FElDwu2EX6Vuh//F7y60rcVJiFQ7EK9DCMgfw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
},
"client/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -31471,11 +31464,16 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/lucide": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.525.0.tgz",
"integrity": "sha512-sfehWlaE/7NVkcEQ4T9JD3eID8RNMIGJBBUq9wF3UFiJIrcMKRbU3g1KGfDk4svcW7yw8BtDLXaXo02scDtUYQ==",
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -33387,6 +33385,36 @@
"color-name": "^1.1.4"
}
},
"node_modules/morphicons": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/morphicons/-/morphicons-1.4.1.tgz",
"integrity": "sha512-l+F51rwvCAvuTnVq9eKrrwjd2oM40dW/fQ6fWQyz2FVSwB9Gq+mcnznJQPeFpenDvBY9qFEg17WTTCwChBdabw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18",
"react-native": ">=0.71",
"react-native-svg": ">=14",
"svelte": ">=5",
"vue": ">=3.3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"react-native-svg": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
@ -43305,7 +43333,9 @@
"jest-environment-jsdom": "^30.2.0",
"jotai": "^2.12.5",
"librechat-data-provider": "*",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"morphicons": "^1.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
@ -43354,8 +43384,10 @@
"input-otp": "^1.4.2",
"jotai": "^2.12.5",
"librechat-data-provider": "*",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"match-sorter": "^8.1.0",
"morphicons": "^1.4.1",
"rc-input-number": "^7.4.2",
"react": "^18.2.0 || ^19.1.0",
"react-dom": "^18.2.0 || ^19.1.0",

View file

@ -262,6 +262,11 @@ describe('MCPConnection standalone SSE stream conflict', () => {
.transport?.sessionId;
expect(firstSessionId).toBeTruthy();
/**
* `client.connect()` assigns the new session id as soon as initialize returns,
* before `connectClient` emits `connected`. Waiting on the id swap alone races
* that handshake, so require a live connection on the rebuilt session.
*/
await waitForCondition(async () => {
const current = (connection as unknown as { transport?: { sessionId?: string } }).transport
?.sessionId;

View file

@ -27,7 +27,7 @@ export default {
'^.+\\.(ts|tsx|js|jsx)$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(@tanstack|lucide-react|@dicebear|@ariakit/react-components|@ariakit/react-utils|@ariakit/react-store|@ariakit/components|@ariakit/store|@ariakit/utils)/)',
'node_modules/(?!(@tanstack|lucide-react|lucide|morphicons|@dicebear|@ariakit/react-components|@ariakit/react-utils|@ariakit/react-store|@ariakit/components|@ariakit/store|@ariakit/utils)/)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};

View file

@ -78,8 +78,10 @@
"input-otp": "^1.4.2",
"jotai": "^2.12.5",
"librechat-data-provider": "*",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"match-sorter": "^8.1.0",
"morphicons": "^1.4.1",
"rc-input-number": "^7.4.2",
"react": "^18.2.0 || ^19.1.0",
"react-dom": "^18.2.0 || ^19.1.0",
@ -111,7 +113,9 @@
"jest-environment-jsdom": "^30.2.0",
"jotai": "^2.12.5",
"librechat-data-provider": "*",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"morphicons": "^1.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",

View file

@ -1,9 +1,10 @@
import { X, Plus } from 'lucide-react';
import { X, Plus } from 'lucide';
import { motion } from 'framer-motion';
import { JSX } from 'react/jsx-runtime';
import type { ButtonHTMLAttributes } from 'react';
import type { LucideIcon } from 'lucide-react';
import type React from 'react';
import { MorphIcon } from './MorphIcon';
import { cn } from '~/utils';
interface BadgeProps
@ -108,7 +109,7 @@ export default function Badge({
onBadgeAction?.();
}}
>
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
<MorphIcon icon={isAvailable ? X : Plus} className="h-3 w-3" />
</motion.button>
)}
</>

View file

@ -67,24 +67,18 @@ jest.mock('~/svgs', () => ({
),
}));
// Mock lucide-react icons
jest.mock('lucide-react', () => ({
ArrowUp: ({ className }: { className?: string }) => (
<span data-testid="arrow-up" className={className} />
),
ArrowDown: ({ className }: { className?: string }) => (
<span data-testid="arrow-down" className={className} />
),
ArrowDownUp: ({ className }: { className?: string }) => (
<span data-testid="arrow-down-up" className={className} />
),
Inbox: ({ className }: { className?: string }) => (
<span data-testid="inbox-icon" className={className} />
),
SearchX: ({ className }: { className?: string }) => (
<span data-testid="search-x-icon" className={className} />
),
}));
// Mock MorphIcon, mapping lucide icon data back to per-icon test ids
jest.mock('../MorphIcon', () => {
const { createMorphIconMock } = jest.requireActual('../../test/mockMorphIcon');
const { ArrowUp, ArrowDown, ArrowDownUp } = jest.requireActual('lucide');
return {
MorphIcon: createMorphIconMock([
[ArrowUp, 'arrow-up'],
[ArrowDown, 'arrow-down'],
[ArrowDownUp, 'arrow-down-up'],
]),
};
});
// Mock Table components
jest.mock('../Table', () => ({

View file

@ -1,7 +1,8 @@
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { JSX } from 'react/jsx-runtime';
import { Inbox, SearchX } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowUp, ArrowDown, ArrowDownUp, Inbox, SearchX } from 'lucide-react';
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide';
import {
useReactTable,
getCoreRowModel,
@ -18,6 +19,7 @@ import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '.
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
import { useMediaQuery, useLocalize } from '~/hooks';
import { DataTableSearch } from './DataTableSearch';
import { MorphIcon } from '../MorphIcon';
import { cn, logger } from '~/utils';
import { Button } from '../Button';
import { Label } from '../Label';
@ -655,14 +657,21 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
>
{renderedHeader}
<span aria-hidden="true">
{{
asc: <ArrowUp className="size-3.5" />,
desc: <ArrowDown className="size-3.5" />,
}[header.column.getIsSorted() as string] ?? (
/* The neutral marker is noise on every unsorted column, so it
only surfaces once the header is a pointer or keyboard target. */
<ArrowDownUp className="size-3.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
)}
<MorphIcon
icon={
{
asc: ArrowUp,
desc: ArrowDown,
}[header.column.getIsSorted() as string] ?? ArrowDownUp
}
className={cn(
'size-3.5',
/* The neutral marker is noise on every unsorted column, so it
only surfaces once the header is a pointer or keyboard target. */
!sortDirection &&
'opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100',
)}
/>
</span>
</Button>
);

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Download } from 'lucide';
import { render } from '@testing-library/react';
import { MorphIcon } from './MorphIcon';
/**
* Migrated call sites rely on MorphIcon hiding itself from assistive technology
* by default, the way the lucide icons it replaced were explicitly marked
* `aria-hidden`. Guard that contract so a library change cannot silently expose
* every decorative icon as a redundant graphic.
*/
describe('MorphIcon accessibility defaults', () => {
it('hides the icon from assistive technology when no label is given', () => {
const { container } = render(<MorphIcon icon={Download} size={16} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('aria-hidden', 'true');
expect(svg).not.toHaveAttribute('role');
});
it('exposes the icon as a titled image when a label is given', () => {
const { container } = render(<MorphIcon icon={Download} size={16} label="Download" />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('role', 'img');
expect(svg).not.toHaveAttribute('aria-hidden');
expect(container.querySelector('title')).toHaveTextContent('Download');
});
});

View file

@ -0,0 +1,2 @@
export { MorphIcon } from 'morphicons/react';
export type { IconInput, IconNode, MorphHandle, MorphIconProps } from 'morphicons/react';

View file

@ -0,0 +1,51 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SecretInput } from './SecretInput';
jest.mock('./MorphIcon', () => {
const { createMorphIconMock } = jest.requireActual('../test/mockMorphIcon');
const { Eye, EyeOff, Copy, Check } = jest.requireActual('lucide');
return {
MorphIcon: createMorphIconMock([
[Eye, 'eye'],
[EyeOff, 'eye-off'],
[Copy, 'copy'],
[Check, 'check'],
]),
};
});
describe('SecretInput', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});
it('shows eye icon when hidden and eye-off when visible', () => {
render(<SecretInput value="secret" readOnly aria-label="token" />);
const toggle = screen.getByRole('button', { name: 'Show secret' });
expect(toggle.querySelector('[data-icon="eye"]')).not.toBeNull();
fireEvent.click(toggle);
expect(
screen.getByRole('button', { name: 'Hide secret' }).querySelector('[data-icon="eye-off"]'),
).not.toBeNull();
});
it('morphs copy to check after a successful copy', async () => {
render(<SecretInput value="secret-value" showCopy readOnly aria-label="token" />);
const copyButton = screen.getByRole('button', { name: 'Copy to clipboard' });
expect(copyButton.querySelector('[data-icon="copy"]')).not.toBeNull();
fireEvent.click(copyButton);
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Copied' }).querySelector('[data-icon="check"]'),
).not.toBeNull();
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('secret-value');
});
});

View file

@ -1,6 +1,7 @@
import * as React from 'react';
import { useState, useCallback } from 'react';
import { Eye, EyeOff, Copy, Check } from 'lucide-react';
import { Eye, EyeOff, Copy, Check } from 'lucide';
import { MorphIcon } from './MorphIcon';
import { cn } from '~/utils';
export interface SecretInputProps
@ -119,7 +120,7 @@ const SecretInput: React.ForwardRefExoticComponent<
)}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
>
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
<MorphIcon icon={isCopied ? Check : Copy} className="size-4" />
</button>
)}
<button
@ -135,7 +136,7 @@ const SecretInput: React.ForwardRefExoticComponent<
)}
aria-label={isVisible ? 'Hide secret' : 'Show secret'}
>
{isVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
<MorphIcon icon={isVisible ? EyeOff : Eye} className="size-4" />
</button>
</div>
</div>

View file

@ -0,0 +1,64 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ThemeSelector from './ThemeSelector';
import { ThemeContext } from '../theme';
jest.mock('./MorphIcon', () => {
const { createMorphIconMock } = jest.requireActual('../test/mockMorphIcon');
const { Sun, Moon, Monitor } = jest.requireActual('lucide');
return {
MorphIcon: createMorphIconMock([
[Sun, 'sun'],
[Moon, 'moon'],
[Monitor, 'monitor'],
]),
};
});
function renderTheme(theme: string, setTheme = jest.fn()) {
return render(
<ThemeContext.Provider
value={{
theme,
setTheme,
setThemeRGB: jest.fn(),
setThemeName: jest.fn(),
resetTheme: jest.fn(),
}}
>
<ThemeSelector returnThemeOnly />
</ThemeContext.Provider>,
);
}
describe('ThemeSelector MorphIcon map', () => {
beforeEach(() => {
Object.defineProperty(window, 'lastThemeChange', {
writable: true,
configurable: true,
value: 0,
});
});
it('shows monitor for system, moon for dark, sun for light', () => {
const system = renderTheme('system');
expect(screen.getByTestId('monitor')).toBeInTheDocument();
expect(screen.getByTestId('monitor')).toHaveAttribute('data-size', '24');
system.unmount();
const dark = renderTheme('dark');
expect(screen.getByTestId('moon')).toBeInTheDocument();
dark.unmount();
renderTheme('light');
expect(screen.getByTestId('sun')).toBeInTheDocument();
});
it('toggles theme when the button is activated', () => {
const setTheme = jest.fn();
// isDark('light') is false → next is dark
renderTheme('light', setTheme);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_toggle_theme' }));
expect(setTheme).toHaveBeenCalledWith('dark');
});
});

View file

@ -1,7 +1,9 @@
import { useContext, useCallback, useEffect, useState } from 'react';
import { JSX } from 'react/jsx-runtime';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon, Monitor } from 'lucide';
import type { IconNode } from './MorphIcon';
import { ThemeContext, isDark } from '../theme';
import { MorphIcon } from './MorphIcon';
import { useLocalize } from '../hooks';
import { Button } from './Button';
@ -13,15 +15,15 @@ declare global {
type ThemeType = 'system' | 'dark' | 'light';
const themeIcons: Record<ThemeType, IconNode> = {
system: Monitor,
dark: Moon,
light: Sun,
};
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
const localize = useLocalize();
const themeIcons: Record<ThemeType, JSX.Element> = {
system: <Monitor aria-hidden="true" />,
dark: <Moon aria-hidden="true" />,
light: <Sun aria-hidden="true" />,
};
const nextTheme = isDark(theme) ? 'light' : 'dark';
useEffect(() => {
@ -47,7 +49,7 @@ const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) =
onChange(nextTheme);
}}
>
{themeIcons[theme as ThemeType]}
<MorphIcon icon={themeIcons[theme as ThemeType]} size={24} />
</Button>
);
};

View file

@ -16,6 +16,7 @@ export * from './SecretInput';
export * from './FilterInput';
export * from './FieldMessage';
export * from './Label';
export * from './MorphIcon';
export * from './OriginalDialog';
export * from './QuestionMark';
export * from './Slider';

View file

@ -0,0 +1,30 @@
import React from 'react';
export type MockMorphIconProps = {
icon?: unknown;
className?: string;
size?: number | string;
'data-testid'?: string;
};
/**
* Build a MorphIcon mock that maps lucide IconNode identity to a stable
* `data-icon` / optional `data-testid` so tests can assert icon selection.
*/
export function createMorphIconMock(
iconNames: Map<unknown, string> | Array<[unknown, string]>,
): React.FC<MockMorphIconProps> {
const names = iconNames instanceof Map ? iconNames : new Map(iconNames);
return function MockMorphIcon({ icon, className, size }: MockMorphIconProps) {
const name = names.get(icon) ?? 'morph-icon';
return (
<svg
data-testid={name}
data-icon={name}
data-size={size != null ? String(size) : undefined}
className={className}
/>
);
};
}