diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index 6b3e51b3f6..4361481d54 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useState, useCallback } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; -import { InfoHoverCard, ESide } from '@librechat/client'; +import { InfoHoverCard, ESide, UserIcon } from '@librechat/client'; import type { TFile, TMessage } from 'librechat-data-provider'; import type { TMessageIcon } from '~/common'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; @@ -10,7 +10,6 @@ import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import Image from '~/components/Chat/Messages/Content/Image'; -import { useAuthContext } from '~/hooks/AuthContext'; import { fontSizeAtom } from '~/store/fontSize'; import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; @@ -41,7 +40,9 @@ const SteerPart = memo(function SteerPart({ createdAt?: number; }) { const localize = useLocalize(); - const { user } = useAuthContext(); + /** Read the atom rather than the auth context: AuthContextProvider mirrors the + * user into it, and the public share route mounts outside that provider. */ + const user = useRecoilValue(store.user); const fontSize = useAtomValue(fontSizeAtom); const { isSharedConvo } = useShareContext(); const usernameDisplay = useRecoilValue(store.UsernameDisplay); @@ -90,7 +91,24 @@ const SteerPart = memo(function SteerPart({ >
- + {isSharedConvo === true ? ( + /** The atom still holds the viewer's identity when a signed-in user opens + * a share link, so rendering the identity-bearing avatar here would put + * the viewer's face on the sharer's steer. Mirrors Share/MessageIcon. */ +
+ +
+ ) : ( + + )}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx index 4c44aeef8a..8f586c18e0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { TMessage } from 'librechat-data-provider'; import SteerPart from '../SteerPart'; +import store from '~/store'; let mockShareContext: { isSharedConvo?: boolean; shareId?: string } = {}; @@ -10,19 +12,10 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); -jest.mock('~/hooks/AuthContext', () => ({ - useAuthContext: () => ({ user: { name: 'Danny', username: 'danny' } }), -})); - jest.mock('~/Providers', () => ({ useShareContext: () => mockShareContext, })); -jest.mock('~/components/Chat/Messages/MessageIcon', () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock('~/components/Chat/Messages/ui/MessageTimestamp', () => ({ __esModule: true, default: () => null, @@ -53,11 +46,23 @@ jest.mock('~/components/Chat/Messages/Content/Image', () => ({ default: ({ altText }: { altText: string }) => {altText}, })); -function renderPart(files?: TMessage['files']) { +/** Seeds the user atom rather than mocking `useAuthContext`, and renders the real + * MessageIcon tree — mocking either one hid a crash on the share route, where + * neither an auth context nor a user exists. */ +const SEEDED_USER = { name: 'Danny', username: 'danny' }; + +function renderPart( + files?: TMessage['files'], + /** `null` seeds nothing — passing `undefined` would fall back to the default and + * silently test the signed-in state instead of the anonymous share route. */ + user: { name: string; username: string } | null = SEEDED_USER, +) { return render( - - - , + + user && set(store.user, user as never)}> + + + , ); } @@ -72,6 +77,29 @@ describe('SteerPart author label', () => { expect(screen.queryByText('com_user_message')).toBeNull(); }); + it('renders on the share route, where there is no auth context and no user', () => { + /** Regression for #14474: `/share/:shareId` mounts outside AuthContextProvider, + * so any `useAuthContext()` on this tree throws and the whole page is replaced + * by the route error boundary. Both SteerPart and the MessageIcon tree it + * renders used to call it. */ + mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; + + expect(() => renderPart(undefined, null)).not.toThrow(); + expect(screen.getByText('steered words')).toBeInTheDocument(); + expect(screen.getByText('com_user_message')).toBeInTheDocument(); + }); + + it('never renders the viewer identity on a shared steer avatar', () => { + /** The user atom is app-wide and survives navigation, so a signed-in viewer + * opening a share link still has an identity in state. The shared steer must + * show the generic avatar regardless. */ + mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; + renderPart(undefined, SEEDED_USER); + + expect(screen.queryByTitle('Danny')).toBeNull(); + expect(screen.queryByText('Danny')).toBeNull(); + }); + it('labels with the generic user message in the share view, never the viewer identity', () => { mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; renderPart(); @@ -104,7 +132,9 @@ describe('SteerPart presentation', () => { it('presents the steer as a user message with an icon', () => { renderPart(); - expect(screen.getByTestId('user-icon')).toBeInTheDocument(); + /** Asserts the real avatar rather than a stubbed one — the previous mock was + * what hid the auth-context crash inside this icon tree. */ + expect(screen.getByTitle('Danny')).toBeInTheDocument(); expect(screen.getByText('steered words')).toBeInTheDocument(); }); diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index fae0f286d3..7de5e39185 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,10 +1,11 @@ import React, { memo } from 'react'; +import { useRecoilValue } from 'recoil'; import { UserIcon, useAvatar } from '@librechat/client'; import type { IconProps } from '~/common'; import MessageEndpointIcon from './MessageEndpointIcon'; -import { useAuthContext } from '~/hooks/AuthContext'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; +import store from '~/store'; type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' }; @@ -101,7 +102,9 @@ const UserAvatar = memo( UserAvatar.displayName = 'UserAvatar'; const Icon: React.FC = memo((props) => { - const { user } = useAuthContext(); + /** Same reason as SteerPart: this renders on the unauthenticated share route, + * where `useAuthContext` throws. The atom is the same value in the app. */ + const user = useRecoilValue(store.user); const { size = 30, isCreatedByUser } = props; const avatarSrc = useAvatar(user);