🧵 refactor: Render the Package Message List by Default in ChatScreen (#17753)

* refactor: Move the Messages View Provider Into @librechat/chat

* refactor: Render the Package Message List by Default in ChatScreen

* fix: Drop the Rail's IntersectionObserver Guard for a Package Spec Stub

Nothing renders the rail without a browser: its observer starts in an effect, which never runs in a server render. The guard existed only because jsdom lacks the API, so jsdom now gets inert observers and matchMedia from the package's jest setup, and the spec's own stubs, which no longer matched the DOM lib's IntersectionObserver type, are gone.

* test: Scroll a Sidebar Row Into View Before Dragging It

A row under the fold has no pixel the mouse can press, so the drag selected text instead. Whether a run hit it depended on which earlier specs shared its shard and left chats in the Running and Finished sections.

* test: Give the Client Specs an Inert IntersectionObserver

With the rail's guard gone, a client spec that renders the message list reaches the observer jsdom lacks. The shared setup provides an inert one, as the package's jest setup does, and the child-thread spec's partial client mock gains the rem scale hook the rail reads.

* chore: Retest Against Current Dev

* test: Give the Test Messages Host the Row Hook Members

The messages host gained the assistant, added-conversation, streamed-attachment and feedback hooks when the row hooks moved into the package, so the shared test host supplies them too.
This commit is contained in:
Marco Beretta 2026-11-05 22:35:15 +01:00 • committed by GitHub
parent 1b857e46cb
commit 62cc2ffc19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 786 additions and 350 deletions

View file

@ -1,208 +1,19 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useChatContext } from './ChatContext';
interface MessagesViewContextValue {
/** Core conversation data */
conversation: ReturnType<typeof useChatContext>['conversation'];
conversationId: string | null | undefined;
readOnly: boolean;
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
/** Message operations */
ask: ReturnType<typeof useChatContext>['ask'];
regenerate: ReturnType<typeof useChatContext>['regenerate'];
handleContinue: ReturnType<typeof useChatContext>['handleContinue'];
/** Message state management */
index: ReturnType<typeof useChatContext>['index'];
latestMessageId: ReturnType<typeof useChatContext>['latestMessageId'];
latestMessageDepth: ReturnType<typeof useChatContext>['latestMessageDepth'];
getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['setMessages'];
}
type MessagesOperations = Pick<
export {
MessagesViewContext,
MessagesViewProvider,
useMessagesSubmission,
useMessagesOperations,
useMessagesViewContext,
useMessagesIsSubmitting,
useMessagesConversation,
MessagesOperationsContext,
MessagesSubmittingContext,
useIsMessagesViewReadOnly,
useOptionalMessagesOperations,
useOptionalMessagesConversation,
} from '@librechat/chat/react';
export type {
MessagesEditing,
MessagesOperations,
MessagesViewContextValue,
'ask' | 'regenerate' | 'handleContinue' | 'getMessages' | 'setMessages'
>;
/** The view's conversation with the operations an inline editor writes through. */
export type MessagesEditing = Pick<
MessagesViewContextValue,
'conversation' | 'ask' | 'getMessages' | 'setMessages'
>;
const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined);
/**
* The view's operations alone. They are referentially stable, so components that
* only call them (every message row's hover actions) are not re-rendered by the
* submission and tail changes the combined context carries on each send.
*/
const MessagesOperationsContext = createContext<MessagesOperations | undefined>(undefined);
/**
* Whether the view is generating, alone. A boolean context re-renders its consumers
* only when a send starts or settles, which is when every row's rerun controls flip.
*/
const MessagesSubmittingContext = createContext(false);
// Export the contexts so they can be provided by other providers (e.g., ShareMessagesProvider)
export { MessagesViewContext, MessagesOperationsContext, MessagesSubmittingContext };
export type { MessagesViewContextValue, MessagesOperations };
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
const chatContext = useChatContext();
const {
ask,
index,
regenerate,
isSubmitting,
conversation,
latestMessageId,
latestMessageDepth,
setAbortScroll,
handleContinue,
abortScroll,
getMessages,
setMessages,
} = chatContext;
/** Memoize conversation-related values */
const conversationValues = useMemo(
() => ({
conversation,
conversationId: conversation?.conversationId,
}),
[conversation],
);
/** Memoize submission states */
const submissionStates = useMemo(
() => ({
abortScroll,
isSubmitting,
setAbortScroll,
}),
[isSubmitting, abortScroll, setAbortScroll],
);
/** Memoize message operations (these are typically stable references) */
const messageOperations = useMemo(
() => ({
ask,
regenerate,
getMessages,
setMessages,
handleContinue,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
/** Memoize message state values */
const messageState = useMemo(
() => ({
index,
latestMessageId,
latestMessageDepth,
}),
[index, latestMessageId, latestMessageDepth],
);
/** Combine all values into final context value */
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
readOnly: false,
...conversationValues,
...submissionStates,
...messageOperations,
...messageState,
}),
[conversationValues, submissionStates, messageOperations, messageState],
);
return (
<MessagesOperationsContext.Provider value={messageOperations}>
<MessagesSubmittingContext.Provider value={isSubmitting}>
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
</MessagesSubmittingContext.Provider>
</MessagesOperationsContext.Provider>
);
}
/** Whether the view is generating; false outside a live messages view. */
export function useMessagesIsSubmitting(): boolean {
return useContext(MessagesSubmittingContext);
}
export function useMessagesViewContext() {
const context = useContext(MessagesViewContext);
if (!context) {
throw new Error('useMessagesViewContext must be used within MessagesViewProvider');
}
return context;
}
/** Defaults to read-only when no provider is present, so live auth-bearing app actions stay off. */
export function useIsMessagesViewReadOnly(): boolean {
return useContext(MessagesViewContext)?.readOnly ?? true;
}
/** Hook for components that only need conversation data */
export function useMessagesConversation() {
const { conversation, conversationId } = useMessagesViewContext();
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need submission states */
export function useMessagesSubmission() {
const { isSubmitting, abortScroll, setAbortScroll } = useMessagesViewContext();
return useMemo(
() => ({ isSubmitting, abortScroll, setAbortScroll }),
[isSubmitting, abortScroll, setAbortScroll],
);
}
/** Hook for components that only need message operations */
export function useMessagesOperations(): MessagesOperations {
const context = useContext(MessagesOperationsContext);
if (!context) {
throw new Error('useMessagesOperations must be used within MessagesViewProvider');
}
return context;
}
const NOOP_OPS: MessagesOperations = {
ask: () => {},
regenerate: () => {},
handleContinue: () => {},
getMessages: () => undefined,
setMessages: () => {},
};
/**
* Hook for components that need message operations but may render outside MessagesViewProvider
* (e.g. the /search route). Returns no-op stubs when the provider is absent — UI actions will
* be silently discarded rather than crashing. Callers must use optional chaining on
* `getMessages()` results, as it returns `undefined` outside the provider.
*/
export function useOptionalMessagesOperations(): MessagesOperations {
return useContext(MessagesOperationsContext) ?? NOOP_OPS;
}
/**
* Hook for components that need conversation data but may render outside MessagesViewProvider
* (e.g. the /search route). Returns `undefined` for both fields when the provider is absent.
*/
export function useOptionalMessagesConversation() {
const context = useContext(MessagesViewContext);
const conversation = context?.conversation;
const conversationId = context?.conversationId;
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
} from '@librechat/chat/react';

View file

@ -13,16 +13,20 @@ import {
ComposerRestoreProvider,
pendingApprovalActionFamily,
} from '@librechat/chat/react';
import type { ChatScreenMessagesSlots } from '@librechat/chat/components';
import type { TChatProject, TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { AddedChatContext, ChatFormProvider, useChatContext, useFileMapContext } from '~/Providers';
import Footer, { useLandingFooter, useConfiguredFooter } from './Footer';
import PendingSteers from './Messages/Content/Parts/PendingSteers';
import { useChatSettings } from '~/Providers/ChatSettingsContext';
import ApprovalProvider from './Messages/Content/ApprovalContext';
import ConversationStarters from './Input/ConversationStarters';
import ProjectBadge from '~/components/Projects/ProjectBadge';
import { OwnerTextProvider } from './Messages/PrivateText';
import MessagesView from './Messages/MessagesView';
import ScrollButton from './Messages/ScrollButton';
import PendingTurn from './Messages/PendingTurn';
import { FLAT_THREAD } from './Messages/Thread';
import { useAddedResponse } from '~/hooks';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -32,6 +36,14 @@ import Landing from './Landing';
import Header from './Header';
import store from '~/store';
/** The app's pieces of the package message list. */
const messagesSlots: ChatScreenMessagesSlots = {
flatThread: FLAT_THREAD,
renderPendingTurn: (props) => <PendingTurn {...props} />,
renderPendingSteers: (props) => <PendingSteers {...props} />,
renderScrollButton: (props) => <ScrollButton {...props} />,
};
type ChatViewProps = {
index?: number;
project?: TChatProject;
@ -101,7 +113,7 @@ function ChatViewPane({
<Header parentConversationId={parentConversationId} readOnly={readOnly} />
)}
renderBadge={(projectId) => <ProjectBadge projectId={projectId} />}
renderMessages={(thread) => <MessagesView {...thread} />}
messagesSlots={messagesSlots}
landing={<Landing centerFormOnLanding={centerFormOnLanding} />}
renderColumn={(column) => (
<OwnerTextProvider

View file

@ -1,54 +0,0 @@
import { useChatSurface } from '@librechat/chat/react';
import { MessageNav, MessagesView as MessagesList } from '@librechat/chat/components';
import type { TMessage } from 'librechat-data-provider';
import { MessagePartsHostProvider, appMessagePartsHost } from '~/Providers/MessagePartsHostContext';
import PendingSteers from './Content/Parts/PendingSteers';
import { MessagesViewProvider } from '~/Providers';
import ScrollButton from './ScrollButton';
import PendingTurn from './PendingTurn';
import { FLAT_THREAD } from './Thread';
function MessagesViewContent({
messagesTree,
messages,
}: {
messagesTree?: TMessage[] | null;
messages?: TMessage[] | null;
}) {
const { showScrollButton, maximizeChatSpace } = useChatSurface();
return (
<MessagesList
messagesTree={messagesTree}
messages={messages}
showScrollButton={showScrollButton}
maximizeChatSpace={maximizeChatSpace}
flatThread={FLAT_THREAD}
renderPendingTurn={(props) => <PendingTurn {...props} />}
renderPendingSteers={(props) => <PendingSteers {...props} />}
renderScrollButton={(props) => <ScrollButton {...props} />}
renderNav={({ conversationId, scrollableRef }) => (
<MessageNav
key={conversationId}
conversationId={conversationId}
scrollableRef={scrollableRef}
/>
)}
/>
);
}
export default function MessagesView({
messagesTree,
messages,
}: {
messagesTree?: TMessage[] | null;
messages?: TMessage[] | null;
}) {
return (
<MessagesViewProvider>
<MessagePartsHostProvider host={appMessagePartsHost}>
<MessagesViewContent messagesTree={messagesTree} messages={messages} />
</MessagePartsHostProvider>
</MessagesViewProvider>
);
}

View file

@ -0,0 +1,120 @@
import React, { createRef, isValidElement } from 'react';
import '@testing-library/jest-dom';
import type { ChatScreenMessagesSlots, ChatScreenProps } from '@librechat/chat/components';
import type { ReactElement, ReactNode } from 'react';
import PendingSteers from '../Messages/Content/Parts/PendingSteers';
import ScrollButton from '../Messages/ScrollButton';
import PendingTurn from '../Messages/PendingTurn';
import { FLAT_THREAD } from '../Messages/Thread';
import { render } from 'test/layout-test-utils';
import ChatView from '../ChatView';
const mockScreenProps = jest.fn<void, [ChatScreenProps]>();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ conversationId: 'convo-1' }),
}));
jest.mock('~/hooks/AuthContext', () => ({
AuthContextProvider: ({ children }: { children: React.ReactNode }) => children,
useAuthContext: () => ({ isAuthenticated: false, user: null, roles: {} }),
}));
jest.mock('~/data-provider', () => ({
useProjectQuery: () => ({ data: undefined }),
}));
jest.mock('@librechat/chat/react', () => {
const actual = jest.requireActual('@librechat/chat/react');
return {
...actual,
ChatSessionProvider: ({
children,
}: {
children: (session: { messages: null; isLoading: boolean }) => React.ReactNode;
}) => (
<actual.ChatContext.Provider value={{ conversation: null }}>
{children({ messages: null, isLoading: false })}
</actual.ChatContext.Provider>
),
};
});
/** The screen is the subject's receiver: record what ChatView hands it. */
jest.mock('@librechat/chat/components', () => ({
...jest.requireActual('@librechat/chat/components'),
ChatScreen: (props: ChatScreenProps) => {
mockScreenProps(props);
return null;
},
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useAddedResponse: () => ({}),
}));
jest.mock('../Presentation', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('../Footer', () => ({
__esModule: true,
default: () => null,
useConfiguredFooter: () => false,
useLandingFooter: () => true,
}));
const screenSlots = (): ChatScreenMessagesSlots => {
render(<ChatView />);
const props = mockScreenProps.mock.calls.at(-1)?.[0];
expect(props?.renderMessages).toBeUndefined();
return props?.messagesSlots ?? {};
};
const scrollableRef = createRef<HTMLDivElement>();
/** A slot's output as an element, failing the test when the slot rendered nothing. */
function element(node: ReactNode): ReactElement {
if (!isValidElement(node)) {
throw new Error('The slot did not render an element');
}
return node;
}
describe('ChatView message list slots', () => {
test('leaves the list to ChatScreen and fills it with the app thread mode', () => {
expect(screenSlots().flatThread).toBe(FLAT_THREAD);
});
test('renders the app pending turn, steers and scroll control with what the list passes', () => {
const slots = screenSlots();
const turn = { scrollableRef, messages: null, maximizeChatSpace: true };
const steers = { conversationId: 'convo-1', index: 0, fullWidth: false };
const scroll = {
conversationId: 'convo-1',
enabled: true,
maximizeChatSpace: false,
scrollableRef,
messagesEndRef: createRef<HTMLDivElement>(),
scrollHandler: () => undefined,
onNearBottomChange: () => undefined,
};
const turnElement = element(slots.renderPendingTurn?.(turn));
const steersElement = element(slots.renderPendingSteers?.(steers));
const scrollElement = element(slots.renderScrollButton?.(scroll));
expect(turnElement.type).toBe(PendingTurn);
expect(turnElement.props).toEqual(turn);
expect(steersElement.type).toBe(PendingSteers);
expect(steersElement.props).toEqual(steers);
expect(scrollElement.type).toBe(ScrollButton);
expect(scrollElement.props).toEqual(scroll);
});
test('leaves the navigation rail to the package list', () => {
expect(screenSlots().renderNav).toBeUndefined();
});
});

View file

@ -67,7 +67,6 @@ jest.mock('../Footer', () => ({
}));
jest.mock('../Landing', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('~/components/Lia', () => ({ __esModule: true, default: () => null }));
jest.mock('../Messages/MessagesView', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ChatForm', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {

View file

@ -1,6 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MessagesHostTestProvider } from 'test/messagesHost';
import { ChatSurfaceHarness } from 'test/harness';
import ChatView from '../ChatView';
/** The app registers its parts host at boot (App.jsx); the child's list reads it. */
import '~/Providers/MessagePartsHostContext';
let mockConversation: Record<string, unknown> | null;
@ -22,6 +26,7 @@ jest.mock('@librechat/client', () => ({
Spinner: () => <div data-testid="spinner" />,
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
useLocalize: () => (key: string) => key,
useRemScale: () => 1,
}));
jest.mock('librechat-data-provider', () => ({
@ -72,7 +77,10 @@ jest.mock('~/data-provider', () => ({
}));
jest.mock('../Input/ConversationStarters', () => () => null);
jest.mock('../Messages/MessagesView', () => () => <div data-testid="messages" />);
/** The package list renders the child's messages; the app's pieces of it are not under test. */
jest.mock('../Messages/PendingTurn', () => () => null);
jest.mock('../Messages/ScrollButton', () => () => null);
jest.mock('../Messages/Content/Parts/PendingSteers', () => () => null);
jest.mock('../Presentation', () => ({ children }: { children: React.ReactNode }) => (
<>{children}</>
));
@ -117,7 +125,13 @@ describe('ChatView child-thread execution identity', () => {
},
};
render(<ChatView />);
render(
<ChatSurfaceHarness>
<MessagesHostTestProvider>
<ChatView />
</MessagesHostTestProvider>
</ChatSurfaceHarness>,
);
expect(screen.queryByTestId('chat-form')).not.toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('com_ui_subagent_thread_read_only');

View file

@ -0,0 +1,16 @@
/** jsdom has no layout and ships no IntersectionObserver; the message rail and scroll control
* need one to mount. A spec about what they report installs its own. */
class InertIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: InertIntersectionObserver,
});

View file

@ -0,0 +1,53 @@
import React from 'react';
import { MessagesHostProvider } from '@librechat/chat/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { MessagesHost } from '@librechat/chat/react';
/** A messages host for specs whose subject is not the rows: inert scrolling, no forking,
* copying or read-aloud, and rows that render nothing. */
export const testMessagesHost: MessagesHost = {
useHour12: () => undefined,
renderFiles: () => null,
useForkPreferences: () => ({
option: '',
setOption: () => undefined,
splitAtTarget: false,
setSplitAtTarget: () => undefined,
rememberOption: false,
setRememberOption: () => undefined,
}),
useForkConversation: () => null,
useMessageCopy: () => ({ copyToClipboard: () => undefined, canCopy: false }),
useGetMessages: () => () => undefined,
useHasPendingTurn: () => false,
renderMessageAudio: () => null,
useScreenshotTarget: () => undefined,
useAutoScroll: () => false,
useConversationMessages: () => undefined,
useAssistant: () => undefined,
useGetAddedConvo: () => () => null,
useStreamedAttachments: () => undefined,
useMessageFeedback: () => () => undefined,
useSeenSource: () => ({
readReply: () => undefined,
hasReplyProof: () => false,
markSeen: () => undefined,
consumeFocusSuppression: () => false,
}),
renderMessageIcon: () => null,
renderMessageRow: () => null,
renderActivity: () => null,
useFileMap: () => undefined,
};
const queryClient = new QueryClient();
/** Mounts `testMessagesHost` for specs that render a chat pane's message list, with the query
* client its seen reporting reads. */
export function MessagesHostTestProvider({ children }: { children?: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MessagesHostProvider host={testMessagesHost}>{children}</MessagesHostProvider>
</QueryClientProvider>
);
}

View file

@ -20,6 +20,9 @@ import 'jest-canvas-mock';
// Mock ResizeObserver
import './resizeObserver.mock';
// Mock IntersectionObserver
import './intersectionObserver.mock';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,

View file

@ -127,6 +127,38 @@ test.describe('pinned drag rules', () => {
await expect.poll(() => isConversationPinned(plain)).toBe(true);
});
test('a chat below the sidebar fold can be dragged onto a pinned row @scenario:a-chat-below-the-fold-can-be-pinned-by-drag', async ({
page,
}) => {
await skipWithoutHover(page);
const [pinnedTitle] = uniqueTitles('pin-below-fold', 1);
const plainTitle = `pin-below-fold plain ${randomUUID()}`;
seededPins = await seedPinnedConversations([pinnedTitle]);
const plain = randomUUID();
await seedConversations(getE2EUser().email, [
{ conversationId: plain, title: plainTitle, updatedAt: new Date() },
]);
unpinnedConversationIds.push(plain);
await openWithPinnedSection(page);
await expect(pinnedConvoRow(page, pinnedTitle)).toBeVisible();
const plainRow = chatsListRow(page, plainTitle);
await expect(plainRow).toBeVisible();
/** Whatever else the sidebar holds, this puts the fold right above the row. */
const box = await plainRow.boundingBox();
const viewport = page.viewportSize();
if (!box || !viewport) {
throw new Error('the row and the page must both be laid out');
}
await page.setViewportSize({ width: viewport.width, height: Math.floor(box.y) - 1 });
await dragRowOnto(page, plainRow, pinnedConvoRow(page, pinnedTitle), 0.5);
await expect(pinnedConvoRow(page, plainTitle)).toBeVisible();
await expect.poll(() => isConversationPinned(plain)).toBe(true);
});
test('a pinned chat cannot be dragged through a pinned model @scenario:a-pinned-chat-cannot-be-dragged-through-a-pinned-model', async ({
page,
}) => {

View file

@ -123,6 +123,11 @@ export async function dragRowOnto(
target: Locator,
fraction: number,
): Promise<void> {
/* A row below the sidebar's fold is visible to Playwright but has no pixel the
* mouse can press: the press lands outside the page and selects text instead of
* starting a drag. A person scrolls to it first, and only as far as it takes, so
* the row they are dropping on stays on screen. */
await source.evaluate((row) => row.scrollIntoView({ block: 'nearest' }));
const from = await source.boundingBox();
const to = await target.boundingBox();
if (!from || !to) {

View file

@ -4,3 +4,29 @@ const { TextEncoder } = require('node:util');
if (typeof globalThis.TextEncoder === 'undefined') {
globalThis.TextEncoder = TextEncoder;
}
/** jsdom has no layout, so it ships none of the observers or media queries every browser has. The
* message list's rail and scroll control need them to mount; a spec about what they report
* installs its own. */
if (typeof window !== 'undefined') {
class InertObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
window.IntersectionObserver ??= InertObserver;
window.ResizeObserver ??= InertObserver;
window.matchMedia ??= (media) => ({
matches: false,
media,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
});
}

View file

@ -0,0 +1,38 @@
import type { MessagePartsHost } from '../react/host';
/** A parts host that renders nothing app-specific: no markdown, icons, files or preferences. */
export function createMessagePartsHost(
overrides: Partial<MessagePartsHost> = {},
): MessagePartsHost {
return {
useMessage: () => ({ messageId: '' }),
renderMarkdown: ({ content }) => content,
useSmoothStreaming: () => false,
useAgent: () => undefined,
renderAgentIcon: () => null,
renderAgentAvatar: () => null,
renderContentParts: () => null,
renderImage: () => null,
renderFilePreview: () => null,
useFontSize: () => 'text-base',
useShowThinking: () => false,
useAutoExpandTools: () => false,
useLaTeXParsing: () => false,
useChatDirection: () => 'auto',
useUserTextPreferences: () => ({
enableUserMsgMarkdown: false,
collapseLongUserMessages: false,
usernameDisplay: false,
}),
useUser: () => undefined,
useFileMap: () => undefined,
useToast: () => () => undefined,
useSandboxStarting: () => false,
useToolArtifactClaim: () => [null, () => undefined],
useSteerEscalating: () => false,
usePaneConversationId: () => null,
useLiveAppliedSteer: () => [false, () => undefined],
useShare: () => ({}),
...overrides,
};
}

View file

@ -200,7 +200,12 @@ export type { ChatPaneProps } from './components/pane/ChatPane';
export type { ChatPaneView, ChatPaneColumn } from './components/pane/view';
export { default as ChatScreen } from './components/pane/Screen';
export { getChatPaneChrome } from './components/pane/chrome';
export type { ChatScreenProps, ChatScreenComposerProps } from './components/pane/Screen';
export type {
ChatScreenProps,
ChatScreenComposerProps,
ChatScreenMessagesProps,
ChatScreenMessagesSlots,
} from './components/pane/Screen';
export type { ChatPaneChrome } from './components/pane/chrome';
/** Message list root: the scroll container, mount window and pending tail around the thread. */

View file

@ -14,6 +14,7 @@ import useScrollbarGutter from '../../react/messages/useScrollbarGutter';
import { useMessagesHost } from '../../react/messages/host';
import { useChatContext } from '../../react/chat/context';
import { useMessagePartsHost } from '../../react/host';
import MessageNav from './MessageNav';
import Thread from './thread/Thread';
export type { MessagesThreadProps };
@ -54,7 +55,7 @@ export type MessagesViewProps = {
}) => ReactNode;
/** Renders the scroll-to-bottom control. */
renderScrollButton?: (props: MessagesScrollButtonProps) => ReactNode;
/** Renders the in-thread navigation rail. */
/** Renders the in-thread navigation rail; the package rail by default. */
renderNav?: (props: {
conversationId?: string | null;
scrollableRef: RefObject<HTMLDivElement>;
@ -62,6 +63,13 @@ export type MessagesViewProps = {
};
const renderDefaultThread = (props: MessagesThreadProps): ReactNode => <Thread {...props} />;
/** Keyed so a warm switch releases the outgoing conversation's rail. */
const renderDefaultNav: NonNullable<MessagesViewProps['renderNav']> = ({
conversationId,
scrollableRef,
}) => (
<MessageNav key={conversationId} conversationId={conversationId} scrollableRef={scrollableRef} />
);
/**
* The message list: the scroll container, the rendered thread with its progressive mount
@ -78,7 +86,7 @@ export default function MessagesView({
renderPendingTurn,
renderPendingSteers,
renderScrollButton,
renderNav,
renderNav = renderDefaultNav,
}: MessagesViewProps): ReactElement {
const localize = useLocalize();
const host = useMessagesHost();

View file

@ -292,3 +292,38 @@ describe('MessagesView', () => {
expect(scrollable).not.toHaveClass('[overflow-anchor:none]');
});
});
describe('MessagesView default rail', () => {
const turns = (conversationId: string) =>
['one', 'two', 'three'].map((id, index) => ({
messageId: `${conversationId}-${id}`,
conversationId,
isCreatedByUser: index % 2 === 0,
text: id,
})) as TMessage[];
/** The rail builds its entries from the rendered rows. */
const renderThread: MessagesViewProps['renderThread'] = ({ messages }) => (
<ol aria-label="Thread">
{messages?.map((message) => (
<li key={message.messageId} id={message.messageId} className="message-render">
{message.text}
</li>
))}
</ol>
);
it('keeps the rail on same-chat updates and replaces it on a warm switch', async () => {
const { rerenderView } = renderView(
{ messagesTree: turns('convo-1'), messages: turns('convo-1'), renderThread },
createHost({ useConversationMessages: (id) => turns(id ?? 'convo-1') }).host,
);
const rail = await screen.findByRole('navigation');
rerenderView({ messagesTree: turns('convo-1'), messages: turns('convo-1') });
expect(screen.getByRole('navigation')).toBe(rail);
rerenderView({ messagesTree: turns('convo-2'), messages: turns('convo-2') });
expect(rail.isConnected).toBe(false);
expect(await screen.findByRole('navigation')).not.toBe(rail);
});
});

View file

@ -3,11 +3,13 @@ import { useLocalize } from '@librechat/client';
import { buildTree } from 'librechat-data-provider';
import type { TChatProject, TFile, TMessage } from 'librechat-data-provider';
import type { ReactElement, ReactNode, RefObject } from 'react';
import type { ChatScreenMessagesProps, ChatScreenMessagesSlots } from './messages';
import type { ChatPaneProps } from './ChatPane';
import type { ChatPaneChrome } from './chrome';
import { useChatContext } from '../../react/chat/context';
import { getChatPaneChrome } from './chrome';
import { getChatPaneView } from './view';
import ScreenMessages from './messages';
import ChatPane from './ChatPane';
/** What the composer slot needs from the pane's decisions. */
@ -21,6 +23,8 @@ export type ChatScreenComposerProps = {
placeholder?: string;
};
export type { ChatScreenMessagesProps, ChatScreenMessagesSlots };
export type ChatScreenProps = Pick<
ChatPaneProps,
'index' | 'landing' | 'renderColumn' | 'starters'
@ -43,11 +47,10 @@ export type ChatScreenProps = Pick<
renderHeader?: (chrome: ChatPaneChrome) => ReactNode;
/** The badge of the routed conversation's project. */
renderBadge?: (projectId: string) => ReactNode;
/** The message list, given the session's messages and the tree built from them. */
renderMessages: (props: {
messagesTree: TMessage[] | null;
messages: TMessage[] | null;
}) => ReactNode;
/** Replaces the message list; the package list, filled with `messagesSlots`, by default. */
renderMessages?: (props: ChatScreenMessagesProps) => ReactNode;
/** The slots of the default message list. */
messagesSlots?: ChatScreenMessagesSlots;
/** The composer, unless the thread is read-only. */
renderComposer?: (props: ChatScreenComposerProps) => ReactNode;
/** The footer: the full one below the column, or the configured one inside the composer band. */
@ -58,8 +61,9 @@ export type ChatScreenProps = Pick<
/**
* A chat screen: builds the message tree from the session, decides the pane's layout, heading,
* footers, project and read-only state, and lays them out in `ChatPane`. The app supplies the
* surfaces as slots; the slots that depend on a decision receive it instead of recomputing it.
* footers, project and read-only state, and lays them out in `ChatPane`. The message list is the
* package's own unless replaced; the app supplies its chrome (header, welcome screen, composer,
* footer) as slots, and the slots that depend on a decision receive it instead of recomputing it.
*/
export default function ChatScreen({
index = 0,
@ -76,6 +80,7 @@ export default function ChatScreen({
renderHeader,
renderBadge,
renderMessages,
messagesSlots,
renderComposer,
renderFooter,
renderMascot,
@ -110,7 +115,13 @@ export default function ChatScreen({
heading={chrome.title ?? localize('com_ui_new_chat')}
header={renderHeader?.(chrome)}
badge={chrome.projectId != null ? renderBadge?.(chrome.projectId) : null}
messages={renderMessages({ messagesTree, messages })}
messages={
renderMessages ? (
renderMessages({ messagesTree, messages })
) : (
<ScreenMessages messagesTree={messagesTree} messages={messages} slots={messagesSlots} />
)
}
landing={landing}
renderColumn={renderColumn}
starters={starters}

View file

@ -1,10 +1,26 @@
import React from 'react';
/**
* @jest-environment jsdom
*/
import { useEffect } from 'react';
import { Provider, createStore } from 'jotai';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ChatContext, setDefaultMessagesHost } from '@librechat/chat/react';
import type { ChatContract, MessagesHost } from '@librechat/chat/react';
import type { TMessage } from 'librechat-data-provider';
import type { ReactElement } from 'react';
import type { MessagesHost } from '../../../react/messages/host';
import type { ChatContract } from '../../../react/chat/contract';
import type { ChatScreenMessagesSlots } from '../messages';
import { createMessagePartsHost } from '../../../__fixtures__/partsHost';
import { ChatSurfaceProvider } from '../../../react/subagents/provider';
import { MessagesHostProvider } from '../../../react/messages/host';
import { MessagePartsHostProvider } from '../../../react/host';
import { ChatContext } from '../../../react/chat/context';
import { initEnglish } from '../../../__fixtures__/i18n';
import ScreenMessages from '../messages';
beforeAll(initEnglish);
/** The client config cleared mocks between tests; this suite came from there. */
beforeEach(() => jest.clearAllMocks());
let mockMessagesKey = 'convo-1';
let mockMaximizeChatSpace = false;
@ -12,70 +28,59 @@ let mockFlatThread = false;
const mockNavMount = jest.fn();
const mockNavUnmount = jest.fn();
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: () => false,
}));
/** The rail as the app mounts it: keyed to its conversation, reporting its lifetime. */
function MessageNav({ conversationId }: { conversationId?: string | null }) {
useEffect(() => {
mockNavMount();
return () => mockNavUnmount();
}, []);
return <div data-testid="message-nav" data-conversation-id={conversationId} />;
}
jest.mock('recoil', () => ({
useRecoilValue: () => false,
}));
jest.mock('~/Providers', () => ({
MessagesViewProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useFileMapContext: () => ({}),
}));
jest.mock('@librechat/chat/react', () => ({
...jest.requireActual('@librechat/chat/react'),
useChatSurface: () => ({ showScrollButton: false, maximizeChatSpace: mockMaximizeChatSpace }),
}));
jest.mock('~/store', () => ({
__esModule: true,
default: {},
}));
jest.mock('../Thread', () => ({
get FLAT_THREAD() {
return mockFlatThread;
},
}));
jest.mock('../Content/Parts/PendingSteers', () => ({
__esModule: true,
default: ({ conversationId, fullWidth }: { conversationId: string; fullWidth: boolean }) => (
/** The app's slots, the way ChatView fills them, with each piece reduced to what it was handed. */
const slots = (): ChatScreenMessagesSlots => ({
flatThread: mockFlatThread,
renderPendingTurn: () => null,
renderPendingSteers: ({ conversationId, fullWidth }) => (
<div
data-testid="pending-steers"
data-conversation-id={conversationId}
data-full-width={String(fullWidth)}
/>
),
}));
jest.mock('../PendingTurn', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('../ScrollButton', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('@librechat/chat/components', () => {
const { useEffect } = jest.requireActual('react');
return {
...jest.requireActual('@librechat/chat/components'),
MessageNav: function MessageNav({ conversationId }: { conversationId?: string | null }) {
useEffect(() => {
mockNavMount();
return () => mockNavUnmount();
}, []);
return <div data-testid="message-nav" data-conversation-id={conversationId} />;
},
};
renderScrollButton: () => null,
renderNav: ({ conversationId }) => (
<MessageNav key={conversationId} conversationId={conversationId} />
),
});
import MessagesView from '../MessagesView';
/** ChatScreen's default list, under the viewer's surface preferences. */
function MessagesView({
messagesTree,
messages,
}: {
messagesTree: TMessage[] | null;
messages: TMessage[] | null;
}) {
return (
<ChatSurfaceProvider
value={{
enterToSend: true,
maximizeChatSpace: mockMaximizeChatSpace,
showScrollButton: false,
composerBindings: {
shortcutsEnabled: true,
submitOverride: undefined,
yieldedChords: new Set<string>(),
},
claimForeground: () => undefined,
handOffComposerText: () => undefined,
}}
>
<ScreenMessages messagesTree={messagesTree} messages={messages} slots={slots()} />
</ChatSurfaceProvider>
);
}
/** Rows render as their message id, so a spec can tell which messages the thread rendered. */
const host: MessagesHost = {
@ -113,9 +118,8 @@ const host: MessagesHost = {
useFileMap: () => undefined,
};
beforeAll(() => {
setDefaultMessagesHost(host);
});
const partsHost = createMessagePartsHost();
const store = createStore();
const renderedRows = () => screen.queryAllByTestId('row').map((row) => row.textContent);
@ -124,18 +128,24 @@ const queryClient = new QueryClient();
const withChat = (ui: ReactElement) => (
<QueryClientProvider client={queryClient}>
<ChatContext.Provider
value={
{
index: 0,
isSubmitting: false,
latestMessageDepth: 0,
messagesKey: mockMessagesKey,
} as unknown as ChatContract
}
>
{ui}
</ChatContext.Provider>
<Provider store={store}>
<MessagesHostProvider host={host}>
<MessagePartsHostProvider host={partsHost}>
<ChatContext.Provider
value={
{
index: 0,
isSubmitting: false,
latestMessageDepth: 0,
messagesKey: mockMessagesKey,
} as unknown as ChatContract
}
>
{ui}
</ChatContext.Provider>
</MessagePartsHostProvider>
</MessagesHostProvider>
</Provider>
</QueryClientProvider>
);

View file

@ -0,0 +1,43 @@
import type { TMessage } from 'librechat-data-provider';
import type { ReactElement } from 'react';
import type { MessagesViewProps } from '../messages/MessagesView';
import { MessagesViewProvider } from '../../react/messages/view';
import { useChatSurface } from '../../react/subagents/surface';
import MessagesView from '../messages/MessagesView';
/** The list's slots the app fills: thread mode, pending pieces, scroll control and rail. */
export type ChatScreenMessagesSlots = Pick<
MessagesViewProps,
| 'flatThread'
| 'renderThread'
| 'renderPendingTurn'
| 'renderPendingSteers'
| 'renderScrollButton'
| 'renderNav'
>;
/** What the message list receives: the session's messages and the tree built from them. */
export type ChatScreenMessagesProps = {
messagesTree: TMessage[] | null;
messages: TMessage[] | null;
};
/** The default message list: the package list under the viewer's surface preferences. */
export default function ScreenMessages({
messagesTree,
messages,
slots,
}: ChatScreenMessagesProps & { slots?: ChatScreenMessagesSlots }): ReactElement {
const { showScrollButton, maximizeChatSpace } = useChatSurface();
return (
<MessagesViewProvider>
<MessagesView
{...slots}
messagesTree={messagesTree}
messages={messages}
showScrollButton={showScrollButton}
maximizeChatSpace={maximizeChatSpace}
/>
</MessagesViewProvider>
);
}

View file

@ -182,6 +182,27 @@ export type { TMessageAuthor } from './react/messages/author';
export { ErrorSourceProvider, useErrorSource } from './react/messages/error';
export type { ErrorSource } from './react/messages/error';
export { MessageSurfaceContext } from './react/messages/surface';
export {
MessagesViewContext,
MessagesViewProvider,
useMessagesSubmission,
useMessagesOperations,
useMessagesViewContext,
useMessagesIsSubmitting,
useMessagesConversation,
MessagesOperationsContext,
MessagesSubmittingContext,
useIsMessagesViewReadOnly,
useOptionalMessagesOperations,
useOptionalMessagesConversation,
} from './react/messages/view';
export type {
MessagesEditing,
MessagesOperations,
MessagesSubmission,
MessagesConversation,
MessagesViewContextValue,
} from './react/messages/view';
export type { MessageSurface } from './react/messages/surface';
export {
GeneratedToolLabelContext,

View file

@ -0,0 +1,228 @@
import { createContext, useContext, useMemo } from 'react';
import type { Context, ReactElement, ReactNode } from 'react';
import { useChatContext } from '../chat/context';
interface MessagesViewContextValue {
/** Core conversation data */
conversation: ReturnType<typeof useChatContext>['conversation'];
conversationId: string | null | undefined;
readOnly: boolean;
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
/** Message operations */
ask: ReturnType<typeof useChatContext>['ask'];
regenerate: ReturnType<typeof useChatContext>['regenerate'];
handleContinue: ReturnType<typeof useChatContext>['handleContinue'];
/** Message state management */
index: ReturnType<typeof useChatContext>['index'];
latestMessageId: ReturnType<typeof useChatContext>['latestMessageId'];
latestMessageDepth: ReturnType<typeof useChatContext>['latestMessageDepth'];
getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['setMessages'];
}
type MessagesOperations = Pick<
MessagesViewContextValue,
'ask' | 'regenerate' | 'handleContinue' | 'getMessages' | 'setMessages'
>;
/** The view's conversation and its id. */
export type MessagesConversation = Pick<
MessagesViewContextValue,
'conversation' | 'conversationId'
>;
/** Whether the view is generating, with its scroll-abort flag. */
export type MessagesSubmission = Pick<
MessagesViewContextValue,
'isSubmitting' | 'abortScroll' | 'setAbortScroll'
>;
/** The view's conversation with the operations an inline editor writes through. */
export type MessagesEditing = Pick<
MessagesViewContextValue,
'conversation' | 'ask' | 'getMessages' | 'setMessages'
>;
const MessagesViewContext: Context<MessagesViewContextValue | undefined> = createContext<
MessagesViewContextValue | undefined
>(undefined);
/**
* The view's operations alone. They are referentially stable, so components that
* only call them (every message row's hover actions) are not re-rendered by the
* submission and tail changes the combined context carries on each send.
*/
const MessagesOperationsContext: Context<MessagesOperations | undefined> = createContext<
MessagesOperations | undefined
>(undefined);
/**
* Whether the view is generating, alone. A boolean context re-renders its consumers
* only when a send starts or settles, which is when every row's rerun controls flip.
*/
const MessagesSubmittingContext: Context<boolean> = createContext(false);
// Export the contexts so they can be provided by other providers (e.g., ShareMessagesProvider)
export { MessagesViewContext, MessagesOperationsContext, MessagesSubmittingContext };
export type { MessagesViewContextValue, MessagesOperations };
export function MessagesViewProvider({ children }: { children: ReactNode }): ReactElement {
const chatContext = useChatContext();
const {
ask,
index,
regenerate,
isSubmitting,
conversation,
latestMessageId,
latestMessageDepth,
setAbortScroll,
handleContinue,
abortScroll,
getMessages,
setMessages,
} = chatContext;
/** Memoize conversation-related values */
const conversationValues = useMemo(
() => ({
conversation,
conversationId: conversation?.conversationId,
}),
[conversation],
);
/** Memoize submission states */
const submissionStates = useMemo(
() => ({
abortScroll,
isSubmitting,
setAbortScroll,
}),
[isSubmitting, abortScroll, setAbortScroll],
);
/** Memoize message operations (these are typically stable references) */
const messageOperations = useMemo(
() => ({
ask,
regenerate,
getMessages,
setMessages,
handleContinue,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
/** Memoize message state values */
const messageState = useMemo(
() => ({
index,
latestMessageId,
latestMessageDepth,
}),
[index, latestMessageId, latestMessageDepth],
);
/** Combine all values into final context value */
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
readOnly: false,
...conversationValues,
...submissionStates,
...messageOperations,
...messageState,
}),
[conversationValues, submissionStates, messageOperations, messageState],
);
return (
<MessagesOperationsContext.Provider value={messageOperations}>
<MessagesSubmittingContext.Provider value={isSubmitting}>
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
</MessagesSubmittingContext.Provider>
</MessagesOperationsContext.Provider>
);
}
/** Whether the view is generating; false outside a live messages view. */
export function useMessagesIsSubmitting(): boolean {
return useContext(MessagesSubmittingContext);
}
export function useMessagesViewContext(): MessagesViewContextValue {
const context = useContext(MessagesViewContext);
if (!context) {
throw new Error('useMessagesViewContext must be used within MessagesViewProvider');
}
return context;
}
/** Defaults to read-only when no provider is present, so live auth-bearing app actions stay off. */
export function useIsMessagesViewReadOnly(): boolean {
return useContext(MessagesViewContext)?.readOnly ?? true;
}
/** Hook for components that only need conversation data */
export function useMessagesConversation(): MessagesConversation {
const { conversation, conversationId } = useMessagesViewContext();
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need submission states */
export function useMessagesSubmission(): MessagesSubmission {
const { isSubmitting, abortScroll, setAbortScroll } = useMessagesViewContext();
return useMemo(
() => ({ isSubmitting, abortScroll, setAbortScroll }),
[isSubmitting, abortScroll, setAbortScroll],
);
}
/** Hook for components that only need message operations */
export function useMessagesOperations(): MessagesOperations {
const context = useContext(MessagesOperationsContext);
if (!context) {
throw new Error('useMessagesOperations must be used within MessagesViewProvider');
}
return context;
}
const NOOP_OPS: MessagesOperations = {
ask: () => {},
regenerate: () => {},
handleContinue: () => {},
getMessages: () => undefined,
setMessages: () => {},
};
/**
* Hook for components that need message operations but may render outside MessagesViewProvider
* (e.g. the /search route). Returns no-op stubs when the provider is absent, so UI actions are
* silently discarded rather than crashing. Callers must use optional chaining on
* `getMessages()` results, as it returns `undefined` outside the provider.
*/
export function useOptionalMessagesOperations(): MessagesOperations {
return useContext(MessagesOperationsContext) ?? NOOP_OPS;
}
/**
* Hook for components that need conversation data but may render outside MessagesViewProvider
* (e.g. the /search route). Returns `undefined` for both fields when the provider is absent.
*/
export function useOptionalMessagesConversation(): {
conversation: MessagesConversation['conversation'] | undefined;
conversationId: MessagesConversation['conversationId'];
} {
const context = useContext(MessagesViewContext);
const conversation = context?.conversation;
const conversationId = context?.conversationId;
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}