From 0e160d2ba03f311c825a3e3d2dc1558cbe52dfbf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 14 Aug 2026 18:14:05 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=B1=20style:=20Consolidate=20the=20Mob?= =?UTF-8?q?ile=20Chat=20Header=20(#14843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: Extract `useNewChat` as the Single New-Chat Path The new-chat sequence (clear the outgoing conversation's cached messages, invalidate the messages query, reset the conversation atom) existed in three places: the sidebar's `NewChatButton`, the `newChat` keyboard shortcut, and an unrendered `Nav/NewChat` component. Consolidate into `hooks/Chat/useNewChat`. The panel switch stays an optional `onNewChat` callback rather than living in the hook, because `useActivePanel` throws outside `ActivePanelProvider` and the chat header sits outside it — the upcoming header button needs this seam. `useKeyboardShortcuts` consumes the returned `newConversation` so the file still instantiates `useNewConvo` exactly once. Delete `Nav/NewChat`: it was reachable only through its own barrel export, and carried a stale `max-md:hidden` plus a `data-testid` that collided with the sidebar's button. `handleNewChatClick` now also defers on shift-click, so shift-click opens a new window like any other link. `ExpandedPanel.spec` mocks the new hook — it reaches `useNewConvo` by deep path, which escapes the spec's `~/hooks` barrel mock. * ♻️ refactor: Split Header Action Logic Out of Its Buttons Lift the behaviour behind the compare and temporary-chat header buttons into `useMultiConvo` and `useTemporaryChat`, leaving each component as a thin trigger. The upcoming mobile overflow menu needs the same actions as menu items, and the visibility rules (assistants have their own comparison surface; temporary chat can't be toggled mid-thread) have to stay in one place rather than being restated per surface. Add the header's new-chat button, consuming `useNewChat`. It renders as an anchor to `/c/new` so modified clicks still open a tab, and uses a distinct `data-testid` from the sidebar's button so queries can't match both. `useTemporaryChat` toggles through a functional updater, dropping the `useRecoilCallback` that existed only to close over the current value. No visual change yet — the header layout lands next. * 📱 style: Fold the Mobile Header Into Four Targets The mobile header was a horizontally scrolling strip of up to seven controls, each in its own outlined box, so nothing grouped and nothing receded. The overflow was hidden rather than solved: ModelSelector alone is capped at 70vw (273px) and the side clusters need ~130px, which does not fit a 390px phone. Mobile now reads: sidebar toggle, model selector, new chat, ellipsis. Lift the bookmark and export/share menu items into `useBookmarkItems` and `useExportShare`, each returning the items plus the dialog instance the surface must render. Both menus already built `MenuItemProps[]` internally, so the desktop buttons keep their exact markup and simply consume the hook — the two surfaces cannot drift apart. `HeaderMenu` composes those with the compare and temporary-chat actions. Bookmarks nests through `subItems` rather than flattening every tag to the top level, permission gates decide membership, and the trigger does not render when nothing survives — reachable, since export/share self-hides on a new conversation. Layout is one DOM order serving both breakpoints; hidden items generate no flex gap, so each collapses without reordering. Branching is CSS-only: `useMediaQuery` resolves after paint, and the old `isSmallScreen ? : null` popped the row a frame late on every mount. `overflow-x-auto` is gone. It hid the overflow instead of fixing it, and it is a horizontal-swipe sink the later edge-swipe work needs removed. Presets stays a visible mobile icon for now: `PresetItems` uses Radix's `Close`, which throws outside a Popover root, so folding it needs a controlled + anchored menu and browser verification. Also drops two stray `console.log` calls carried along from the bookmark mutation handlers. * 🩹 fix: Address Codex Findings on the Mobile Header Menu `separate: true` marks an entry as *being* a divider — `DropdownPopup` returns only a `MenuSeparator` for it and drops the item. Setting the flag on Share and on temporary chat therefore deleted those actions whenever an earlier group existed. Push standalone divider entries instead. The spec missed this because its `DropdownPopup` mock rendered every label regardless of the flag. It now mirrors the real contract — dividers replace items, `show: false` entries are dropped — and asserts both actions survive alongside their dividers. Gate the bookmark tags query on the bookmark permission. `HeaderMenu` mounts unconditionally and called `useBookmarkItems` before the permission result applied, so users without `BOOKMARKS:USE` issued a forbidden request on every chat header mount; the old header dodged this by mounting `BookmarkMenu` only after the check. Restore two states the collapsed trigger had dropped: the shared-link indicator and its active-link label, and a visible checked state for temporary chat, which previously only reached assistive tech through `aria-checked` while the old button switched to `bg-surface-active`. Compose both new controls from the shared `Button` primitive with the same override `OpenSidebar` already uses, rather than restating the bordered icon-button recipe locally. * 🐛 fix: Give the Overflow Menu's Share Indicator Its Own Test Id Restoring the shared-link indicator on the mobile trigger reused the id `ExportAndShareMenu` already owns. Both headers stay mounted and are only hidden by CSS, so `getByTestId('header-shared-link-indicator')` matched two elements and `shared-links.spec.ts` failed on a strict-mode violation. Distinct id, matching the new-chat button, which was already separated from the sidebar's for the same reason. --- client/src/components/Chat/AddMultiConvo.tsx | 35 +-- .../components/Chat/ExportAndShareMenu.tsx | 95 ++------ client/src/components/Chat/Header.tsx | 77 +++---- .../components/Chat/Menus/BookmarkMenu.tsx | 165 ++------------ .../src/components/Chat/Menus/HeaderMenu.tsx | 165 ++++++++++++++ client/src/components/Chat/Menus/NewChat.tsx | 44 ++++ .../Chat/Menus/__tests__/HeaderMenu.spec.tsx | 202 ++++++++++++++++++ client/src/components/Chat/Menus/index.ts | 2 + client/src/components/Chat/TemporaryChat.tsx | 28 +-- client/src/components/Nav/NewChat.tsx | 45 ---- client/src/components/Nav/index.ts | 1 - .../UnifiedSidebar/ExpandedPanel.tsx | 33 +-- .../__tests__/ExpandedPanel.spec.tsx | 25 +++ .../hooks/Chat/__tests__/useNewChat.spec.ts | 105 +++++++++ client/src/hooks/Chat/index.ts | 3 + client/src/hooks/Chat/useBookmarkItems.tsx | 178 +++++++++++++++ client/src/hooks/Chat/useExportShare.tsx | 99 +++++++++ client/src/hooks/Chat/useMultiConvo.ts | 34 +++ client/src/hooks/Chat/useNewChat.ts | 61 ++++++ client/src/hooks/Chat/useTemporaryChat.ts | 32 +++ client/src/hooks/useKeyboardShortcuts.ts | 15 +- 21 files changed, 1043 insertions(+), 401 deletions(-) create mode 100644 client/src/components/Chat/Menus/HeaderMenu.tsx create mode 100644 client/src/components/Chat/Menus/NewChat.tsx create mode 100644 client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx delete mode 100644 client/src/components/Nav/NewChat.tsx create mode 100644 client/src/hooks/Chat/__tests__/useNewChat.spec.ts create mode 100644 client/src/hooks/Chat/useBookmarkItems.tsx create mode 100644 client/src/hooks/Chat/useExportShare.tsx create mode 100644 client/src/hooks/Chat/useMultiConvo.ts create mode 100644 client/src/hooks/Chat/useNewChat.ts create mode 100644 client/src/hooks/Chat/useTemporaryChat.ts diff --git a/client/src/components/Chat/AddMultiConvo.tsx b/client/src/components/Chat/AddMultiConvo.tsx index 101dbadd19..a807dfc527 100644 --- a/client/src/components/Chat/AddMultiConvo.tsx +++ b/client/src/components/Chat/AddMultiConvo.tsx @@ -1,38 +1,13 @@ -import { useCallback } from 'react'; -import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PlusCircle } from 'lucide-react'; import { TooltipAnchor } from '@librechat/client'; -import { isAssistantsEndpoint } from 'librechat-data-provider'; -import type { TConversation } from 'librechat-data-provider'; -import { useGetConversation, useLocalize } from '~/hooks'; -import { mainTextareaId } from '~/common'; -import store from '~/store'; +import useMultiConvo from '~/hooks/Chat/useMultiConvo'; +import { useLocalize } from '~/hooks'; function AddMultiConvo() { const localize = useLocalize(); - const getConversation = useGetConversation(0); - const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)); - const setAddedConvo = useSetRecoilState(store.conversationByIndex(1)); + const { show, addConversation } = useMultiConvo(); - const clickHandler = useCallback(() => { - const conversation = getConversation(); - const { title: _t, ...convo } = conversation ?? ({} as TConversation); - setAddedConvo({ - ...convo, - title: '', - } as TConversation); - - const textarea = document.getElementById(mainTextareaId); - if (textarea) { - textarea.focus(); - } - }, [getConversation, setAddedConvo]); - - if (!endpoint) { - return null; - } - - if (isAssistantsEndpoint(endpoint)) { + if (!show) { return null; } @@ -42,7 +17,7 @@ function AddMultiConvo() { role="button" tabIndex={0} aria-label={localize('com_ui_add_multi_conversation')} - onClick={clickHandler} + onClick={addConversation} data-testid="add-multi-convo-button" className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" > diff --git a/client/src/components/Chat/ExportAndShareMenu.tsx b/client/src/components/Chat/ExportAndShareMenu.tsx index 8632cb3840..ffebe1d93e 100644 --- a/client/src/components/Chat/ExportAndShareMenu.tsx +++ b/client/src/components/Chat/ExportAndShareMenu.tsx @@ -1,15 +1,9 @@ -import { useState, useId, useRef } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useState, useId } from 'react'; +import { Share2 } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; -import { Upload, Share2 } from 'lucide-react'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client'; -import type * as t from '~/common'; -import ExportModal from '~/components/Nav/ExportConversation/ExportModal'; -import { ShareButton } from '~/components/Conversations/ConvoOptions'; -import { useHasAccess, useLocalize } from '~/hooks'; -import store from '~/store'; +import useExportShare from '~/hooks/Chat/useExportShare'; +import { useLocalize } from '~/hooks'; export default function ExportAndShareMenu({ isSharedButtonEnabled, @@ -17,63 +11,18 @@ export default function ExportAndShareMenu({ isSharedButtonEnabled: boolean; }) { const localize = useLocalize(); - const [showExports, setShowExports] = useState(false); - const [isPopoverActive, setIsPopoverActive] = useState(false); - const [showShareDialog, setShowShareDialog] = useState(false); - const menuId = useId(); - const shareButtonRef = useRef(null); - const exportButtonRef = useRef(null); - const canCreateSharedLinks = useHasAccess({ - permissionType: PermissionTypes.SHARED_LINKS, - permission: Permissions.CREATE, - }); + const [isPopoverActive, setIsPopoverActive] = useState(false); const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const conversation = useRecoilValue(store.conversationByIndex(0)); + const { show, items, hasSharedLink, dialogs } = useExportShare({ isSharedButtonEnabled }); - const exportable = - conversation != null && - conversation.conversationId != null && - conversation.conversationId !== 'new' && - conversation.conversationId !== 'search'; - const { data: share } = useGetSharedLinkQuery(conversation?.conversationId ?? '', { - enabled: exportable && isSharedButtonEnabled, - }); - const hasSharedLink = Boolean(share?.shareId); - - if (exportable === false) { + if (!show) { return null; } - const shareHandler = () => { - setShowShareDialog(true); - }; - - const exportHandler = () => { - setShowExports(true); - }; - - const dropdownItems: t.MenuItemProps[] = [ - { - label: localize('com_ui_share'), - onClick: shareHandler, - icon: , - show: isSharedButtonEnabled && canCreateSharedLinks, - /** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */ - hideOnClick: false, - ref: shareButtonRef, - render: (props) => + } + /> + ); +} diff --git a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx new file mode 100644 index 0000000000..d4893b316a --- /dev/null +++ b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx @@ -0,0 +1,202 @@ +import { render, screen } from '@testing-library/react'; +import type { MenuItemProps } from '~/common'; + +const mockAccess: Record = {}; +const mockBookmarkArgs: { enabled?: boolean }[] = []; +const mockHookState = { + multiConvo: { show: true, addConversation: jest.fn() }, + temporary: { show: true, isTemporary: false, toggle: jest.fn() }, + bookmarks: { + show: true, + items: [{ id: 'tag-1', label: 'work' }] as MenuItemProps[], + bookmarks: [], + hasBookmarks: false, + isLoading: false, + triggerAriaLabel: 'bookmarks', + dialog:
, + }, + exportShare: { + show: true, + items: [{ label: 'share' }, { label: 'export' }] as MenuItemProps[], + hasSharedLink: false, + dialogs:
, + }, +}; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useHasAccess: ({ permissionType }: { permissionType: string }) => + mockAccess[permissionType] ?? true, +})); + +jest.mock('~/hooks/Chat/useMultiConvo', () => ({ + __esModule: true, + default: () => mockHookState.multiConvo, +})); +jest.mock('~/hooks/Chat/useTemporaryChat', () => ({ + __esModule: true, + default: () => mockHookState.temporary, +})); +jest.mock('~/hooks/Chat/useBookmarkItems', () => ({ + __esModule: true, + default: (args: { enabled?: boolean } = {}) => { + mockBookmarkArgs.push(args); + return mockHookState.bookmarks; + }, +})); +jest.mock('~/hooks/Chat/useExportShare', () => ({ + __esModule: true, + default: () => mockHookState.exportShare, +})); + +/** The real MenuButton needs the MenuProvider that DropdownPopup supplies, which is mocked out below. */ +jest.mock('@ariakit/react', () => ({ + MenuButton: ({ + children, + render: _render, + ...props + }: React.ComponentProps<'button'> & { render?: React.ReactNode }) => ( + + ), +})); + +/** + * Mirrors DropdownPopup's real contract: `separate` entries render as a divider + * *instead of* an item, and `show: false` entries are dropped. A looser mock + * hides the bug where a flag on an actionable item silently deletes it. + */ +jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), + TooltipAnchor: ({ render: node }: { render: React.ReactNode }) => node, + DropdownPopup: ({ trigger, items }: { trigger: React.ReactNode; items: MenuItemProps[] }) => ( +
+ {trigger} +
    + {items + .filter((item) => item.show !== false) + .map((item, index) => + item.separate === true ? ( +
  • + ) : ( +
  • + {item.label} +
  • + ), + )} +
+
+ ), +})); + +import HeaderMenu from '../HeaderMenu'; + +const rows = () => Array.from(screen.getByTestId('menu-items').children); +const labels = () => + rows() + .filter((node) => node.getAttribute('data-kind') === 'item') + .map((node) => node.textContent); + +describe('HeaderMenu', () => { + beforeEach(() => { + mockBookmarkArgs.length = 0; + for (const key of Object.keys(mockAccess)) { + delete mockAccess[key]; + } + mockHookState.multiConvo.show = true; + mockHookState.temporary.show = true; + mockHookState.bookmarks.show = true; + mockHookState.exportShare.show = true; + mockHookState.exportShare.hasSharedLink = false; + mockHookState.temporary.isTemporary = false; + }); + + it('collapses every secondary action behind one trigger', () => { + render(); + + expect(screen.getByTestId('header-overflow-menu')).toBeInTheDocument(); + expect(labels()).toEqual([ + 'com_ui_bookmarks', + 'com_ui_add_multi_conversation', + 'share', + 'export', + 'com_ui_temporary', + ]); + }); + + it('keeps every action reachable when groups are divided', () => { + render(); + + /** A divider is its own entry; flagging an action as one deletes it. */ + expect(labels()).toContain('share'); + expect(labels()).toContain('com_ui_temporary'); + expect(rows().filter((node) => node.getAttribute('data-kind') === 'separator')).toHaveLength(2); + }); + + it('nests bookmarks rather than flattening every tag into the top level', () => { + render(); + + expect(rows()[0]).toHaveAttribute('data-sub', 'true'); + }); + + it('renders nothing when no action survives its gate', () => { + mockHookState.multiConvo.show = false; + mockHookState.temporary.show = false; + mockHookState.bookmarks.show = false; + mockHookState.exportShare.show = false; + + render(); + + expect(screen.queryByTestId('header-overflow-menu')).not.toBeInTheDocument(); + }); + + it('drops actions the user lacks permission for', () => { + mockAccess.BOOKMARKS = false; + mockAccess.MULTI_CONVO = false; + + render(); + + expect(labels()).toEqual(['share', 'export', 'com_ui_temporary']); + }); + + it('never opens with a leading divider when earlier groups are gated out', () => { + mockAccess.BOOKMARKS = false; + mockAccess.MULTI_CONVO = false; + + render(); + + expect(rows()[0]).toHaveAttribute('data-kind', 'item'); + }); + + it('does not query bookmark tags without the bookmark permission', () => { + mockAccess.BOOKMARKS = false; + + render(); + + expect(mockBookmarkArgs.every((args) => args.enabled === false)).toBe(true); + }); + + it('keeps surfacing an active shared link on the collapsed trigger', () => { + mockHookState.exportShare.hasSharedLink = true; + + render(); + + /** Distinct from the desktop menu's indicator; both are mounted at once. */ + expect(screen.getByTestId('header-menu-shared-link-indicator')).toBeInTheDocument(); + expect(screen.getByTestId('header-overflow-menu')).toHaveAttribute( + 'aria-label', + 'com_ui_export_share_link_active', + ); + }); + + it('shows temporary chat as active to sighted users, not just assistive tech', () => { + mockHookState.temporary.isTemporary = true; + + render(); + + const temporaryRow = rows().find((node) => node.textContent === 'com_ui_temporary'); + expect(temporaryRow).toBeDefined(); + expect(labels()).toContain('com_ui_temporary'); + }); +}); diff --git a/client/src/components/Chat/Menus/index.ts b/client/src/components/Chat/Menus/index.ts index b55dfd846a..ba12ccf196 100644 --- a/client/src/components/Chat/Menus/index.ts +++ b/client/src/components/Chat/Menus/index.ts @@ -1,2 +1,4 @@ export { default as PresetsMenu } from './PresetsMenu'; export { default as OpenSidebar } from './OpenSidebar'; +export { default as HeaderMenu } from './HeaderMenu'; +export { default as NewChat } from './NewChat'; diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index fe4b87262a..b8d1513b54 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,37 +1,17 @@ -import React from 'react'; -import { useRecoilValue } from 'recoil'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; -import { Constants } from 'librechat-data-provider'; -import { useRecoilState, useRecoilCallback } from 'recoil'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; +import useTemporaryChat from '~/hooks/Chat/useTemporaryChat'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); - const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); - const conversation = useRecoilValue(store.conversationByIndex(0)); - const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); + const { show, isTemporary, toggle } = useTemporaryChat(); const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary')); const ariaKey = useShortcutAriaKey('toggleTemporaryChat'); - const handleBadgeToggle = useRecoilCallback( - () => () => { - setIsTemporary(!isTemporary); - }, - [isTemporary], - ); - - const conversationId = conversation?.conversationId; - const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO; - - if ( - hasStarted || - (Array.isArray(conversation?.messages) && conversation.messages.length >= 1) || - isSubmitting - ) { + if (!show) { return null; } @@ -41,7 +21,7 @@ export function TemporaryChat() { description={tooltipDescription} render={ - } - /> - ); -} diff --git a/client/src/components/Nav/index.ts b/client/src/components/Nav/index.ts index 8a18285181..e56b67d0a5 100644 --- a/client/src/components/Nav/index.ts +++ b/client/src/components/Nav/index.ts @@ -1,6 +1,5 @@ export * from './ExportConversation'; export * from './SettingsTabs/'; export { default as NavLink } from './NavLink'; -export { default as NewChat } from './NewChat'; export { default as SearchBar } from './SearchBar'; export { default as Settings } from './Settings'; diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index 8400310e83..437320712a 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -1,15 +1,14 @@ import { memo, useCallback, lazy, Suspense } from 'react'; import { useRecoilValue } from 'recoil'; import { SquarePen } from 'lucide-react'; -import { QueryKeys } from 'librechat-data-provider'; -import { useQueryClient } from '@tanstack/react-query'; import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client'; import type { NavLink } from '~/common'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers'; import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; -import { useLocalize, useNewConvo } from '~/hooks'; -import { clearMessagesCache, cn } from '~/utils'; +import useNewChat from '~/hooks/Chat/useNewChat'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; import store from '~/store'; const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings')); @@ -20,27 +19,17 @@ const NewChatButton = memo(function NewChatButton({ setActive: (id: string) => void; }) { const localize = useLocalize(); - const queryClient = useQueryClient(); - const { newConversation } = useNewConvo(); - const conversationId = useRecoilValue(store.conversationIdByIndex(0)); const switchToHistory = useRecoilValue(store.newChatSwitchToHistory); const tooltipDescription = useShortcutHint('newChat', localize('com_ui_new_chat')); const ariaKey = useShortcutAriaKey('newChat'); - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (e.button === 0 && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - clearMessagesCache(queryClient, conversationId); - queryClient.invalidateQueries([QueryKeys.messages]); - newConversation(); - if (switchToHistory) { - setActive(DEFAULT_PANEL); - } - } - }, - [queryClient, conversationId, newConversation, switchToHistory, setActive], - ); + const handlePanelSwitch = useCallback(() => { + if (switchToHistory) { + setActive(DEFAULT_PANEL); + } + }, [switchToHistory, setActive]); + + const { handleNewChatClick } = useNewChat({ onNewChat: handlePanelSwitch }); return ( diff --git a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx index 9ee7d83d63..aad4bc297e 100644 --- a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx +++ b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx @@ -39,6 +39,31 @@ jest.mock('~/hooks', () => ({ useNewConvo: () => ({ newConversation: mockNewConversation }), })); +/** + * Stands in for the real hook, which reaches `useNewConvo` by deep path and so + * escapes the `~/hooks` mock above. Mirrors its contract closely enough that + * the panel-switch assertions still exercise the `onNewChat` wiring. + */ +jest.mock('~/hooks/Chat/useNewChat', () => ({ + __esModule: true, + default: ({ onNewChat }: { onNewChat?: () => void } = {}) => ({ + newConversation: mockNewConversation, + startNewChat: () => { + mockNewConversation(); + onNewChat?.(); + }, + handleNewChatClick: (event: React.MouseEvent) => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + event.preventDefault(); + mockClearMessagesCache(); + mockNewConversation(); + onNewChat?.(); + }, + }), +})); + jest.mock('~/utils', () => ({ clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args), cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), diff --git a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts new file mode 100644 index 0000000000..0b74a8cf3e --- /dev/null +++ b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts @@ -0,0 +1,105 @@ +import { renderHook, act } from '@testing-library/react'; + +import type { MouseEvent } from 'react'; + +const mockNewConversation = jest.fn(); +const mockClearMessagesCache = jest.fn(); +const mockInvalidateQueries = jest.fn(); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})); + +jest.mock('recoil', () => ({ + useRecoilValue: () => 'convo-1', +})); + +jest.mock('~/hooks/useNewConvo', () => ({ + __esModule: true, + default: () => ({ newConversation: mockNewConversation }), +})); + +jest.mock('~/utils', () => ({ + clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args), +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { conversationIdByIndex: (index: number) => `conversationIdByIndex-${index}` }, +})); + +jest.mock('librechat-data-provider', () => ({ + QueryKeys: { messages: 'messages' }, +})); + +import useNewChat from '../useNewChat'; + +const clickEvent = (overrides: Partial> = {}) => + ({ + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + preventDefault: jest.fn(), + ...overrides, + }) as unknown as MouseEvent; + +describe('useNewChat', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears the outgoing conversation before resetting', () => { + const { result } = renderHook(() => useNewChat()); + + act(() => result.current.startNewChat()); + + expect(mockClearMessagesCache).toHaveBeenCalledWith(expect.anything(), 'convo-1'); + expect(mockInvalidateQueries).toHaveBeenCalledWith(['messages']); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it('runs the optional callback after the reset', () => { + const onNewChat = jest.fn(); + const { result } = renderHook(() => useNewChat({ onNewChat })); + + act(() => result.current.startNewChat()); + + expect(onNewChat).toHaveBeenCalledTimes(1); + expect(mockNewConversation.mock.invocationCallOrder[0]).toBeLessThan( + onNewChat.mock.invocationCallOrder[0], + ); + }); + + it('works without a callback', () => { + const { result } = renderHook(() => useNewChat()); + + expect(() => act(() => result.current.startNewChat())).not.toThrow(); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it('takes over a plain left click', () => { + const { result } = renderHook(() => useNewChat()); + const event = clickEvent(); + + act(() => result.current.handleNewChatClick(event)); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mockNewConversation).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['ctrl', { ctrlKey: true }], + ['meta', { metaKey: true }], + ['shift', { shiftKey: true }], + ['middle', { button: 1 }], + ])('lets a %s click fall through to the browser', (_label, overrides) => { + const { result } = renderHook(() => useNewChat()); + const event = clickEvent(overrides); + + act(() => result.current.handleNewChatClick(event)); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockNewConversation).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts index 3dc2a8fd65..b3d8972f3a 100644 --- a/client/src/hooks/Chat/index.ts +++ b/client/src/hooks/Chat/index.ts @@ -1,3 +1,6 @@ +export { default as useNewChat } from './useNewChat'; +export { default as useMultiConvo } from './useMultiConvo'; +export { default as useTemporaryChat } from './useTemporaryChat'; export { default as useChatHelpers } from './useChatHelpers'; export { default as useTokenLimits } from './useTokenLimits'; export { default as useTokenUsage } from './useTokenUsage'; diff --git a/client/src/hooks/Chat/useBookmarkItems.tsx b/client/src/hooks/Chat/useBookmarkItems.tsx new file mode 100644 index 0000000000..be4ed7c0c7 --- /dev/null +++ b/client/src/hooks/Chat/useBookmarkItems.tsx @@ -0,0 +1,178 @@ +import { useState, useCallback, useMemo, useRef } from 'react'; +import { useRecoilValue } from 'recoil'; +import { BookmarkPlusIcon } from 'lucide-react'; +import { useToastContext } from '@librechat/client'; +import { useQueryClient } from '@tanstack/react-query'; +import { Constants, QueryKeys } from 'librechat-data-provider'; +import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; + +import type { TConversationTag } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type * as t from '~/common'; + +import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider'; +import { BookmarkEditDialog } from '~/components/Bookmarks'; +import { useBookmarkSuccess, useLocalize } from '~/hooks'; +import { isTemporaryConversation, logger } from '~/utils'; +import { NotificationSeverity } from '~/common'; +import store from '~/store'; + +export type UseBookmarkItemsResult = { + /** Bookmarks only apply to a saved, non-temporary conversation. */ + show: boolean; + items: t.MenuItemProps[]; + bookmarks: TConversationTag[]; + hasBookmarks: boolean; + isLoading: boolean; + triggerAriaLabel: string; + /** Rendered by whichever surface owns the menu; both need the same instance. */ + dialog: ReactNode; +}; + +/** + * Bookmark tagging as menu items, so the desktop icon menu and the mobile + * overflow menu share one set of items, one mutation, and one edit dialog. + */ +export default function useBookmarkItems({ + enabled = true, +}: { enabled?: boolean } = {}): UseBookmarkItemsResult { + const localize = useLocalize(); + const queryClient = useQueryClient(); + const { showToast } = useToastContext(); + + const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; + const conversationId = conversation?.conversationId ?? ''; + const updateConvoTags = useBookmarkSuccess(conversationId); + const tags = conversation?.tags; + const isTemporary = isTemporaryConversation(conversation); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const newBookmarkRef = useRef(null); + + const focusTag = useCallback((tag: string) => { + const tagElement = document.getElementById(tag); + if (tagElement) { + setTimeout(() => tagElement.focus(), 2); + } + }, []); + + const mutation = useTagConversationMutation(conversationId, { + onSuccess: (newTags: string[], vars) => { + updateConvoTags(newTags); + focusTag(vars.tag); + }, + onError: () => { + showToast({ + message: 'Error adding bookmark', + severity: NotificationSeverity.ERROR, + }); + }, + onMutate: (vars) => { + focusTag(vars.tag); + }, + }); + + /** The tags endpoint is behind the bookmark permission, so an ungated query 403s. */ + const { data } = useConversationTagsQuery({ enabled }); + + const isActiveConvo = Boolean( + conversation && + conversationId && + conversationId !== Constants.NEW_CONVO && + conversationId !== 'search', + ); + + const handleSubmit = useCallback( + (tag?: string) => { + if (tag === undefined || tag === '' || !conversationId) { + showToast({ + message: 'Invalid tag or conversationId', + severity: NotificationSeverity.ERROR, + }); + return; + } + + logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags before setting', tags); + + const allTags = + queryClient.getQueryData([QueryKeys.conversationTags]) ?? []; + const existingTags = allTags.map((t) => t.tag); + const filteredTags = tags?.filter((t) => existingTags.includes(t)); + + logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after filtering', filteredTags); + const newTags = + filteredTags?.includes(tag) === true + ? filteredTags.filter((t) => t !== tag) + : [...(filteredTags ?? []), tag]; + + logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after', newTags); + mutation.mutate({ tags: newTags, tag }); + }, + [tags, conversationId, mutation, queryClient, showToast], + ); + + const tagsCount = tags?.length ?? 0; + + const triggerAriaLabel = useMemo(() => { + if (tagsCount > 0) { + return localize('com_ui_bookmarks_count_selected', { count: tagsCount }); + } + return localize('com_ui_bookmarks_add'); + }, [tagsCount, localize]); + + const items: t.MenuItemProps[] = useMemo(() => { + const next: t.MenuItemProps[] = [ + { + id: '%___new___bookmark___%', + label: localize('com_ui_bookmarks_new'), + icon: , + hideOnClick: false, + ref: newBookmarkRef, + render: (props) =>