mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 10:36:09 +00:00
✨ 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:
parent
2fe872d2ea
commit
bd31ecd129
51 changed files with 667 additions and 291 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
82
client/test/mockMorphIcon.tsx
Normal file
82
client/test/mockMorphIcon.tsx
Normal 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
54
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
29
packages/client/src/components/MorphIcon.spec.tsx
Normal file
29
packages/client/src/components/MorphIcon.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
2
packages/client/src/components/MorphIcon.ts
Normal file
2
packages/client/src/components/MorphIcon.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { MorphIcon } from 'morphicons/react';
|
||||
export type { IconInput, IconNode, MorphHandle, MorphIconProps } from 'morphicons/react';
|
||||
51
packages/client/src/components/SecretInput.spec.tsx
Normal file
51
packages/client/src/components/SecretInput.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
64
packages/client/src/components/ThemeSelector.spec.tsx
Normal file
64
packages/client/src/components/ThemeSelector.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
30
packages/client/src/test/mockMorphIcon.tsx
Normal file
30
packages/client/src/test/mockMorphIcon.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue