mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📱 style: Consolidate the Mobile Chat Header (#14843)
* ♻️ 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 ? <OpenSidebar/> : 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.
This commit is contained in:
parent
f44ce0bb5d
commit
0e160d2ba0
21 changed files with 1043 additions and 401 deletions
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(null);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(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: <Share2 className="icon-md mr-2 text-text-secondary" />,
|
||||
show: isSharedButtonEnabled && canCreateSharedLinks,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} data-testid="share-conversation-menu-item" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
];
|
||||
const description = localize(
|
||||
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -86,15 +35,11 @@ export default function ExportAndShareMenu({
|
|||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={localize(
|
||||
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
|
||||
)}
|
||||
description={description}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="export-menu-button"
|
||||
aria-label={localize(
|
||||
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
|
||||
)}
|
||||
aria-label={description}
|
||||
className="relative 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"
|
||||
>
|
||||
<Share2
|
||||
|
|
@ -113,22 +58,10 @@ export default function ExportAndShareMenu({
|
|||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
items={items}
|
||||
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
|
||||
/>
|
||||
<ExportModal
|
||||
open={showExports}
|
||||
onOpenChange={setShowExports}
|
||||
conversation={conversation}
|
||||
triggerRef={exportButtonRef}
|
||||
aria-label={localize('com_ui_export_convo_modal')}
|
||||
/>
|
||||
<ShareButton
|
||||
triggerRef={shareButtonRef}
|
||||
conversationId={conversation.conversationId ?? ''}
|
||||
open={showShareDialog}
|
||||
onOpenChange={setShowShareDialog}
|
||||
/>
|
||||
{dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus';
|
||||
import ModelSelector from './Menus/Endpoints/ModelSelector';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import { OpenSidebar, PresetsMenu } from './Menus';
|
||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||
import { TemporaryChat } from './TemporaryChat';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
|
|
@ -15,6 +14,12 @@ import store from '~/store';
|
|||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
/**
|
||||
* Three zones in a single DOM order that serves both layouts: hidden items
|
||||
* generate no flex gap, so each breakpoint collapses to the right row without
|
||||
* reordering. Branching is CSS-only — `useMediaQuery` resolves after paint and
|
||||
* would pop the row a frame late on every mount.
|
||||
*/
|
||||
function Header() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const navVisible = useRecoilValue(store.sidebarExpanded);
|
||||
|
|
@ -39,47 +44,45 @@ function Header() {
|
|||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
/** The drawer covers the header on mobile; keep its controls out of the tab order. */
|
||||
const hiddenBehindNav = navVisible === true && 'max-md:hidden';
|
||||
|
||||
return (
|
||||
<div className="via-presentation/70 md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 absolute top-0 z-10 flex h-[52px] w-full items-center justify-between bg-gradient-to-b from-presentation to-transparent p-2 font-semibold text-text-primary 2xl:via-transparent">
|
||||
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
<div className="mx-1 flex items-center">
|
||||
{isSmallScreen ? <OpenSidebar /> : null}
|
||||
{!(navVisible && isSmallScreen) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 pl-2',
|
||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : '',
|
||||
)}
|
||||
>
|
||||
<ModelSelector startupConfig={startupConfig} />
|
||||
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||
{hasAccessToMultiConvo === true && <AddMultiConvo />}
|
||||
{isSmallScreen && (
|
||||
<>
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
{hasAccessToTemporaryChat === true && <TemporaryChat />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-0 z-10 flex h-[52px] w-full items-center gap-2 bg-gradient-to-b from-presentation via-presentation/70 to-transparent p-2 font-semibold text-text-primary md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 2xl:via-transparent">
|
||||
<div className="flex flex-shrink-0 items-center md:hidden">
|
||||
<OpenSidebar />
|
||||
</div>
|
||||
|
||||
{!isSmallScreen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportAndShareMenu
|
||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||
/>
|
||||
{hasAccessToTemporaryChat === true && <TemporaryChat />}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 md:pl-3 md:transition-all md:duration-200 md:ease-in-out',
|
||||
hiddenBehindNav,
|
||||
)}
|
||||
>
|
||||
<ModelSelector startupConfig={startupConfig} />
|
||||
{interfaceConfig.presets === true && interfaceConfig.modelSelect === true && (
|
||||
<PresetsMenu />
|
||||
)}
|
||||
{hasAccessToBookmarks === true && (
|
||||
<div className="hidden items-center md:flex">
|
||||
<BookmarkMenu />
|
||||
</div>
|
||||
)}
|
||||
{hasAccessToMultiConvo === true && (
|
||||
<div className="hidden items-center md:flex">
|
||||
<AddMultiConvo />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
<div />
|
||||
|
||||
<div className={cn('flex flex-shrink-0 items-center gap-2', hiddenBehindNav)}>
|
||||
<NewChat className="md:hidden" />
|
||||
<HeaderMenu startupConfig={startupConfig} className="md:hidden" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
|
||||
{hasAccessToTemporaryChat === true && <TemporaryChat />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,158 +1,29 @@
|
|||
import { useState, useId, useCallback, useMemo, useRef } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { BookmarkPlusIcon } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import { DropdownPopup, TooltipAnchor, Spinner, useToastContext } from '@librechat/client';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { DropdownPopup, TooltipAnchor, Spinner } from '@librechat/client';
|
||||
import type { FC } from 'react';
|
||||
import type * as t from '~/common';
|
||||
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkEditDialog } from '~/components/Bookmarks';
|
||||
import { useBookmarkSuccess, useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { cn, isTemporaryConversation, logger } from '~/utils';
|
||||
import useBookmarkItems from '~/hooks/Chat/useBookmarkItems';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BookmarkMenu: FC = () => {
|
||||
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 menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const conversationId = useRecoilValue(store.conversationByIndex(0))?.conversationId ?? '';
|
||||
const { show, items, bookmarks, hasBookmarks, isLoading, triggerAriaLabel, dialog } =
|
||||
useBookmarkItems();
|
||||
|
||||
const mutation = useTagConversationMutation(conversationId, {
|
||||
onSuccess: (newTags: string[], vars) => {
|
||||
updateConvoTags(newTags);
|
||||
const tagElement = document.getElementById(vars.tag);
|
||||
console.log('tagElement', tagElement);
|
||||
if (tagElement) {
|
||||
setTimeout(() => tagElement.focus(), 2);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: 'Error adding bookmark',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
onMutate: (vars) => {
|
||||
const tagElement = document.getElementById(vars.tag);
|
||||
console.log('tagElement', tagElement);
|
||||
if (tagElement) {
|
||||
setTimeout(() => tagElement.focus(), 2);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = useConversationTagsQuery();
|
||||
|
||||
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<TConversationTag[]>([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 newBookmarkRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const tagsCount = tags?.length ?? 0;
|
||||
const hasBookmarks = tagsCount > 0;
|
||||
|
||||
const buttonAriaLabel = useMemo(() => {
|
||||
if (tagsCount > 0) {
|
||||
return localize('com_ui_bookmarks_count_selected', { count: tagsCount });
|
||||
}
|
||||
return localize('com_ui_bookmarks_add');
|
||||
}, [tagsCount, localize]);
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = useMemo(() => {
|
||||
const items: t.MenuItemProps[] = [
|
||||
{
|
||||
id: '%___new___bookmark___%',
|
||||
label: localize('com_ui_bookmarks_new'),
|
||||
icon: <BookmarkPlusIcon className="size-4" />,
|
||||
hideOnClick: false,
|
||||
ref: newBookmarkRef,
|
||||
render: (props) => <button {...props} />,
|
||||
onClick: () => setIsDialogOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
if (data) {
|
||||
for (const tag of data) {
|
||||
const isSelected = tags?.includes(tag.tag) === true;
|
||||
items.push({
|
||||
id: tag.tag,
|
||||
label: tag.tag,
|
||||
hideOnClick: false,
|
||||
icon: isSelected ? (
|
||||
<BookmarkFilledIcon className="size-4" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-4" />
|
||||
),
|
||||
onClick: () => handleSubmit(tag.tag),
|
||||
disabled: mutation.isLoading,
|
||||
ariaChecked: isSelected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [tags, data, handleSubmit, mutation.isLoading, localize]);
|
||||
|
||||
if (!isActiveConvo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTemporary) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (mutation.isLoading) {
|
||||
if (isLoading) {
|
||||
return <Spinner aria-label="Spinner" />;
|
||||
}
|
||||
if (hasBookmarks) {
|
||||
|
|
@ -162,7 +33,7 @@ const BookmarkMenu: FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
menuId={menuId}
|
||||
|
|
@ -177,7 +48,7 @@ const BookmarkMenu: FC = () => {
|
|||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="bookmark-menu-button"
|
||||
aria-label={buttonAriaLabel}
|
||||
aria-label={triggerAriaLabel}
|
||||
aria-pressed={hasBookmarks}
|
||||
className={cn(
|
||||
'mt-text-sm flex size-9 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation text-sm transition-colors duration-200 hover:bg-surface-hover',
|
||||
|
|
@ -190,17 +61,9 @@ const BookmarkMenu: FC = () => {
|
|||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
/>
|
||||
<BookmarkEditDialog
|
||||
tags={tags}
|
||||
open={isDialogOpen}
|
||||
setTags={updateConvoTags}
|
||||
setOpen={setIsDialogOpen}
|
||||
triggerRef={newBookmarkRef}
|
||||
conversationId={conversationId}
|
||||
context="BookmarkMenu - BookmarkEditDialog"
|
||||
items={items}
|
||||
/>
|
||||
{dialog}
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
165
client/src/components/Chat/Menus/HeaderMenu.tsx
Normal file
165
client/src/components/Chat/Menus/HeaderMenu.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useState, useId } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { DropdownPopup, TooltipAnchor, Button } from '@librechat/client';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import { Ellipsis, PlusCircle, MessageCircleDashed, Check } from 'lucide-react';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type * as t from '~/common';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import useBookmarkItems from '~/hooks/Chat/useBookmarkItems';
|
||||
import useTemporaryChat from '~/hooks/Chat/useTemporaryChat';
|
||||
import useExportShare from '~/hooks/Chat/useExportShare';
|
||||
import useMultiConvo from '~/hooks/Chat/useMultiConvo';
|
||||
import { useHasAccess, useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Mobile overflow menu. Collapses the header's secondary actions behind a
|
||||
* single control so the bar holds four targets instead of seven. Each action's
|
||||
* behaviour and visibility rule comes from the hook that also drives its
|
||||
* desktop button, so the two surfaces cannot drift apart.
|
||||
*/
|
||||
export default function HeaderMenu({
|
||||
startupConfig,
|
||||
className,
|
||||
}: {
|
||||
startupConfig?: TStartupConfig;
|
||||
className?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const menuId = useId();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasAccessToMultiConvo = useHasAccess({
|
||||
permissionType: PermissionTypes.MULTI_CONVO,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasAccessToTemporaryChat = useHasAccess({
|
||||
permissionType: PermissionTypes.TEMPORARY_CHAT,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const multiConvo = useMultiConvo();
|
||||
const temporary = useTemporaryChat();
|
||||
const bookmarks = useBookmarkItems({ enabled: hasAccessToBookmarks === true });
|
||||
const exportShare = useExportShare({
|
||||
isSharedButtonEnabled: startupConfig?.sharedLinksEnabled ?? false,
|
||||
});
|
||||
|
||||
const showBookmarks = hasAccessToBookmarks === true && bookmarks.show;
|
||||
const showCompare = hasAccessToMultiConvo === true && multiConvo.show;
|
||||
const showTemporary = hasAccessToTemporaryChat === true && temporary.show;
|
||||
|
||||
const items: t.MenuItemProps[] = [];
|
||||
|
||||
/** `separate` marks an entry as *being* a divider, so it needs its own slot. */
|
||||
const pushGroup = (...group: t.MenuItemProps[]) => {
|
||||
if (items.length > 0) {
|
||||
items.push({ separate: true });
|
||||
}
|
||||
items.push(...group);
|
||||
};
|
||||
|
||||
if (showBookmarks) {
|
||||
items.push({
|
||||
id: 'header-bookmarks',
|
||||
label: localize('com_ui_bookmarks'),
|
||||
icon: bookmarks.hasBookmarks ? (
|
||||
<BookmarkFilledIcon className="icon-md mr-2 text-text-secondary" />
|
||||
) : (
|
||||
<BookmarkIcon className="icon-md mr-2 text-text-secondary" />
|
||||
),
|
||||
subItems: bookmarks.items,
|
||||
});
|
||||
}
|
||||
|
||||
if (showCompare) {
|
||||
items.push({
|
||||
id: 'header-compare',
|
||||
label: localize('com_ui_add_multi_conversation'),
|
||||
icon: <PlusCircle className="icon-md mr-2 text-text-secondary" />,
|
||||
onClick: multiConvo.addConversation,
|
||||
});
|
||||
}
|
||||
|
||||
if (exportShare.show) {
|
||||
pushGroup(...exportShare.items);
|
||||
}
|
||||
|
||||
if (showTemporary) {
|
||||
pushGroup({
|
||||
id: 'header-temporary',
|
||||
label: localize('com_ui_temporary'),
|
||||
ariaChecked: temporary.isTemporary,
|
||||
className: temporary.isTemporary ? 'bg-surface-active' : undefined,
|
||||
icon: temporary.isTemporary ? (
|
||||
<Check className="icon-md mr-2 text-text-primary" />
|
||||
) : (
|
||||
<MessageCircleDashed className="icon-md mr-2 text-text-secondary" />
|
||||
),
|
||||
onClick: temporary.toggle,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mirrors the desktop share button, which surfaces an active link in its tooltip. */
|
||||
const triggerDescription = exportShare.hasSharedLink
|
||||
? localize('com_ui_export_share_link_active')
|
||||
: localize('com_ui_more_options');
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks: bookmarks.bookmarks }}>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
items={items}
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={triggerDescription}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="header-menu-button"
|
||||
data-testid="header-overflow-menu"
|
||||
aria-label={triggerDescription}
|
||||
aria-expanded={isOpen}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'relative size-9 flex-shrink-0 rounded-xl bg-presentation hover:bg-surface-active-alt',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Ellipsis className="icon-md" aria-hidden="true" />
|
||||
{exportShare.hasSharedLink && (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 size-2 rounded-full bg-status-info ring-2 ring-presentation"
|
||||
data-testid="header-menu-shared-link-indicator"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{showBookmarks && bookmarks.dialog}
|
||||
{exportShare.dialogs}
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
}
|
||||
44
client/src/components/Chat/Menus/NewChat.tsx
Normal file
44
client/src/components/Chat/Menus/NewChat.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { TooltipAnchor, NewChatIcon, Button } from '@librechat/client';
|
||||
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
|
||||
import useNewChat from '~/hooks/Chat/useNewChat';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Header entry point for starting a new conversation. Renders as an anchor so
|
||||
* modified clicks still open `/c/new` in a new tab; `useNewChat` claims only
|
||||
* plain left clicks.
|
||||
*/
|
||||
export default function NewChat({ className }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { handleNewChatClick } = useNewChat();
|
||||
const tooltipDescription = useShortcutHint('newChat', localize('com_ui_new_chat'));
|
||||
const ariaKey = useShortcutAriaKey('newChat');
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={tooltipDescription}
|
||||
render={
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'size-9 flex-shrink-0 rounded-xl bg-presentation hover:bg-surface-active-alt',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href="/c/new"
|
||||
data-testid="header-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
aria-keyshortcuts={ariaKey}
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<NewChatIcon className="icon-md" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
202
client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
Normal file
202
client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
|
||||
const mockAccess: Record<string, boolean> = {};
|
||||
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: <div data-testid="bookmark-dialog" />,
|
||||
},
|
||||
exportShare: {
|
||||
show: true,
|
||||
items: [{ label: 'share' }, { label: 'export' }] as MenuItemProps[],
|
||||
hasSharedLink: false,
|
||||
dialogs: <div data-testid="export-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 }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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'>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
TooltipAnchor: ({ render: node }: { render: React.ReactNode }) => node,
|
||||
DropdownPopup: ({ trigger, items }: { trigger: React.ReactNode; items: MenuItemProps[] }) => (
|
||||
<div>
|
||||
{trigger}
|
||||
<ul data-testid="menu-items">
|
||||
{items
|
||||
.filter((item) => item.show !== false)
|
||||
.map((item, index) =>
|
||||
item.separate === true ? (
|
||||
<li key={index} data-kind="separator" />
|
||||
) : (
|
||||
<li key={index} data-kind="item" data-sub={item.subItems != null}>
|
||||
{item.label}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<HeaderMenu />);
|
||||
|
||||
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(<HeaderMenu />);
|
||||
|
||||
/** 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(<HeaderMenu />);
|
||||
|
||||
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(<HeaderMenu />);
|
||||
|
||||
expect(screen.queryByTestId('header-overflow-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops actions the user lacks permission for', () => {
|
||||
mockAccess.BOOKMARKS = false;
|
||||
mockAccess.MULTI_CONVO = false;
|
||||
|
||||
render(<HeaderMenu />);
|
||||
|
||||
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(<HeaderMenu />);
|
||||
|
||||
expect(rows()[0]).toHaveAttribute('data-kind', 'item');
|
||||
});
|
||||
|
||||
it('does not query bookmark tags without the bookmark permission', () => {
|
||||
mockAccess.BOOKMARKS = false;
|
||||
|
||||
render(<HeaderMenu />);
|
||||
|
||||
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(<HeaderMenu />);
|
||||
|
||||
/** 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(<HeaderMenu />);
|
||||
|
||||
const temporaryRow = rows().find((node) => node.textContent === 'com_ui_temporary');
|
||||
expect(temporaryRow).toBeDefined();
|
||||
expect(labels()).toContain('com_ui_temporary');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<button
|
||||
onClick={handleBadgeToggle}
|
||||
onClick={toggle}
|
||||
aria-label={localize('com_ui_temporary')}
|
||||
aria-pressed={isTemporary}
|
||||
aria-keyshortcuts={ariaKey}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { clearMessagesCache, cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function NewChat({ className }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
|
||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className={cn(
|
||||
'size-9 rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt max-md:hidden',
|
||||
className,
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<HTMLAnchorElement>) => {
|
||||
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 (
|
||||
<TooltipAnchor
|
||||
|
|
@ -53,7 +42,7 @@ const NewChatButton = memo(function NewChatButton({
|
|||
aria-label={localize('com_ui_new_chat')}
|
||||
aria-keyshortcuts={ariaKey}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-hover"
|
||||
onClick={handleClick}
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<SquarePen className="h-5 w-5 text-text-primary" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>) => {
|
||||
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(' '),
|
||||
|
|
|
|||
105
client/src/hooks/Chat/__tests__/useNewChat.spec.ts
Normal file
105
client/src/hooks/Chat/__tests__/useNewChat.spec.ts
Normal file
|
|
@ -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<MouseEvent<HTMLElement>> = {}) =>
|
||||
({
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: jest.fn(),
|
||||
...overrides,
|
||||
}) as unknown as MouseEvent<HTMLElement>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
178
client/src/hooks/Chat/useBookmarkItems.tsx
Normal file
178
client/src/hooks/Chat/useBookmarkItems.tsx
Normal file
|
|
@ -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<HTMLButtonElement>(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<TConversationTag[]>([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: <BookmarkPlusIcon className="size-4" />,
|
||||
hideOnClick: false,
|
||||
ref: newBookmarkRef,
|
||||
render: (props) => <button {...props} />,
|
||||
onClick: () => setIsDialogOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
if (data) {
|
||||
for (const tag of data) {
|
||||
const isSelected = tags?.includes(tag.tag) === true;
|
||||
next.push({
|
||||
id: tag.tag,
|
||||
label: tag.tag,
|
||||
hideOnClick: false,
|
||||
icon: isSelected ? (
|
||||
<BookmarkFilledIcon className="size-4" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-4" />
|
||||
),
|
||||
onClick: () => handleSubmit(tag.tag),
|
||||
disabled: mutation.isLoading,
|
||||
ariaChecked: isSelected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}, [tags, data, handleSubmit, mutation.isLoading, localize]);
|
||||
|
||||
const dialog = (
|
||||
<BookmarkEditDialog
|
||||
tags={tags}
|
||||
open={isDialogOpen}
|
||||
setTags={updateConvoTags}
|
||||
setOpen={setIsDialogOpen}
|
||||
triggerRef={newBookmarkRef}
|
||||
conversationId={conversationId}
|
||||
context="BookmarkMenu - BookmarkEditDialog"
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
show: enabled && isActiveConvo && !isTemporary,
|
||||
items,
|
||||
bookmarks: data ?? [],
|
||||
hasBookmarks: tagsCount > 0,
|
||||
isLoading: mutation.isLoading,
|
||||
triggerAriaLabel,
|
||||
dialog,
|
||||
};
|
||||
}
|
||||
99
client/src/hooks/Chat/useExportShare.tsx
Normal file
99
client/src/hooks/Chat/useExportShare.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Upload, Share2 } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
|
||||
export type UseExportShareResult = {
|
||||
/** New and search conversations have nothing to export or share. */
|
||||
show: boolean;
|
||||
items: t.MenuItemProps[];
|
||||
hasSharedLink: boolean;
|
||||
/** Rendered by whichever surface owns the menu; both need the same instance. */
|
||||
dialogs: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export and share as menu items, so the desktop icon menu and the mobile
|
||||
* overflow menu share one set of items and one pair of dialogs.
|
||||
*/
|
||||
export default function useExportShare({
|
||||
isSharedButtonEnabled,
|
||||
}: {
|
||||
isSharedButtonEnabled: boolean;
|
||||
}): UseExportShareResult {
|
||||
const localize = useLocalize();
|
||||
const [showExports, setShowExports] = useState(false);
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
|
||||
const shareButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const canCreateSharedLinks = useHasAccess({
|
||||
permissionType: PermissionTypes.SHARED_LINKS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
|
||||
const exportable =
|
||||
conversation != null &&
|
||||
conversation.conversationId != null &&
|
||||
conversation.conversationId !== 'new' &&
|
||||
conversation.conversationId !== 'search';
|
||||
|
||||
/** Declared before the `exportable` gate so hook order stays stable. */
|
||||
const { data: share } = useGetSharedLinkQuery(conversation?.conversationId ?? '', {
|
||||
enabled: exportable && isSharedButtonEnabled,
|
||||
});
|
||||
|
||||
const items: t.MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: () => setShowShareDialog(true),
|
||||
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
|
||||
show: isSharedButtonEnabled && canCreateSharedLinks,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} data-testid="share-conversation-menu-item" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: () => setShowExports(true),
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
show: exportable,
|
||||
items,
|
||||
hasSharedLink: Boolean(share?.shareId),
|
||||
dialogs: exportable ? (
|
||||
<>
|
||||
<ExportModal
|
||||
open={showExports}
|
||||
onOpenChange={setShowExports}
|
||||
conversation={conversation}
|
||||
triggerRef={exportButtonRef}
|
||||
aria-label={localize('com_ui_export_convo_modal')}
|
||||
/>
|
||||
<ShareButton
|
||||
triggerRef={shareButtonRef}
|
||||
conversationId={conversation.conversationId ?? ''}
|
||||
open={showShareDialog}
|
||||
onOpenChange={setShowShareDialog}
|
||||
/>
|
||||
</>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
34
client/src/hooks/Chat/useMultiConvo.ts
Normal file
34
client/src/hooks/Chat/useMultiConvo.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
|
||||
import { useGetConversation } from '~/hooks';
|
||||
import { mainTextareaId } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
export type UseMultiConvoResult = {
|
||||
/** Assistants render their own comparison surface, so the action is hidden there. */
|
||||
show: boolean;
|
||||
addConversation: () => void;
|
||||
};
|
||||
|
||||
/** Clones the current conversation into the second pane for side-by-side comparison. */
|
||||
export default function useMultiConvo(): UseMultiConvoResult {
|
||||
const getConversation = useGetConversation(0);
|
||||
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
|
||||
const setAddedConvo = useSetRecoilState(store.conversationByIndex(1));
|
||||
|
||||
const addConversation = useCallback(() => {
|
||||
const conversation = getConversation();
|
||||
const { title: _title, ...convo } = conversation ?? ({} as TConversation);
|
||||
setAddedConvo({ ...convo, title: '' } as TConversation);
|
||||
document.getElementById(mainTextareaId)?.focus();
|
||||
}, [getConversation, setAddedConvo]);
|
||||
|
||||
return {
|
||||
show: endpoint != null && !isAssistantsEndpoint(endpoint),
|
||||
addConversation,
|
||||
};
|
||||
}
|
||||
61
client/src/hooks/Chat/useNewChat.ts
Normal file
61
client/src/hooks/Chat/useNewChat.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import useNewConvo from '~/hooks/useNewConvo';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export type UseNewChatParams = {
|
||||
index?: number;
|
||||
/**
|
||||
* Runs once the conversation has been reset. The sidebar uses it to switch
|
||||
* back to the conversations panel; callers outside `ActivePanelProvider`
|
||||
* (the chat header, keyboard shortcuts) omit it.
|
||||
*/
|
||||
onNewChat?: () => void;
|
||||
};
|
||||
|
||||
export type UseNewChatResult = {
|
||||
startNewChat: () => void;
|
||||
/** For an `<a href="/c/new">` trigger, so modified clicks still open a tab. */
|
||||
handleNewChatClick: (event: MouseEvent<HTMLElement>) => void;
|
||||
newConversation: ReturnType<typeof useNewConvo>['newConversation'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Single source of truth for starting a new conversation: drops the cached
|
||||
* messages for the outgoing conversation, invalidates the messages query, then
|
||||
* resets the conversation atom.
|
||||
*/
|
||||
export default function useNewChat({
|
||||
index = 0,
|
||||
onNewChat,
|
||||
}: UseNewChatParams = {}): UseNewChatResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo(index);
|
||||
const conversationId = useRecoilValue(store.conversationIdByIndex(index));
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
clearMessagesCache(queryClient, conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
onNewChat?.();
|
||||
}, [queryClient, conversationId, newConversation, onNewChat]);
|
||||
|
||||
const handleNewChatClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
startNewChat();
|
||||
},
|
||||
[startNewChat],
|
||||
);
|
||||
|
||||
return { startNewChat, handleNewChatClick, newConversation };
|
||||
}
|
||||
32
client/src/hooks/Chat/useTemporaryChat.ts
Normal file
32
client/src/hooks/Chat/useTemporaryChat.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export type UseTemporaryChatResult = {
|
||||
/** Only offered before a conversation has any history — it cannot be toggled mid-thread. */
|
||||
show: boolean;
|
||||
isTemporary: boolean;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
export default function useTemporaryChat(): UseTemporaryChatResult {
|
||||
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsTemporary((previous) => !previous);
|
||||
}, [setIsTemporary]);
|
||||
|
||||
const conversationId = conversation?.conversationId;
|
||||
const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO;
|
||||
const hasMessages = Array.isArray(conversation?.messages) && conversation.messages.length >= 1;
|
||||
|
||||
return {
|
||||
show: !hasStarted && !hasMessages && !isSubmitting,
|
||||
isTemporary,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
|
||||
import type { ShortcutBinding } from '~/utils/shortcuts';
|
||||
import type { ShortcutOverride } from '~/store/misc';
|
||||
import {
|
||||
|
|
@ -18,8 +17,7 @@ import {
|
|||
import { mainTextareaId, NotificationSeverity } from '~/common';
|
||||
import { useArchiveConvoMutation } from '~/data-provider';
|
||||
import { useHasAccess, useLocalize } from '~/hooks';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import useNewConvo from './useNewConvo';
|
||||
import useNewChat from '~/hooks/Chat/useNewChat';
|
||||
import store from '~/store';
|
||||
|
||||
const isMac = isMacPlatform;
|
||||
|
|
@ -496,8 +494,7 @@ export function isOverridden(actionId: ShortcutActionId, override?: ShortcutOver
|
|||
export function useShortcutActions(): ShortcutAction[] {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { newConversation } = useNewConvo();
|
||||
const { startNewChat, newConversation } = useNewChat();
|
||||
const { showToast } = useToastContext();
|
||||
const routeMatch = useMatch('/c/:conversationId');
|
||||
const routeConvoId = routeMatch?.params.conversationId ?? null;
|
||||
|
|
@ -520,11 +517,9 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
}, [setShowShortcutsDialog]);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
startNewChat();
|
||||
return true;
|
||||
}, [queryClient, conversation?.conversationId, newConversation]);
|
||||
}, [startNewChat]);
|
||||
|
||||
const handleFocusChatInput = useCallback(() => {
|
||||
const textarea = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue