Merge branch 'main' into style/clean-copied-text

This commit is contained in:
Romuald Wandji 2025-11-26 10:05:40 +01:00 committed by GitHub
commit 1336b13639
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 146 additions and 89 deletions

View file

@ -1213,8 +1213,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
},
getStrategyFunctions,
@ -1231,8 +1231,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);
@ -1246,8 +1246,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider,
endpoint: this.options.agent?.endpoint,
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);

View file

@ -11,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { AnimatePresence, motion } from 'framer-motion';
const defaultInterface = getConfigDefaults().interface;
@ -38,24 +39,24 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2">
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${
!navVisible
? 'translate-x-0 opacity-100'
: 'pointer-events-none translate-x-[-100px] opacity-0'
}`}
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</div>
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
>
<div className="mx-1 flex items-center">
<AnimatePresence initial={false}>
{!navVisible && (
<motion.div
className={`flex items-center gap-2`}
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</motion.div>
)}
</AnimatePresence>
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
<ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}

View file

@ -260,37 +260,50 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<FileFormChat conversation={conversation} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
<div className="relative flex-1">
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
{isCollapsed && (
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200"
style={{
backdropFilter: 'blur(2px)',
WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
maskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
}}
/>
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
</div>
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows}

View file

@ -2,7 +2,7 @@ import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Menu from '@ariakit/react/menu';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import type { MouseEvent } from 'react';
import {
useDuplicateConversationMutation,
@ -151,7 +151,7 @@ function ConvoOptions({
icon: isDuplicateLoading ? (
<Spinner className="size-4" />
) : (
<Copy className="icon-sm mr-2 text-text-primary" />
<CopyPlus className="icon-sm mr-2 text-text-primary" />
),
},
{

View file

@ -55,6 +55,11 @@ export function DeleteConversationDialog({
}
setMenuOpen?.(false);
retainView();
showToast({
message: localize('com_ui_convo_delete_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: () => {
showToast({

View file

@ -9,8 +9,10 @@ import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function MobileNav({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: Dispatch<SetStateAction<boolean>>;
}) {
const localize = useLocalize();
@ -25,7 +27,7 @@ export default function MobileNav({
type="button"
data-testid="mobile-header-new-chat-button"
aria-label={localize('com_nav_open_sidebar')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className={`m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover ${navVisible ? 'invisible' : ''}`}
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { AnimatePresence, motion } from 'framer-motion';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
@ -190,22 +191,21 @@ const Nav = memo(
return (
<>
<div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-200 ease-in-out',
'md:max-w-[260px]',
)}
style={{
width: navVisible ? navWidth : '0px',
transform: navVisible ? 'translateX(0)' : 'translateX(-100%)',
}}
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div
className={`flex h-full flex-col transition-opacity duration-200 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
>
<AnimatePresence initial={false}>
{navVisible && (
<motion.div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
initial={{ width: 0 }}
animate={{ width: navWidth }}
exit={{ width: 0 }}
transition={{ duration: 0.2 }}
key="nav"
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
@ -235,9 +235,9 @@ const Nav = memo(
</nav>
</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
);

View file

@ -93,6 +93,11 @@ export default function ArchivedChatsTable({
onSuccess: async () => {
setIsDeleteOpen(false);
await refetch();
showToast({
message: localize('com_ui_convo_delete_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: (error: unknown) => {
showToast({

View file

@ -1,4 +1,4 @@
import { CopyIcon } from 'lucide-react';
import { CopyPlus } from 'lucide-react';
import { useToastContext, Button } from '@librechat/client';
import { useDuplicateAgentMutation } from '~/data-provider';
import { isEphemeralAgent } from '~/common';
@ -41,7 +41,7 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
onClick={handleDuplicate}
>
<div className="flex w-full items-center justify-center gap-2 text-primary">
<CopyIcon className="size-4" />
<CopyPlus className="size-4" />
</div>
</Button>
);

View file

@ -147,11 +147,13 @@ const SidePanelGroup = memo(
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<button
aria-label="Close right side panel"
className={`nav-mask ${!isCollapsed ? 'active' : ''}`}
onClick={handleClosePanel}
/>
)}
</>
);
},

View file

@ -799,6 +799,7 @@
"com_ui_continue_oauth": "Continue with OAuth",
"com_ui_controls": "Controls",
"com_ui_convo_delete_error": "Failed to delete conversation",
"com_ui_convo_delete_success": "Conversation successfully deleted",
"com_ui_copied": "Copied!",
"com_ui_copied_to_clipboard": "Copied to clipboard",
"com_ui_copy_code": "Copy code",

View file

@ -75,7 +75,7 @@ export default function Root() {
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<MobileNav setNavVisible={setNavVisible} />
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>
</div>

View file

@ -1487,6 +1487,26 @@ button {
background-color: transparent;
}
/* Show scrollbar only on hover */
.scrollbar-hover {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-hover:hover {
scrollbar-color: var(--border-medium) transparent;
}
.scrollbar-hover::-webkit-scrollbar-thumb {
background-color: transparent;
transition: background-color 0.3s ease 0.5s;
}
.scrollbar-hover:hover::-webkit-scrollbar-thumb {
background-color: var(--border-medium);
transition-delay: 0s;
}
body,
html {
height: 100%;

View file

@ -50,8 +50,9 @@ import { parseTextNative, parseText } from './text';
import fs, { ReadStream } from 'fs';
import axios from 'axios';
import FormData from 'form-data';
import { generateShortLivedToken } from '../crypto/jwt';
import { readFileAsString } from '../utils';
import type { ServerRequest } from '~/types';
import { generateShortLivedToken } from '~/crypto/jwt';
import { readFileAsString } from '~/utils';
const mockedFs = fs as jest.Mocked<typeof fs>;
const mockedAxios = axios as jest.Mocked<typeof axios>;
@ -77,7 +78,7 @@ describe('text', () => {
const mockReq = {
user: { id: 'user123' },
};
} as ServerRequest;
const mockFileId = 'file123';
@ -228,6 +229,13 @@ describe('text', () => {
file_id: mockFileId,
});
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://rag-api.test/text',
expect.any(Object),
expect.objectContaining({
timeout: 300000,
}),
);
expect(result).toEqual({
text: '',
bytes: 0,
@ -278,7 +286,7 @@ describe('text', () => {
});
const result = await parseText({
req: { user: undefined },
req: { user: undefined } as ServerRequest,
file: mockFile,
file_id: mockFileId,
});

View file

@ -65,7 +65,7 @@ export async function parseText({
accept: 'application/json',
...formHeaders,
},
timeout: 30000,
timeout: 300000,
});
const responseData = response.data;