diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index be62064aaf..dbe37feda6 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -1,10 +1,10 @@ import { ShieldEllipsis } from 'lucide-react'; -import { Permissions, PermissionTypes } from 'librechat-data-provider'; import { Button, useToastContext } from '@librechat/client'; -import { AdminSettingsDialog } from '~/components/ui'; -import { useUpdateMarketplacePermissionsMutation } from '~/data-provider'; -import { useLocalize } from '~/hooks'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; import type { PermissionConfig } from '~/components/ui'; +import { useUpdateMarketplacePermissionsMutation } from '~/data-provider'; +import { AdminSettingsDialog } from '~/components/ui'; +import { useLocalize } from '~/hooks'; const permissions: PermissionConfig[] = [ { permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' }, @@ -50,7 +50,6 @@ const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) => menuId="marketplace-role-dropdown" mutation={mutation} trigger={trigger} - dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary" showAdminWarning={false} /> ); diff --git a/client/src/components/Bookmarks/BookmarkEditDialog.tsx b/client/src/components/Bookmarks/BookmarkEditDialog.tsx index 952a8784eb..05fc902698 100644 --- a/client/src/components/Bookmarks/BookmarkEditDialog.tsx +++ b/client/src/components/Bookmarks/BookmarkEditDialog.tsx @@ -95,7 +95,6 @@ const BookmarkEditDialog = ({ main={ ; - setOpen: React.Dispatch>; mutation: ReturnType; }; const BookmarkForm = ({ @@ -22,12 +21,10 @@ const BookmarkForm = ({ bookmark, mutation, conversationId, - setOpen, formRef, }: TBookmarkFormProps) => { const localize = useLocalize(); const queryClient = useQueryClient(); - const { showToast } = useToastContext(); const { bookmarks } = useBookmarkContext(); const { @@ -48,12 +45,27 @@ const BookmarkForm = ({ }, }); - useEffect(() => { - if (bookmark && bookmark.tag) { + const [prevBookmark, setPrevBookmark] = React.useState(bookmark); + + if (bookmark !== prevBookmark) { + setPrevBookmark(bookmark); + if (bookmark?.tag != null && bookmark.tag !== '') { setValue('tag', bookmark.tag); setValue('description', bookmark.description ?? ''); } - }, [bookmark, setValue]); + } + + /** Every source that could already hold the title, checked before the request is sent. */ + const isTagTaken = (value: string) => { + const allTags = + queryClient.getQueryData([QueryKeys.conversationTags]) ?? []; + + return ( + (tags ?? []).includes(value) || + allTags.some((tag) => tag.tag === value) || + bookmarks.some((existing) => existing.tag === value) + ); + }; const onSubmit = (data: TConversationTagRequest) => { logger.log('tag_mutation', 'BookmarkForm - onSubmit: data', data); @@ -63,25 +75,8 @@ const BookmarkForm = ({ if (data.tag === bookmark?.tag && data.description === bookmark?.description) { return; } - if (data.tag != null && (tags ?? []).includes(data.tag)) { - showToast({ - message: localize('com_ui_bookmarks_create_exists'), - status: 'warning', - }); - return; - } - const allTags = - queryClient.getQueryData([QueryKeys.conversationTags]) ?? []; - if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) { - showToast({ - message: localize('com_ui_bookmarks_create_exists'), - status: 'warning', - }); - return; - } mutation.mutate(data); - setOpen(false); }; return ( @@ -106,23 +101,18 @@ const BookmarkForm = ({ }), }, validate: (value) => { - return ( - value === bookmark?.tag || - bookmarks.every((bookmark) => bookmark.tag !== value) || - localize('com_ui_bookmarks_tag_exists') - ); + if (value == null || value === '' || value === bookmark?.tag) { + return true; + } + return !isTagTaken(value) || localize('com_ui_bookmarks_tag_exists'); }, })} className="w-full" aria-invalid={!!errors.tag} placeholder={localize('com_ui_enter_name')} - aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined} + aria-describedby="bookmark-tag-error" /> - {errors.tag && ( - - {errors.tag.message} - - )} + {/* Description textarea */} @@ -154,7 +144,10 @@ const BookmarkForm = ({ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy', )} aria-labelledby="bookmark-description-label" + aria-invalid={!!errors.description} + aria-describedby="bookmark-description-error" /> + {/* Add to conversation checkbox */} diff --git a/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx b/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx index bcc9788fe8..aef7903ccc 100644 --- a/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx +++ b/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx @@ -1,13 +1,12 @@ import React, { createRef } from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import BookmarkForm from '../BookmarkForm'; import type { TConversationTag } from 'librechat-data-provider'; +import BookmarkForm from '../BookmarkForm'; const mockMutate = jest.fn(); const mockShowToast = jest.fn(); const mockGetQueryData = jest.fn(); -const mockSetOpen = jest.fn(); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string, params?: Record) => { @@ -48,6 +47,20 @@ jest.mock('@librechat/client', () => { }), Label: ({ children, ...props }: { children: React.ReactNode }) => ActualReact.createElement('label', props, children), + FieldMessage: ({ + id, + message, + hint, + }: { + id: string; + message?: string | null; + hint?: string | null; + }) => + ActualReact.createElement( + 'p', + { id, role: message ? 'alert' : undefined }, + message || hint || '', + ), TextareaAutosize: ActualReact.forwardRef< HTMLTextAreaElement, React.TextareaHTMLAttributes @@ -136,7 +149,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -160,7 +172,6 @@ describe('BookmarkForm - Bookmark Editing', () => { ); }); expect(mockShowToast).not.toHaveBeenCalled(); - expect(mockSetOpen).toHaveBeenCalledWith(false); }); it('should not submit when both tag and description are unchanged', async () => { @@ -179,7 +190,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -191,12 +201,11 @@ describe('BookmarkForm - Bookmark Editing', () => { await waitFor(() => { expect(mockMutate).not.toHaveBeenCalled(); }); - expect(mockSetOpen).not.toHaveBeenCalled(); }); }); - describe('Renaming a tag to an existing tag (should show error)', () => { - it('should show error toast when renaming to an existing tag name (via allTags)', async () => { + describe('Renaming a tag to an existing tag (should show inline error)', () => { + it('should show an inline error when renaming to an existing tag name (via allTags)', async () => { const existingBookmark = createMockBookmark({ tag: 'Original Tag', description: 'Description', @@ -218,7 +227,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -234,16 +242,15 @@ describe('BookmarkForm - Bookmark Editing', () => { }); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith({ - message: 'This bookmark already exists', - status: 'warning', - }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A bookmark with this title already exists', + ); }); + expect(mockShowToast).not.toHaveBeenCalled(); expect(mockMutate).not.toHaveBeenCalled(); - expect(mockSetOpen).not.toHaveBeenCalled(); }); - it('should show error toast when renaming to an existing tag name (via tags prop)', async () => { + it('should show an inline error when renaming to an existing tag name (via tags prop)', async () => { const existingBookmark = createMockBookmark({ tag: 'Original Tag', description: 'Description', @@ -260,7 +267,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -276,13 +282,12 @@ describe('BookmarkForm - Bookmark Editing', () => { }); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith({ - message: 'This bookmark already exists', - status: 'warning', - }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A bookmark with this title already exists', + ); }); + expect(mockShowToast).not.toHaveBeenCalled(); expect(mockMutate).not.toHaveBeenCalled(); - expect(mockSetOpen).not.toHaveBeenCalled(); }); }); @@ -303,7 +308,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -327,7 +331,6 @@ describe('BookmarkForm - Bookmark Editing', () => { ); }); expect(mockShowToast).not.toHaveBeenCalled(); - expect(mockSetOpen).toHaveBeenCalledWith(false); }); it('should allow keeping the same tag name when editing (not trigger duplicate error)', async () => { @@ -346,7 +349,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -396,7 +398,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -412,11 +413,11 @@ describe('BookmarkForm - Bookmark Editing', () => { }); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith({ - message: 'This bookmark already exists', - status: 'warning', - }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A bookmark with this title already exists', + ); }); + expect(mockShowToast).not.toHaveBeenCalled(); expect(mockMutate).not.toHaveBeenCalled(); }); @@ -436,7 +437,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); @@ -472,7 +472,6 @@ describe('BookmarkForm - Bookmark Editing', () => { typeof import('~/data-provider').useConversationTagMutation > } - setOpen={mockSetOpen} formRef={formRef} />, ); diff --git a/client/src/components/Chat/Input/DuringRunSendButton.tsx b/client/src/components/Chat/Input/DuringRunSendButton.tsx index 00c42b4902..73fc9845a1 100644 --- a/client/src/components/Chat/Input/DuringRunSendButton.tsx +++ b/client/src/components/Chat/Input/DuringRunSendButton.tsx @@ -57,7 +57,7 @@ const DuringRunSendButton = React.memo( const localize = useLocalize(); const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault); const enterToSend = useRecoilValue(store.enterToSend); - const { submitOverride, yieldedChords } = useComposerBindings(); + const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings(); const { steering } = props; const data = useWatch({ control: props.control }); const content = data?.text?.trim(); @@ -80,6 +80,7 @@ const DuringRunSendButton = React.memo( isSubmitting: true, allowSubmitWhileGenerating: true, hasDuringRunModifier: true, + shortcutsEnabled, enterToSend, submitOverride, yieldedChords, @@ -96,7 +97,7 @@ const DuringRunSendButton = React.memo( modShiftEnter: chord({ ...mod, shiftKey: true }), altEnter: chord({ altKey: true }), }; - }, [enterToSend, submitOverride, yieldedChords]); + }, [enterToSend, shortcutsEnabled, submitOverride, yieldedChords]); /** * With the preference on, plain Enter routes through `submitDuringRun`, diff --git a/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx b/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx index 6bebb0c8bc..b7050cb87d 100644 --- a/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx +++ b/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx @@ -67,16 +67,24 @@ function Harness({ steering }: { steering: SteeringControls }) { type MenuOptions = StubOptions & { enterInterrupts?: boolean; enterToSend?: boolean; + shortcutsEnabled?: boolean; customShortcuts?: Record; }; function openMenu(options: MenuOptions = {}) { - const { enterInterrupts = false, enterToSend = true, customShortcuts = {}, ...stub } = options; + const { + enterInterrupts = false, + enterToSend = true, + shortcutsEnabled = true, + customShortcuts = {}, + ...stub + } = options; render( { set(store.steerInterruptsByDefault, enterInterrupts); set(store.enterToSend, enterToSend); + set(store.shortcutsEnabled, shortcutsEnabled); set(store.customShortcuts, customShortcuts); }} > @@ -257,4 +265,12 @@ describe('DuringRunSendButton — hints follow the effective bindings', () => { expect(kbdFor('com_ui_interrupt_steer')).toBe('Ctrl ⇧ ⏎'); expect(kbdFor('com_ui_interrupt_send')).toBe('Alt ⏎'); }); + + test('keeps plain Enter but hides shortcut hints when shortcuts are disabled', () => { + openMenu({ canSteer: true, shortcutsEnabled: false }); + expect(kbdFor('com_ui_steer')).toBe('⏎'); + expect(kbdFor('com_ui_queue')).toBeNull(); + expect(kbdFor('com_ui_interrupt_steer')).toBeNull(); + expect(kbdFor('com_ui_interrupt_send')).toBeNull(); + }); }); diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index d5c3765261..bb17bdcefa 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -6,7 +6,6 @@ import { Archive, ChevronRight, CircleHelp, - FileText, Keyboard, LifeBuoy, LogOut, @@ -14,7 +13,6 @@ import { ShieldCheck, } from 'lucide-react'; import { ArchivedChatsModal } from '~/components/Nav/SettingsTabs/General/ArchivedChatsModal'; -import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal'; import { useGetStartupConfig, useGetUserBalance } from '~/data-provider'; import { useAuthContext } from '~/hooks/AuthContext'; import { useLocalize } from '~/hooks'; @@ -100,7 +98,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { enabled: !!isAuthenticated && startupConfig?.balance?.enabled, }); const [showSettings, setShowSettings] = useState(false); - const [showFiles, setShowFiles] = useState(false); const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog); const [showArchived, setShowArchived] = useState(false); const accountSettingsButtonRef = useRef(null); @@ -160,10 +157,6 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) { privacyPolicyURL={startupConfig?.interface?.privacyPolicy?.externalUrl} onShowShortcuts={() => setShowShortcutsDialog(true)} /> - setShowFiles(true)} className="select-item text-sm"> - setShowArchived(true)} className="select-item text-sm"> - {showFiles && ( - - )} {showArchived && ( void; onStopEdit: () => void; bindingMap: Map; @@ -105,7 +114,7 @@ function ShortcutRow({ const editAriaLabel = localize('com_shortcut_edit_aria', { 0: label }); const isUnset = displayKeys.length === 0; - if (isEditing) { + if (isEditing && !disabled) { return (
+
{label} @@ -134,6 +148,7 @@ function ShortcutRow({ {info.isCustom && ( + {open && } +
+ ); +}; diff --git a/client/src/components/Prompts/sidebar/GroupSidePanel.tsx b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx index 5ab594a8fc..ee06940978 100644 --- a/client/src/components/Prompts/sidebar/GroupSidePanel.tsx +++ b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx @@ -72,12 +72,12 @@ export default function GroupSidePanel({ )}
{/* Sticky header: filter and toggles stay put while the list scrolls */} -
{children}
+
{children}
} - className="scrollbar-gutter-stable flex flex-col gap-2 overflow-x-hidden pl-3 pr-1 text-text-primary" + className="scrollbar-gutter-stable flex flex-col gap-2 overflow-x-hidden pb-3 pl-3 pr-1 text-text-primary" > {/* Appending the next page, so the loaded rows stay put */} diff --git a/client/src/components/Sharing/PeoplePickerAdminSettings.tsx b/client/src/components/Sharing/PeoplePickerAdminSettings.tsx index b5a499bf61..704b5b4325 100644 --- a/client/src/components/Sharing/PeoplePickerAdminSettings.tsx +++ b/client/src/components/Sharing/PeoplePickerAdminSettings.tsx @@ -1,233 +1,53 @@ -import { useEffect, useId, useState } from 'react'; -import * as Ariakit from '@ariakit/react'; -import { useForm, Controller } from 'react-hook-form'; -import { ChevronDown, ShieldEllipsis } from 'lucide-react'; -import { Permissions, SystemRoles, PermissionTypes } from 'librechat-data-provider'; -import { - Label, - Button, - Switch, - OGDialog, - DropdownPopup, - OGDialogHeader, - OGDialogFooter, - OGDialogTitle, - OGDialogDescription, - OGDialogContent, - OGDialogTrigger, - useToastContext, -} from '@librechat/client'; -import type { Control } from 'react-hook-form'; +import { ShieldEllipsis } from 'lucide-react'; +import { Button, useToastContext } from '@librechat/client'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import type { PermissionConfig } from '~/components/ui'; import { useUpdatePeoplePickerPermissionsMutation } from '~/data-provider'; -import { useLocalize, useAuthContext, useRoleSelector } from '~/hooks'; +import { AdminSettingsDialog } from '~/components/ui'; +import { useLocalize } from '~/hooks'; -type FormValues = { - [Permissions.VIEW_USERS]: boolean; - [Permissions.VIEW_GROUPS]: boolean; - [Permissions.VIEW_ROLES]: boolean; -}; - -type LabelControllerProps = { - label: string; - peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES; - control: Control; -}; - -const LabelController: React.FC = ({ control, peoplePickerPerm, label }) => ( -
- - ( - - )} - /> -
-); +const permissions: PermissionConfig[] = [ + { permission: Permissions.VIEW_USERS, labelKey: 'com_ui_people_picker_allow_view_users' }, + { permission: Permissions.VIEW_GROUPS, labelKey: 'com_ui_people_picker_allow_view_groups' }, + { permission: Permissions.VIEW_ROLES, labelKey: 'com_ui_people_picker_allow_view_roles' }, +]; const PeoplePickerAdminSettings = () => { const localize = useLocalize(); const { showToast } = useToastContext(); - const { user } = useAuthContext(); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false); - const roleLabelId = useId(); - const roleValueId = useId(); - const { - selectedRole, - isSelectedCustomRole, - isCustomRoleLoading, - isCustomRoleError, - defaultValues, - roleDropdownItems, - } = useRoleSelector(PermissionTypes.PEOPLE_PICKER); - const { - reset, - control, - handleSubmit, - formState: { isSubmitting }, - } = useForm({ - mode: 'onChange', - defaultValues: defaultValues as FormValues, - }); - - useEffect(() => { - if (isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError)) { - return; - } - reset(defaultValues as FormValues); - }, [isSelectedCustomRole, isCustomRoleLoading, isCustomRoleError, defaultValues, reset]); - - const handleDialogOpenChange = (open: boolean) => { - if (!open) { - setIsRoleMenuOpen(false); - reset(defaultValues as FormValues); - } - setIsDialogOpen(open); - }; - - const { mutate, isLoading } = useUpdatePeoplePickerPermissionsMutation({ + const mutation = useUpdatePeoplePickerPermissionsMutation({ onSuccess: () => { showToast({ status: 'success', message: localize('com_ui_saved') }); - handleDialogOpenChange(false); }, onError: () => { showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }); }, }); - if (user?.role !== SystemRoles.ADMIN) { - return null; - } - - const labelControllerData: { - peoplePickerPerm: Permissions.VIEW_USERS | Permissions.VIEW_GROUPS | Permissions.VIEW_ROLES; - label: string; - }[] = [ - { - peoplePickerPerm: Permissions.VIEW_USERS, - label: localize('com_ui_people_picker_allow_view_users'), - }, - { - peoplePickerPerm: Permissions.VIEW_GROUPS, - label: localize('com_ui_people_picker_allow_view_groups'), - }, - { - peoplePickerPerm: Permissions.VIEW_ROLES, - label: localize('com_ui_people_picker_allow_view_roles'), - }, - ]; - - const onSubmit = (data: FormValues) => { - mutate({ roleName: selectedRole, updates: data }); - }; + const trigger = ( + + ); return ( - - - - - - -
-
-
-
- - {localize('com_ui_admin_settings_section', { - section: localize('com_ui_people_picker'), - })} - - - {localize('com_ui_people_picker_admin_description')} - -
-
-
- -
-
-
- - {localize('com_ui_role_select')} - - - - {selectedRole} - -
- -
- {labelControllerData.map(({ peoplePickerPerm, label }) => ( - - ))} -
-
- - - - -
-
-
+ ); }; diff --git a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx index ebdb3e7133..773201a18e 100644 --- a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx @@ -4,13 +4,15 @@ import { OGDialog, OGDialogTemplate, Button, + FieldMessage, Label, Input, Spinner, Textarea, useToastContext, } from '@librechat/client'; -import { useCreateMemoryMutation } from '~/data-provider'; +import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory'; +import { useCreateMemoryMutation, useMemoriesQuery } from '~/data-provider'; import { useLocalize, useHasAccess } from '~/hooks'; interface MemoryCreateDialogProps { @@ -34,6 +36,8 @@ export default function MemoryCreateDialog({ permission: Permissions.CREATE, }); + const { data: memData } = useMemoriesQuery(); + const { mutate: createMemory, isLoading } = useCreateMemoryMutation({ onSuccess: () => { showToast({ @@ -43,33 +47,14 @@ export default function MemoryCreateDialog({ onOpenChange(false); setKey(''); setValue(''); + setTouched({ key: false, value: false }); setTimeout(() => { triggerRef?.current?.focus(); }, 0); }, onError: (error: Error) => { - let errorMessage = localize('com_ui_error'); - - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as any; - if (axiosError.response?.data?.error) { - errorMessage = axiosError.response.data.error; - - // Check for duplicate key error - if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { - errorMessage = localize('com_ui_memory_key_exists'); - } - // Check for key validation error (lowercase and underscores only) - else if (errorMessage.includes('lowercase letters and underscores')) { - errorMessage = localize('com_ui_memory_key_validation'); - } - } - } else if (error.message) { - errorMessage = error.message; - } - showToast({ - message: errorMessage, + message: getMemoryApiErrorMessage(error, localize('com_ui_error')), status: 'error', }); }, @@ -77,17 +62,32 @@ export default function MemoryCreateDialog({ const [key, setKey] = useState(''); const [value, setValue] = useState(''); + const [touched, setTouched] = useState({ key: false, value: false }); + const [prevOpen, setPrevOpen] = useState(open); + + if (open !== prevOpen) { + setPrevOpen(open); + if (!open) { + setKey(''); + setValue(''); + setTouched({ key: false, value: false }); + } + } + + const keyError = getMemoryKeyError({ key, memories: memData?.memories }); + const valueError = getMemoryValueError(value); + const hasErrors = keyError != null || valueError != null; + /** Stay quiet on a pristine empty field; validate live once there is something to judge. */ + const showKeyError = touched.key || key !== ''; + const showValueError = touched.value || value !== ''; const handleSave = () => { if (!hasCreateAccess) { return; } - if (!key.trim() || !value.trim()) { - showToast({ - message: localize('com_ui_field_required'), - status: 'error', - }); + if (keyError || valueError) { + setTouched({ key: true, value: true }); return; } @@ -120,11 +120,19 @@ export default function MemoryCreateDialog({ id="memory-key" value={key} onChange={(e) => setKey(e.target.value)} + onBlur={() => setTouched((prev) => ({ ...prev, key: true }))} onKeyDown={handleKeyPress} placeholder={localize('com_ui_enter_key')} className="w-full" + aria-invalid={showKeyError && keyError != null} + aria-describedby="memory-key-message" + /> + -

{localize('com_ui_memory_key_hint')}

@@ -147,7 +162,7 @@ export default function MemoryCreateDialog({ type="button" variant="submit" onClick={handleSave} - disabled={isLoading || !key.trim() || !value.trim()} + disabled={isLoading || hasErrors} aria-label={localize('com_ui_create_memory')} > {isLoading ? : localize('com_ui_create')} diff --git a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx index f794ff7bd6..e748a23367 100644 --- a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { OGDialog, OGDialogTemplate, Button, + FieldMessage, Label, Input, Spinner, @@ -11,6 +12,7 @@ import { useToastContext, } from '@librechat/client'; import type { TUserMemory } from 'librechat-data-provider'; +import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory'; import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider'; import { useLocalize, useHasAccess } from '~/hooks'; import MemoryUsageBadge from './MemoryUsageBadge'; @@ -50,41 +52,19 @@ export default function MemoryEditDialog({ }); const { mutate: updateMemory, isLoading } = useUpdateMemoryMutation({ - onMutate: () => { - onOpenChange(false); - setTimeout(() => { - triggerRef?.current?.focus(); - }, 0); - }, onSuccess: () => { showToast({ message: localize('com_ui_saved'), status: 'success', }); + onOpenChange(false); + setTimeout(() => { + triggerRef?.current?.focus(); + }, 0); }, onError: (error: Error) => { - let errorMessage = localize('com_ui_error'); - - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as any; - if (axiosError.response?.data?.error) { - errorMessage = axiosError.response.data.error; - - // Check for duplicate key error - if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { - errorMessage = localize('com_ui_memory_key_exists'); - } - // Check for key validation error (lowercase and underscores only) - else if (errorMessage.includes('lowercase letters and underscores')) { - errorMessage = localize('com_ui_memory_key_validation'); - } - } - } else if (error.message) { - errorMessage = error.message; - } - showToast({ - message: errorMessage, + message: getMemoryApiErrorMessage(error, localize('com_ui_error')), status: 'error', }); }, @@ -93,25 +73,38 @@ export default function MemoryEditDialog({ const [key, setKey] = useState(''); const [value, setValue] = useState(''); const [originalKey, setOriginalKey] = useState(''); + const [touched, setTouched] = useState({ key: false, value: false }); + const [prevMemory, setPrevMemory] = useState(null); - useEffect(() => { + if (memory !== prevMemory) { + setPrevMemory(memory); if (memory) { setKey(memory.key); setValue(memory.value); setOriginalKey(memory.key); + setTouched({ key: false, value: false }); } - }, [memory]); + } + + const keyError = getMemoryKeyError({ + key, + memories: memData?.memories, + agentId: memory?.agentId, + originalKey, + }); + const valueError = getMemoryValueError(value); + const hasErrors = keyError != null || valueError != null; + /** Stay quiet on a pristine empty field; validate live once there is something to judge. */ + const showKeyError = hasUpdateAccess && (touched.key || key !== ''); + const showValueError = hasUpdateAccess && (touched.value || value !== ''); const handleSave = () => { if (!hasUpdateAccess || !memory) { return; } - if (!key.trim() || !value.trim()) { - showToast({ - message: localize('com_ui_field_required'), - status: 'error', - }); + if (keyError || valueError) { + setTouched({ key: true, value: true }); return; } @@ -189,10 +182,19 @@ export default function MemoryEditDialog({ id="memory-key" value={key} onChange={(e) => hasUpdateAccess && setKey(e.target.value)} + onBlur={() => setTouched((prev) => ({ ...prev, key: true }))} onKeyDown={handleKeyPress} placeholder={localize('com_ui_enter_key')} className="w-full" disabled={!hasUpdateAccess} + aria-invalid={showKeyError && keyError != null} + aria-describedby="memory-key-message" + /> + @@ -205,11 +207,18 @@ export default function MemoryEditDialog({ id="memory-value" value={value} onChange={(e) => hasUpdateAccess && setValue(e.target.value)} + onBlur={() => setTouched((prev) => ({ ...prev, value: true }))} onKeyDown={handleKeyPress} placeholder={localize('com_ui_enter_value')} className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy disabled:cursor-not-allowed disabled:opacity-50" rows={4} disabled={!hasUpdateAccess} + aria-invalid={showValueError && valueError != null} + aria-describedby="memory-value-message" + /> + @@ -221,7 +230,7 @@ export default function MemoryEditDialog({ variant="submit" onClick={handleSave} aria-label={localize('com_ui_save')} - disabled={isLoading || !key.trim() || !value.trim()} + disabled={isLoading || hasErrors} > {isLoading ? : localize('com_ui_save')} diff --git a/client/src/components/Skills/buttons/SkillToggle.tsx b/client/src/components/Skills/buttons/SkillToggle.tsx index 7f43852517..839928653b 100644 --- a/client/src/components/Skills/buttons/SkillToggle.tsx +++ b/client/src/components/Skills/buttons/SkillToggle.tsx @@ -1,20 +1,47 @@ -import { memo } from 'react'; -import { Switch } from '@librechat/client'; +import { memo, useId } from 'react'; +import { Label, Switch, TooltipAnchor } from '@librechat/client'; +import { useLocalize } from '~/hooks'; interface SkillToggleProps { enabled: boolean; onChange: () => void; - ariaLabel: string; } -function SkillToggle({ enabled, onChange, ariaLabel }: SkillToggleProps) { +/** + * Controls whether the skill is injected into the agent's catalog for the + * current user. The label stays fixed while the switch carries the state, so + * flipping it cannot resize the surrounding action row. + */ +function SkillToggle({ enabled, onChange }: SkillToggleProps) { + const localize = useLocalize(); + const switchId = useId(); + const labelId = useId(); + return ( - e.stopPropagation()} - className="inline-flex h-9 items-center justify-center rounded-md px-1 transition-colors hover:bg-surface-hover" - > - onChange()} aria-label={ariaLabel} /> - + e.stopPropagation()} + className="inline-flex h-9 items-center gap-2 rounded-md px-2 transition-colors hover:bg-surface-hover" + > + onChange()} + aria-labelledby={labelId} + /> + + + } + /> ); } diff --git a/client/src/components/Skills/display/SkillDetail.tsx b/client/src/components/Skills/display/SkillDetail.tsx index b76da15ce8..e2ec1fa0b3 100644 --- a/client/src/components/Skills/display/SkillDetail.tsx +++ b/client/src/components/Skills/display/SkillDetail.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo } from 'react'; import { format } from 'date-fns'; import { Button, TooltipAnchor } from '@librechat/client'; -import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react'; +import { Eye, Code, User, Pencil, Calendar, EarthIcon } from 'lucide-react'; import type { TSkill } from 'librechat-data-provider'; import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks'; import SkillMarkdownRenderer from './SkillMarkdownRenderer'; @@ -90,63 +90,59 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp aria-label={skill.name} > {/* Header row */} -
-
-
-
-
-
-
-

- {skill.name} -

- {isPublic && ( - - } +
+
+
+

+ {skill.name} +

+ {isPublic && ( + - )} -
-
- - - {updatedDate && ( - - - )} -
-
+ } + /> + )} +
+
+ + + {updatedDate && ( + + + )}
{/* Actions */} -
- toggle(skill)} - ariaLabel={localize('com_ui_skill_toggle_active')} - /> +
+ toggle(skill)} /> {permissions.canEdit && onEdit && ( - + +