Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/src/common/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface MenuItemProps {
separate?: boolean;
hideOnClick?: boolean;
dialog?: React.ReactElement;
ariaHasPopup?: React.AriaAttributes['aria-haspopup'];
ariaControls?: string;
ariaLabel?: string;
ariaChecked?: boolean;
ref?: React.Ref<any>;
Expand Down
16 changes: 11 additions & 5 deletions client/src/components/Chat/ExportAndShareMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { useState, useId } from 'react';
import { Share2 } from 'lucide-react';
import { Ellipsis } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client';
import useExportShare from '~/hooks/Chat/useExportShare';
import useChatOptions from '~/hooks/Chat/useChatOptions';
import { useLocalize } from '~/hooks';

export default function ExportAndShareMenu({
isSharedButtonEnabled,
readOnly = false,
}: {
isSharedButtonEnabled: boolean;
readOnly?: boolean;
}) {
const localize = useLocalize();
const menuId = useId();
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { show, items, hasSharedLink, dialogs } = useExportShare({ isSharedButtonEnabled });
const { show, items, hasSharedLink, dialogs } = useChatOptions({
isSharedButtonEnabled,
readOnly,
closeMenu: () => setIsPopoverActive(false),
});

if (!show) {
return null;
}

const description = localize(
hasSharedLink ? 'com_ui_export_share_link_active' : 'com_endpoint_export_share',
hasSharedLink ? 'com_ui_chat_options_link_active' : 'com_ui_chat_options',
);

return (
Expand All @@ -42,7 +48,7 @@ export default function ExportAndShareMenu({
aria-label={description}
className="border-border-light bg-presentation text-text-primary hover:bg-surface-tertiary aria-expanded:bg-surface-tertiary relative inline-flex size-9 shrink-0 items-center justify-center rounded-xl border transition-all ease-in-out disabled:pointer-events-none disabled:opacity-50"
>
<Share2
<Ellipsis
className="icon-md text-text-primary"
aria-hidden="true"
focusable="false"
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/Chat/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,15 @@ function Header({
<HeaderMenu
startupConfig={startupConfig}
trace={trace}
readOnly={readOnly}
className={isSmallScreen ? undefined : 'hidden'}
/>
<div className={cn('items-center gap-2', isSmallScreen ? 'hidden' : 'flex')}>
{trace.show && <TraceButton onClick={trace.open} />}
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
readOnly={readOnly}
/>
{showTemporaryChat && <TemporaryChat />}
</div>
</div>
Expand Down
11 changes: 8 additions & 3 deletions client/src/components/Chat/Menus/HeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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 useChatOptions from '~/hooks/Chat/useChatOptions';
import useMultiConvo from '~/hooks/Chat/useMultiConvo';
import { useHasAccess, useLocalize } from '~/hooks';
import { cn } from '~/utils';
Expand All @@ -25,8 +25,11 @@ export default function HeaderMenu({
startupConfig,
trace,
className,
readOnly = false,
}: {
startupConfig?: TStartupConfig;
/** A read-only subagent thread offers share and export but no edits to the chat. */
readOnly?: boolean;
/** Owned by the header, which also renders the desktop trace button from it. */
trace?: TraceControl;
className?: string;
Expand All @@ -51,8 +54,10 @@ export default function HeaderMenu({
const multiConvo = useMultiConvo();
const temporary = useTemporaryChat();
const bookmarks = useBookmarkItems({ enabled: hasAccessToBookmarks === true });
const exportShare = useExportShare({
const exportShare = useChatOptions({
isSharedButtonEnabled: startupConfig?.sharedLinksEnabled ?? false,
readOnly,
closeMenu: () => setIsOpen(false),
});

const showBookmarks = hasAccessToBookmarks === true && bookmarks.show;
Expand Down Expand Up @@ -129,7 +134,7 @@ export default function HeaderMenu({

/** 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_chat_options_link_active')
: localize('com_ui_more_options');

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jest.mock('~/hooks/Chat/useBookmarkItems', () => ({
return mockHookState.bookmarks;
},
}));
jest.mock('~/hooks/Chat/useExportShare', () => ({
jest.mock('~/hooks/Chat/useChatOptions', () => ({
__esModule: true,
default: () => mockHookState.exportShare,
}));
Expand Down Expand Up @@ -186,7 +186,7 @@ describe('HeaderMenu', () => {
expect(screen.getByTestId('header-menu-shared-link-indicator')).toBeInTheDocument();
expect(screen.getByTestId('header-overflow-menu')).toHaveAttribute(
'aria-label',
'com_ui_export_share_link_active',
'com_ui_chat_options_link_active',
);
});

Expand Down
140 changes: 140 additions & 0 deletions client/src/components/Chat/Rename.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useState, useEffect, useRef } from 'react';
import {
Label,
Input,
Button,
Spinner,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
useToastContext,
} from '@librechat/client';
import type { FormEvent, RefObject } from 'react';
import { useUpdateConversationMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';

const TITLE_MAX_LENGTH = 100;
const INPUT_ID = 'chat-rename-input';

type RenameProps = {
conversationId: string;
title: string;
/** Saving an unchanged title is how a user claims an automatic one, so only an owned title short-circuits. */
titleSetByUser: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef?: RefObject<HTMLButtonElement>;
};

function RenameContent({
conversationId,
title,
titleSetByUser,
onClose,
}: Pick<RenameProps, 'conversationId' | 'title' | 'titleSetByUser'> & { onClose: () => void }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [value, setValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const updateMutation = useUpdateConversationMutation(conversationId);

const mountedRef = useRef(true);

useEffect(() => {
mountedRef.current = true;
inputRef.current?.focus();
inputRef.current?.select();
return () => {
mountedRef.current = false;
};
}, []);

const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const next = value.trim() || localize('com_ui_untitled');
if (next === title && titleSetByUser) {
onClose();
return;
Comment thread
berry-13 marked this conversation as resolved.
}
try {
await updateMutation.mutateAsync({ conversationId, title: next });
/** A rename that settles after the dialog went away has no dialog left to close or focus. */
if (mountedRef.current) {
onClose();
}
} catch (error) {
logger.error('Error renaming conversation', error);
showToast({
message: localize('com_ui_rename_failed'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
}
};

return (
<OGDialogContent
id="rename-conversation-dialog"
className="w-11/12 max-w-md"
showCloseButton={false}
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_rename_conversation')}</OGDialogTitle>
</OGDialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={INPUT_ID}>{localize('com_ui_new_conversation_title')}</Label>
<Input
id={INPUT_ID}
ref={inputRef}
value={value}
maxLength={TITLE_MAX_LENGTH}
onChange={(event) => setValue(event.target.value)}
/>
</div>
<div className="flex justify-end gap-4">
<OGDialogClose asChild>
<Button type="button" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button type="submit" variant="submit" disabled={updateMutation.isLoading}>
{updateMutation.isLoading ? <Spinner /> : localize('com_ui_save')}
</Button>
</div>
</form>
</OGDialogContent>
);
}

/** Rename for the open chat. The sidebar renames inline in its row, which the header has no row for. */
export default function Rename({
conversationId,
title,
titleSetByUser,
open,
onOpenChange,
triggerRef,
}: RenameProps) {
/** Closing from a save bypasses the dialog's own handler, which is what returns focus to the
* menu item that opened it, so the same return is done here. */
const close = () => {
setTimeout(() => triggerRef?.current?.focus(), 0);
Comment thread
berry-13 marked this conversation as resolved.
onOpenChange(false);
};

return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<RenameContent
conversationId={conversationId}
title={title}
titleSetByUser={titleSetByUser}
onClose={close}
/>
</OGDialog>
);
}
43 changes: 17 additions & 26 deletions client/src/components/Chat/__tests__/ExportAndShareMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ExportAndShareMenu from '../ExportAndShareMenu';

let mockShareId: string | null = null;

jest.mock('recoil', () => ({
useRecoilValue: () => ({ conversationId: 'conversation-1' }),
}));

jest.mock('librechat-data-provider/react-query', () => ({
useGetSharedLinkQuery: () => ({ data: { shareId: mockShareId } }),
}));
const mockOptions = { show: true, hasSharedLink: false };

jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
Expand All @@ -26,31 +18,22 @@ jest.mock('@librechat/client', () => ({
}));

jest.mock('~/hooks', () => ({
useHasAccess: () => true,
useLocalize: () => (key: string) => key,
}));

jest.mock('~/components/Nav/ExportConversation/ExportModal', () => ({
jest.mock('~/hooks/Chat/useChatOptions', () => ({
__esModule: true,
default: () => null,
}));

jest.mock('~/components/Conversations/ConvoOptions', () => ({
ShareButton: () => null,
}));

jest.mock('~/store', () => ({
__esModule: true,
default: { conversationByIndex: () => ({}) },
default: () => ({ ...mockOptions, items: [], dialogs: null }),
}));

describe('ExportAndShareMenu link status', () => {
beforeEach(() => {
mockShareId = null;
mockOptions.show = true;
mockOptions.hasSharedLink = false;
});

it('shows a blue circular indicator when the conversation has a link', () => {
mockShareId = 'share-1';
mockOptions.hasSharedLink = true;

render(<ExportAndShareMenu isSharedButtonEnabled={true} />);

Expand All @@ -63,14 +46,22 @@ describe('ExportAndShareMenu link status', () => {
);
expect(screen.getByRole('button')).toHaveAttribute(
'aria-label',
'com_ui_export_share_link_active',
'com_ui_chat_options_link_active',
);
});

it('uses the default share control when the conversation has no link', () => {
it('uses the default options label when the conversation has no link', () => {
render(<ExportAndShareMenu isSharedButtonEnabled={true} />);

expect(screen.queryByTestId('header-shared-link-indicator')).not.toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'com_endpoint_export_share');
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'com_ui_chat_options');
});

it('renders nothing for a conversation that has not been saved', () => {
mockOptions.show = false;

const { container } = render(<ExportAndShareMenu isSharedButtonEnabled={true} />);

expect(container).toBeEmptyDOMElement();
});
});
Loading
Loading