From d79d1ff76a936c8a1cffd466c0c47c4a9519409d Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:10:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat:=20Consistent=20Di?= =?UTF-8?q?alogs,=20Clearer=20Settings,=20and=20a=20Keyboard=20Shortcuts?= =?UTF-8?q?=20Switch=20(#14882)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(AdminSettings): consolidate every admin dialog on one implementation The People Picker admin dialog was a standalone reimplementation of the shared AdminSettingsDialog with a better layout, leaving two components to keep in sync by hand. Port that layout into the shared component and rewrite People Picker to configure it, so all eight admin dialogs render from one place. The shared dialog gains the icon-tile header, the role selector and permission switches as bordered cards, and a real footer. Its header row was also top-aligning the 40px icon tile against a single-line title, leaving the icon hanging 6px low; it now centers. Both behaviors the standalone version lacked are preserved: the admin access warning and the confirm-before-disable flow used by Prompts. Adds an optional descriptionKey for a screen-reader description, and closes the dialog when the mutation reports success, which is how People Picker kept its auto-close. Permission switch ids are prefixed with useId so two mounted dialogs cannot collide. Marketplace dropped its dialogContentClassName override because the max-w-md and background it set conflicted with the new padding. * feat(ui): add FieldMessage for helper text that never shifts layout Form fields across the app render their validation error conditionally, so the error appearing pushes every field below it down. FieldMessage always occupies one line and only swaps its content and color between a resting hint, an error, and nothing, so the surrounding layout is fixed by construction rather than by whichever message happens to be showing. * fix(Memories): validate the key and value on the client Creating or updating a memory only learned that its key was malformed or already taken after the request came back, and the failure arrived as a toast. getMemoryKeyError mirrors the schema validator in data-schemas and checks for a duplicate within the same agent partition, so both dialogs resolve the outcome before sending anything. Errors render inline under the field instead of as a toast, live from the first character typed, and Create and Save stay disabled while any error stands. A pristine empty field shows only its hint, so the form does not report a problem before there is one. The edit dialog also closed itself in onMutate, discarding the user's edits whenever the server rejected the write and leaving the error toast to land on a dialog that was already gone. It now closes on success. This drops six toasts to two: the field-required and duplicate-key toasts are covered inline, leaving one generic toast per dialog for unexpected server failures alongside the existing success toast. * fix(Bookmarks): consolidate title validation and show it inline The duplicate-title check ran from three sources against two different strings: an inline validator reading the bookmark context, plus two warning toasts in onSubmit reading the tags prop and the conversationTags cache. All three now feed one helper behind the single inline validator, so both toasts are gone and the message is always com_ui_bookmarks_tag_exists. The title error was rendered conditionally, so it pushed the description field down as it appeared and disappeared; it now uses FieldMessage. The description registered a maxLength rule but rendered its error nowhere, so exceeding 1048 characters silently refused to submit with nothing on screen. It now reports like the title does. Submitting also closed the dialog immediately, throwing away what the user typed if the request failed, even though the mutation's onSuccess already closed it. Dropping that leaves the form with no reason to take setOpen. Renaming is no longer blocked when the title is unchanged: the tags-prop check had no exemption for the bookmark's own title, so editing just the description of a bookmark attached to the current conversation reported a duplicate and refused to save. The two tests that asserted the removed toasts now assert the inline error and that no toast fires. * feat(Skills): label the availability toggle and drop the detail icon The toggle in the skill detail header was a bare switch whose only name was an aria-label reading "Toggle skill active state", so nothing on screen said what it did, and "active" did not say active for what. It now carries a visible "Available to agent" label bound to the switch, plus a tooltip stating the effect: when on, the agent can use this skill in new messages. The label text stays fixed while the switch carries the state, so flipping it cannot resize the action row and nudge the buttons beside it. Also removes the decorative ScrollText circle from the detail header. It conveyed nothing the heading did not already say, and dropping it lets the title block sit at the top level instead of nested inside a flex row that now has a single child. * feat(Settings): move file management into Data Controls and clarify its labels Files were reachable only from the account dropdown, away from the other data-management entries. A Manage files row now sits in Data Controls beside Import conversations and Shared links, opening the same modal, and the account menu item is gone so there is one place to look. Two labels renamed for accuracy and consistency: - "Revoke all user provided credentials" becomes "Revoke all provider API keys". It sits in the API keys section beside Provider API keys and Agent API keys, so it should name what it revokes; "credentials" was vague and "user provided" described the system's perspective rather than the user's. - "Clear all chats" becomes "Delete all chats", matching its own Delete button and the "Delete TTS cache storage" row beside it. The action is irreversible, which delete states more plainly than clear. The TTS cache row gains an InfoHoverCard, the same explanation affordance used by the API keys dialog. Nothing previously said what the cache held or why its button is so often greyed out, which happens whenever the cache is empty, including for anyone who has never used text-to-speech. * feat(Shortcuts): add a switch that disables every keyboard shortcut There was no way to turn shortcuts off short of rebinding each one to nothing, which loses the bindings. A switch at the top of the shortcuts dialog now suppresses all of them at once while keeping every custom binding intact, so turning it back on restores the previous setup. The preference persists per browser in localStorage next to the custom bindings. Enforcement is a single guard in the window keydown handler, which already owns every shortcut, so nothing dispatches while it is on. Nothing is exempt, including the chord that opens this dialog. The dialog is still reachable from the account menu, so the switch cannot lock anyone out, and an exception would contradict what it says. useShortcutDisplay and useShortcutAriaKey return empty while it is on, so tooltips and aria-keyshortcuts across the app stop naming chords that would not fire. The binding rows stay editable, so shortcuts can be configured before turning them back on. * style(Skills,Prompts): align side panel spacing with the other panels Memories and Bookmarks share one spacing contract: 8px above the header, 12px down each side, and 12px under the last row. Skills and Prompts each drifted from it, so switching panels nudged the content. Skills sat at 16px per side and 12px on top, with the list running flush into the bottom edge. Its top padding now comes from the panel root like the other panels, its header and list use the shared 12px sides, and the list gets the same bottom inset. Prompts was applying the top padding twice, once on the panel root from the accordion and again on its own header, for 16px, and its list also ran flush into the bottom. The header no longer adds its own, and the list gets the bottom inset. Its asymmetric pl-3 pr-1 is left alone: the list reserves an 8px scrollbar gutter, so those values already render as an even 12px on both sides. Squaring the padding numbers would have made the panel visibly lopsided. Measured after the change, all four panels report 8px top, 12px bottom, and 12px on each side. * refactor(Shortcuts): invert the switch to an enabled-by-default control The control read "Disable keyboard shortcuts", so it was on when the feature was off. Inverting it makes the switch agree with the thing it names: it now reads "Keyboard Shortcuts", ships on, and turning it off is what stops the shortcuts. The stored value follows, from keyboardShortcutsDisabled to keyboardShortcutsEnabled defaulting to true. Nothing migrates the old key because the previous shape never shipped, and an absent value now means enabled, which is the default anyway. The row loses its filled card and sits as a plain bottom-bordered row under the title, reading as a section header for the list rather than a block competing with it. Every binding row now renders as disabled while the switch is off, dimmed with its edit and reset buttons actually disabled rather than merely looking inert. Any row left mid-edit is closed when the switch goes off, so the recorder cannot keep capturing keys for a shortcut that would not fire. * style(Shortcuts): fit the dialog on desktop without a scrollbar Open panels was a full-width block stacked under the two shortcut columns, so opening the dialog on a desktop viewport always started with a scrollbar, at about 100px of overflow. It becomes the third column instead. That removes the stacked block entirely, the three columns land at comparable heights, and the content now fits with nothing to scroll. The dialog widens on large screens to hold the extra column. Narrower viewports are unchanged in spirit: the panels list spans both columns below the shortcuts at tablet width and everything stacks into one column on a phone, scrolling as it did before. Reflowing the groups with CSS multi-column was the other option and looked worse: the short groups left a tall void beside Chat, and squeezing the panel rows into four columns truncated their labels. * style(Skills): make Edit an icon button and drop the detail text below the actions Edit was the only text button in a row of icon buttons, so it read as a different kind of control than Share and Delete beside it. It becomes a pencil icon at the same 36px size, carrying its label through a tooltip and an aria-label so the accessible name survives. The header row also centred its two halves against each other, which pinned the title level with the action buttons. The actions now pin to the top of the row and the text column starts below them, giving the title, author, date, and description a little room without moving the controls. * test: mock shortcut setting in expanded panel * remove unused com_ui_skill_toggle_active i18n key Superseded by com_ui_skill_available and com_ui_skill_available_hint in SkillToggle.tsx, but the old key was left behind in the locale files. * fix: honor shortcut switch in composer * fix: defer file loading until dialog opens * fix: preserve memory API errors * chore: restore automated locale entries * fix: honor shortcut switch during generation * refactor: share field message primitive * fix: reserve helper height for wrapping field messages * fix: wrap skill detail actions at narrow widths * fix: reset the memory create dialog when it closes --- .../Agents/MarketplaceAdminSettings.tsx | 9 +- .../Bookmarks/BookmarkEditDialog.tsx | 1 - .../src/components/Bookmarks/BookmarkForm.tsx | 65 +++-- .../Bookmarks/__tests__/BookmarkForm.test.tsx | 61 +++-- .../Chat/Input/DuringRunSendButton.tsx | 5 +- .../__tests__/DuringRunSendButton.test.tsx | 18 +- client/src/components/Nav/AccountSettings.tsx | 14 - .../Nav/KeyboardShortcutsDialog.tsx | 98 +++++-- .../src/components/Nav/Settings/registry.tsx | 9 + .../Nav/SettingsTabs/Data/DeleteCache.tsx | 7 +- .../Nav/SettingsTabs/Data/ManageFiles.tsx | 25 ++ .../Prompts/sidebar/GroupSidePanel.tsx | 4 +- .../Sharing/PeoplePickerAdminSettings.tsx | 246 +++--------------- .../SidePanel/Memories/MemoryCreateDialog.tsx | 73 +++--- .../SidePanel/Memories/MemoryEditDialog.tsx | 81 +++--- .../components/Skills/buttons/SkillToggle.tsx | 47 +++- .../components/Skills/display/SkillDetail.tsx | 98 ++++--- .../Skills/sidebar/SkillsSidePanel.tsx | 6 +- .../__tests__/ExpandedPanel.spec.tsx | 5 + .../src/components/ui/AdminSettingsDialog.tsx | 234 ++++++++++------- client/src/components/ui/index.ts | 2 +- .../hooks/Input/useComposerBindings.spec.tsx | 56 ++++ client/src/hooks/Input/useComposerBindings.ts | 18 +- client/src/hooks/Input/useTextarea.ts | 4 +- .../src/hooks/useKeyboardShortcuts.spec.tsx | 58 ++++- client/src/hooks/useKeyboardShortcuts.ts | 17 +- client/src/locales/en/translation.json | 14 +- client/src/store/misc.ts | 4 + client/src/utils/memory.spec.ts | 17 ++ client/src/utils/memory.ts | 63 +++++ client/src/utils/shortcuts.spec.ts | 22 ++ client/src/utils/shortcuts.ts | 7 +- .../src/components/FieldMessage.spec.tsx | 44 ++++ .../client/src/components/FieldMessage.tsx | 51 ++++ packages/client/src/components/index.ts | 1 + 35 files changed, 921 insertions(+), 563 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/Data/ManageFiles.tsx create mode 100644 client/src/hooks/Input/useComposerBindings.spec.tsx create mode 100644 client/src/utils/memory.spec.ts create mode 100644 packages/client/src/components/FieldMessage.spec.tsx create mode 100644 packages/client/src/components/FieldMessage.tsx 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 && ( - + +