From 62cc2ffc19f230f35b840baeb57da9c4ca73c241 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:35:15 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B5=20refactor:=20Render=20the=20Packa?= =?UTF-8?q?ge=20Message=20List=20by=20Default=20in=20ChatScreen=20(#17753)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- client/src/Providers/MessagesViewContext.tsx | 225 ++--------------- client/src/components/Chat/ChatView.tsx | 16 +- .../components/Chat/Messages/MessagesView.tsx | 54 ----- .../Chat/__tests__/ChatView.slots.spec.tsx | 120 +++++++++ .../Chat/__tests__/ChatView.spec.tsx | 1 - .../Chat/__tests__/ChatView.subagent.spec.tsx | 18 +- client/test/intersectionObserver.mock | 16 ++ client/test/messagesHost.tsx | 53 ++++ client/test/setupTests.js | 3 + .../mock/scenarios/pinned-drag-rules.spec.ts | 32 +++ e2e/specs/mock/scenarios/pinned.helpers.ts | 5 + packages/chat/jest.polyfills.js | 26 ++ packages/chat/src/__fixtures__/partsHost.ts | 38 +++ packages/chat/src/components.ts | 7 +- .../src/components/messages/MessagesView.tsx | 12 +- .../messages/__tests__/MessagesView.spec.tsx | 35 +++ packages/chat/src/components/pane/Screen.tsx | 27 ++- .../pane/__tests__/messages.spec.tsx | 156 ++++++------ .../chat/src/components/pane/messages.tsx | 43 ++++ packages/chat/src/react.ts | 21 ++ packages/chat/src/react/messages/view.tsx | 228 ++++++++++++++++++ 21 files changed, 786 insertions(+), 350 deletions(-) delete mode 100644 client/src/components/Chat/Messages/MessagesView.tsx create mode 100644 client/src/components/Chat/__tests__/ChatView.slots.spec.tsx create mode 100644 client/test/intersectionObserver.mock create mode 100644 client/test/messagesHost.tsx create mode 100644 packages/chat/src/__fixtures__/partsHost.ts rename client/src/components/Chat/Messages/__tests__/MessagesView.spec.tsx => packages/chat/src/components/pane/__tests__/messages.spec.tsx (70%) create mode 100644 packages/chat/src/components/pane/messages.tsx create mode 100644 packages/chat/src/react/messages/view.tsx diff --git a/client/src/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index 79d9bcb12f..a4cd290e0b 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -1,208 +1,19 @@ -import React, { createContext, useContext, useMemo } from 'react'; -import { useChatContext } from './ChatContext'; - -interface MessagesViewContextValue { - /** Core conversation data */ - conversation: ReturnType['conversation']; - conversationId: string | null | undefined; - - readOnly: boolean; - - /** Submission and control states */ - isSubmitting: ReturnType['isSubmitting']; - abortScroll: ReturnType['abortScroll']; - setAbortScroll: ReturnType['setAbortScroll']; - - /** Message operations */ - ask: ReturnType['ask']; - regenerate: ReturnType['regenerate']; - handleContinue: ReturnType['handleContinue']; - - /** Message state management */ - index: ReturnType['index']; - latestMessageId: ReturnType['latestMessageId']; - latestMessageDepth: ReturnType['latestMessageDepth']; - getMessages: ReturnType['getMessages']; - setMessages: ReturnType['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(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(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( - () => ({ - readOnly: false, - ...conversationValues, - ...submissionStates, - ...messageOperations, - ...messageState, - }), - [conversationValues, submissionStates, messageOperations, messageState], - ); - - return ( - - - {children} - - - ); -} - -/** 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'; diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 61d67cd9f8..25fad7d803 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -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) => , + renderPendingSteers: (props) => , + renderScrollButton: (props) => , +}; + type ChatViewProps = { index?: number; project?: TChatProject; @@ -101,7 +113,7 @@ function ChatViewPane({
)} renderBadge={(projectId) => } - renderMessages={(thread) => } + messagesSlots={messagesSlots} landing={} renderColumn={(column) => ( } - renderPendingSteers={(props) => } - renderScrollButton={(props) => } - renderNav={({ conversationId, scrollableRef }) => ( - - )} - /> - ); -} - -export default function MessagesView({ - messagesTree, - messages, -}: { - messagesTree?: TMessage[] | null; - messages?: TMessage[] | null; -}) { - return ( - - - - - - ); -} diff --git a/client/src/components/Chat/__tests__/ChatView.slots.spec.tsx b/client/src/components/Chat/__tests__/ChatView.slots.spec.tsx new file mode 100644 index 0000000000..c262d04a59 --- /dev/null +++ b/client/src/components/Chat/__tests__/ChatView.slots.spec.tsx @@ -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(); + +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; + }) => ( + + {children({ messages: null, isLoading: false })} + + ), + }; +}); + +/** 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 }) =>
{children}
, +})); +jest.mock('../Footer', () => ({ + __esModule: true, + default: () => null, + useConfiguredFooter: () => false, + useLandingFooter: () => true, +})); + +const screenSlots = (): ChatScreenMessagesSlots => { + render(); + const props = mockScreenProps.mock.calls.at(-1)?.[0]; + expect(props?.renderMessages).toBeUndefined(); + return props?.messagesSlots ?? {}; +}; + +const scrollableRef = createRef(); + +/** 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(), + 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(); + }); +}); diff --git a/client/src/components/Chat/__tests__/ChatView.spec.tsx b/client/src/components/Chat/__tests__/ChatView.spec.tsx index a2bac59ab6..94b39afba1 100644 --- a/client/src/components/Chat/__tests__/ChatView.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.spec.tsx @@ -67,7 +67,6 @@ jest.mock('../Footer', () => ({ })); jest.mock('../Landing', () => ({ __esModule: true, default: () =>
})); jest.mock('~/components/Lia', () => ({ __esModule: true, default: () => null })); -jest.mock('../Messages/MessagesView', () => ({ __esModule: true, default: () =>
})); jest.mock('../Input/ChatForm', () => ({ __esModule: true, default: (props: Record) => { diff --git a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx index 3a81c16899..11f5e2fdf9 100644 --- a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx @@ -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 | null; @@ -22,6 +26,7 @@ jest.mock('@librechat/client', () => ({ Spinner: () =>
, cn: (...values: Array) => 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', () => () =>
); +/** 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(); + render( + + + + + , + ); expect(screen.queryByTestId('chat-form')).not.toBeInTheDocument(); expect(screen.getByRole('note')).toHaveTextContent('com_ui_subagent_thread_read_only'); diff --git a/client/test/intersectionObserver.mock b/client/test/intersectionObserver.mock new file mode 100644 index 0000000000..daf10f296d --- /dev/null +++ b/client/test/intersectionObserver.mock @@ -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, +}); diff --git a/client/test/messagesHost.tsx b/client/test/messagesHost.tsx new file mode 100644 index 0000000000..5c174d039b --- /dev/null +++ b/client/test/messagesHost.tsx @@ -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 ( + + {children} + + ); +} diff --git a/client/test/setupTests.js b/client/test/setupTests.js index 70c6abddc5..3bdea0047d 100644 --- a/client/test/setupTests.js +++ b/client/test/setupTests.js @@ -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, diff --git a/e2e/specs/mock/scenarios/pinned-drag-rules.spec.ts b/e2e/specs/mock/scenarios/pinned-drag-rules.spec.ts index fdadc8e738..43dd3901bc 100644 --- a/e2e/specs/mock/scenarios/pinned-drag-rules.spec.ts +++ b/e2e/specs/mock/scenarios/pinned-drag-rules.spec.ts @@ -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, }) => { diff --git a/e2e/specs/mock/scenarios/pinned.helpers.ts b/e2e/specs/mock/scenarios/pinned.helpers.ts index 252e66b0d4..d87a18584b 100644 --- a/e2e/specs/mock/scenarios/pinned.helpers.ts +++ b/e2e/specs/mock/scenarios/pinned.helpers.ts @@ -123,6 +123,11 @@ export async function dragRowOnto( target: Locator, fraction: number, ): Promise { + /* 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) { diff --git a/packages/chat/jest.polyfills.js b/packages/chat/jest.polyfills.js index 132109e01f..a76ead522c 100644 --- a/packages/chat/jest.polyfills.js +++ b/packages/chat/jest.polyfills.js @@ -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, + }); +} diff --git a/packages/chat/src/__fixtures__/partsHost.ts b/packages/chat/src/__fixtures__/partsHost.ts new file mode 100644 index 0000000000..03a85e29a9 --- /dev/null +++ b/packages/chat/src/__fixtures__/partsHost.ts @@ -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 { + 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, + }; +} diff --git a/packages/chat/src/components.ts b/packages/chat/src/components.ts index 7ce86645ef..01c0417059 100644 --- a/packages/chat/src/components.ts +++ b/packages/chat/src/components.ts @@ -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. */ diff --git a/packages/chat/src/components/messages/MessagesView.tsx b/packages/chat/src/components/messages/MessagesView.tsx index c358962101..ef8156de62 100644 --- a/packages/chat/src/components/messages/MessagesView.tsx +++ b/packages/chat/src/components/messages/MessagesView.tsx @@ -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; @@ -62,6 +63,13 @@ export type MessagesViewProps = { }; const renderDefaultThread = (props: MessagesThreadProps): ReactNode => ; +/** Keyed so a warm switch releases the outgoing conversation's rail. */ +const renderDefaultNav: NonNullable = ({ + conversationId, + 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(); diff --git a/packages/chat/src/components/messages/__tests__/MessagesView.spec.tsx b/packages/chat/src/components/messages/__tests__/MessagesView.spec.tsx index c2464e948a..7b42a90ad3 100644 --- a/packages/chat/src/components/messages/__tests__/MessagesView.spec.tsx +++ b/packages/chat/src/components/messages/__tests__/MessagesView.spec.tsx @@ -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 }) => ( +
    + {messages?.map((message) => ( +
  1. + {message.text} +
  2. + ))} +
+ ); + + 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); + }); +}); diff --git a/packages/chat/src/components/pane/Screen.tsx b/packages/chat/src/components/pane/Screen.tsx index 33d944d11d..c21e506d30 100644 --- a/packages/chat/src/components/pane/Screen.tsx +++ b/packages/chat/src/components/pane/Screen.tsx @@ -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 }) + ) : ( + + ) + } landing={landing} renderColumn={renderColumn} starters={starters} diff --git a/client/src/components/Chat/Messages/__tests__/MessagesView.spec.tsx b/packages/chat/src/components/pane/__tests__/messages.spec.tsx similarity index 70% rename from client/src/components/Chat/Messages/__tests__/MessagesView.spec.tsx rename to packages/chat/src/components/pane/__tests__/messages.spec.tsx index 03deb8e2e2..bca8eec282 100644 --- a/client/src/components/Chat/Messages/__tests__/MessagesView.spec.tsx +++ b/packages/chat/src/components/pane/__tests__/messages.spec.tsx @@ -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
; +} -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 }) => (
), -})); - -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
; - }, - }; + renderScrollButton: () => null, + renderNav: ({ 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 ( + (), + }, + claimForeground: () => undefined, + handOffComposerText: () => undefined, + }} + > + + + ); +} /** 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) => ( - - {ui} - + + + + + {ui} + + + + ); diff --git a/packages/chat/src/components/pane/messages.tsx b/packages/chat/src/components/pane/messages.tsx new file mode 100644 index 0000000000..3388b6a100 --- /dev/null +++ b/packages/chat/src/components/pane/messages.tsx @@ -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 ( + + + + ); +} diff --git a/packages/chat/src/react.ts b/packages/chat/src/react.ts index 2b8ff2c456..242ebb0d27 100644 --- a/packages/chat/src/react.ts +++ b/packages/chat/src/react.ts @@ -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, diff --git a/packages/chat/src/react/messages/view.tsx b/packages/chat/src/react/messages/view.tsx new file mode 100644 index 0000000000..cb92e52983 --- /dev/null +++ b/packages/chat/src/react/messages/view.tsx @@ -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['conversation']; + conversationId: string | null | undefined; + + readOnly: boolean; + + /** Submission and control states */ + isSubmitting: ReturnType['isSubmitting']; + abortScroll: ReturnType['abortScroll']; + setAbortScroll: ReturnType['setAbortScroll']; + + /** Message operations */ + ask: ReturnType['ask']; + regenerate: ReturnType['regenerate']; + handleContinue: ReturnType['handleContinue']; + + /** Message state management */ + index: ReturnType['index']; + latestMessageId: ReturnType['latestMessageId']; + latestMessageDepth: ReturnType['latestMessageDepth']; + getMessages: ReturnType['getMessages']; + setMessages: ReturnType['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 = 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 = 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 = 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( + () => ({ + readOnly: false, + ...conversationValues, + ...submissionStates, + ...messageOperations, + ...messageState, + }), + [conversationValues, submissionStates, messageOperations, messageState], + ); + + return ( + + + {children} + + + ); +} + +/** 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]); +}