From 6fcd28137378f94cd27bbdd352f374a4a57ff487 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:53:20 +0200 Subject: [PATCH] refactor: Read Message Content Siblings Through the Parts Host --- client/src/Providers/MessagePartsHostContext.tsx | 6 ++++-- .../Chat/Messages/Content/ApprovalContext.tsx | 4 ++-- .../Chat/Messages/Content/BackgroundTaskCard.tsx | 4 ++-- .../Chat/Messages/Content/ContentParts.bench.tsx | 6 +++--- .../src/components/Chat/Messages/Content/Markdown.tsx | 9 ++++----- .../Chat/Messages/Content/MarkdownBlocks.bench.tsx | 6 +++--- .../Chat/Messages/Content/MarkdownComponents.tsx | 9 ++++----- .../Chat/Messages/Content/MessageContent.tsx | 10 ++++------ .../Messages/Content/Parts/__tests__/host.test.tsx | 1 + .../components/Chat/Messages/Content/SiblingHeader.tsx | 4 ++-- .../src/components/Chat/Messages/Content/hydration.tsx | 4 ++-- client/test/harness.tsx | 6 ++++++ packages/chat/src/components/__tests__/host.spec.tsx | 1 + packages/chat/src/react/__tests__/host.spec.tsx | 1 + packages/chat/src/react/host.tsx | 8 +++++++- 15 files changed, 46 insertions(+), 33 deletions(-) diff --git a/client/src/Providers/MessagePartsHostContext.tsx b/client/src/Providers/MessagePartsHostContext.tsx index 603da1065ce..7c7cada6379 100644 --- a/client/src/Providers/MessagePartsHostContext.tsx +++ b/client/src/Providers/MessagePartsHostContext.tsx @@ -30,8 +30,9 @@ import { showThinkingAtom } from '~/store/showThinking'; import { fontSizeAtom } from '~/store/fontSize'; function useAppMessage(): MessagePartMessage { - const { messageId, isSubmitting, isLatestMessage, nextType } = useMessageContext(); - return { messageId, isSubmitting, isLatestMessage, nextType }; + const { messageId, isSubmitting, isLatestMessage, nextType, conversationId, partIndex } = + useMessageContext(); + return { messageId, isSubmitting, isLatestMessage, nextType, conversationId, partIndex }; } function useAppAgent(agentId: string): Agent | undefined { @@ -111,6 +112,7 @@ export const appMessagePartsHost: MessagePartsHost = { useFontSize: () => useAtomValue(fontSizeAtom), useShowThinking: () => useAtomValue(showThinkingAtom), useAutoExpandTools: () => useRecoilValue(store.autoExpandTools), + useLaTeXParsing: () => useRecoilValue(store.LaTeXParsing), useUserTextPreferences: useAppUserTextPreferences, /* The atom rather than the auth context: AuthContextProvider mirrors the user into it, and the * public share route mounts outside that provider. */ diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index 950722a49a1..d5956cd84d7 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -19,9 +19,9 @@ import { } from '~/data-provider'; import { pendingApprovalActionFamily } from '~/components/Chat/approval/state'; import { askSubmitStatusAtom } from '~/components/Chat/ask/state'; +import { useChatSettings } from '~/Providers/ChatSettingsContext'; import { resolveAskUserQuestionPart } from '~/utils/approval'; import { ChatContext } from '~/Providers/ChatContext'; -import { useGetEphemeralAgent } from '~/store/agents'; /** Shared read/write for the ask-answer submit status. */ export function useAskSubmitStatus(): { @@ -428,7 +428,7 @@ export function useResumeSubmit() { const jotaiStore = useStore(); const chatContext = useContext(ChatContext); const conversation = chatContext?.conversation; - const getEphemeralAgent = useGetEphemeralAgent(); + const { getEphemeralAgent } = useChatSettings(); const approvalMutation = useSubmitToolApprovalMutation(); const askMutation = useSubmitAskAnswerMutation(); const { getDecisions, isReady, setStatus, beginToolSubmission, endToolSubmission } = diff --git a/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx b/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx index aabcb636505..dc65565a243 100644 --- a/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx +++ b/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx @@ -3,13 +3,13 @@ import { Button } from '@librechat/client'; import type { BackgroundTaskStatus, BackgroundTaskView } from './Parts/background'; import type { TranslationKeys } from '~/hooks'; import { backgroundTaskMessageKey, backgroundTaskNoteKey } from './Parts/guidance'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import { ToolIcon, getToolIconType, OutputRenderer } from './ToolOutput'; import { useSubagentTaskPanel } from '~/components/Chat/Subagents/task'; import { formatBackgroundCodeOutput } from './Parts/background'; import { parseToolName } from '~/utils/toolLabels'; import { getToolDisplayLabel, cn } from '~/utils'; import SubagentProgress from './SubagentProgress'; -import { useMessageContext } from '~/Providers'; import { useLocalize } from '~/hooks'; const STATUS: Record = { @@ -40,7 +40,7 @@ export default function BackgroundTaskCard({ mcpServerNames?: readonly string[]; }) { const localize = useLocalize(); - const { conversationId } = useMessageContext(); + const { conversationId } = useMessagePartsHost().useMessage(); const isSubagent = task.toolName === 'subagent'; const durableTask = useMemo( () => diff --git a/client/src/components/Chat/Messages/Content/ContentParts.bench.tsx b/client/src/components/Chat/Messages/Content/ContentParts.bench.tsx index b5e56dc1e14..37f1c50c72a 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.bench.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.bench.tsx @@ -1,10 +1,10 @@ import React, { Profiler } from 'react'; -import { RecoilRoot } from 'recoil'; import { act, render } from '@testing-library/react'; import { ContentTypes, Tools } from 'librechat-data-provider'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { TMessageContentParts } from 'librechat-data-provider'; import ContentParts from './ContentParts'; +import { AppStores } from 'test/harness'; /** * Render benchmark for a live agent run: one recorded-shape stream (batches of @@ -157,7 +157,7 @@ function replay(snapshots: Snapshot[]): Result { const queryClient = new QueryClient(); const tree = (content: Snapshot, isSubmitting: boolean) => ( - + { @@ -176,7 +176,7 @@ function replay(snapshots: Snapshot[]): Result { showThinking={false} /> - + ); jest.useFakeTimers(); diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 222c3d0a0d3..521549ab1e1 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -1,12 +1,10 @@ import React, { memo, useRef, useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { FADE_HYDRATION_THRESHOLD } from './animate'; -import { useMessageContext } from '~/Providers'; import MarkdownBlocks from './MarkdownBlocks'; -import store from '~/store'; type TContentProps = { content: string; @@ -14,9 +12,10 @@ type TContentProps = { }; const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) { - const { isSubmitting = false } = useMessageContext() ?? {}; + const { useMessage, useLaTeXParsing } = useMessagePartsHost(); + const { isSubmitting = false } = useMessage(); const smoothStreaming = useSmoothStreaming(); - const LaTeXParsing = useRecoilValue(store.LaTeXParsing); + const LaTeXParsing = useLaTeXParsing(); const isInitializing = content === ''; const streaming = isLatestMessage && isSubmitting; diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx index 5f7ad381cca..abde32408c1 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx @@ -1,5 +1,4 @@ import React, { Profiler } from 'react'; -import { RecoilRoot } from 'recoil'; import ReactMarkdown from 'react-markdown'; import { render } from '@testing-library/react'; import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; @@ -7,6 +6,7 @@ import { splitMarkdownIntoBlocks, splitMarkdownIntoBlocksUncached } from './spli import { ArtifactProvider, CodeBlockProvider, MessageContext } from '~/Providers'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import MarkdownBlocks from './MarkdownBlocks'; +import { AppStores } from 'test/harness'; import Markdown from './Markdown'; /** @@ -126,9 +126,9 @@ const measure = ( }; const tree = (content: string) => ( - + - + ); const { rerender, unmount } = render(tree(prefixes[0])); diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index a8910149559..9cfe33920c3 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -1,6 +1,4 @@ import React, { memo, useMemo, useRef, useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; -import { useToastContext } from '@librechat/client'; import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider'; import { handleDoubleClick, @@ -9,12 +7,12 @@ import { toAbsoluteFilePath, } from '~/utils'; import Mermaid, { MermaidErrorBoundary } from '~/components/Messages/Content/Mermaid'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import { useCodeBlockContext, useMediaContext } from '~/Providers'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import useHasAccess from '~/hooks/Roles/useHasAccess'; import { useFileDownload } from '~/data-provider'; import { useLocalize } from '~/hooks'; -import store from '~/store'; type TCodeProps = { inline?: boolean; @@ -115,8 +113,9 @@ type TAnchorProps = { }; export const a: React.ElementType = memo(function MarkdownAnchor({ href, children }: TAnchorProps) { - const user = useRecoilValue(store.user); - const { showToast } = useToastContext(); + const { useUser, useToast } = useMessagePartsHost(); + const user = useUser(); + const showToast = useToast(); const localize = useLocalize(); const { diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 9bd350920c0..5b659051cfe 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -1,15 +1,14 @@ import { memo, Suspense, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; import { Alert, DelayedRender } from '@librechat/client'; import { EmptyText, CollapsibleText } from '@librechat/chat/components'; import { Constants, parseThinkingContent } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageContentProps, TDisplayProps } from '~/common'; import AttachmentRecoveryActions from '~/components/Messages/Content/Error/recovery'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import Error from '~/components/Messages/Content/Error'; import ToolCallLimitNotice from './ToolCallLimitNotice'; -import { useMessageContext } from '~/Providers'; import MarkdownLite from './MarkdownLite'; import EditMessage from './EditMessage'; import Thinking from './Parts/Thinking'; @@ -17,7 +16,6 @@ import { useLocalize } from '~/hooks'; import Container from './Container'; import Markdown from './Markdown'; import { cn } from '~/utils'; -import store from '~/store'; const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.'; const DELAYED_ERROR_TIMEOUT = 5500; @@ -92,9 +90,9 @@ export const ErrorMessage = ({ }; export const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { - const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); - const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); - const collapseLongUserMessages = useRecoilValue(store.collapseLongUserMessages); + const { useMessage, useUserTextPreferences } = useMessagePartsHost(); + const { isSubmitting = false, isLatestMessage = false } = useMessage(); + const { enableUserMsgMarkdown, collapseLongUserMessages } = useUserTextPreferences(); const smoothStreaming = useSmoothStreaming(); // The word fade itself indicates streaming, so the trailing block cursor diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx index aaecc68ddeb..3705f276dd6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx @@ -40,6 +40,7 @@ function createHost(overrides: Partial = {}): MessagePartsHost useFontSize: () => 'text-base', useShowThinking: () => false, useAutoExpandTools: () => false, + useLaTeXParsing: () => false, useUserTextPreferences: () => ({ usernameDisplay: false, enableUserMsgMarkdown: false, diff --git a/client/src/components/Chat/Messages/Content/SiblingHeader.tsx b/client/src/components/Chat/Messages/Content/SiblingHeader.tsx index fcfdd6844f6..86a5eddb573 100644 --- a/client/src/components/Chat/Messages/Content/SiblingHeader.tsx +++ b/client/src/components/Chat/Messages/Content/SiblingHeader.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { GitBranchPlus } from 'lucide-react'; -import { useToastContext } from '@librechat/client'; import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider'; import type { TMessage, Agent } from 'librechat-data-provider'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import { useBranchMessageMutation } from '~/data-provider/Messages'; import MessageIcon from '~/components/Share/MessageIcon'; import { useAgentsMapContext } from '~/Providers'; @@ -36,7 +36,7 @@ export default function SiblingHeader({ }: SiblingHeaderProps) { const agentsMap = useAgentsMapContext(); const localize = useLocalize(); - const { showToast } = useToastContext(); + const showToast = useMessagePartsHost().useToast(); const branchMessage = useBranchMessageMutation(conversationId ?? null, { onSuccess: () => { diff --git a/client/src/components/Chat/Messages/Content/hydration.tsx b/client/src/components/Chat/Messages/Content/hydration.tsx index d91dae867fa..962dc4a710b 100644 --- a/client/src/components/Chat/Messages/Content/hydration.tsx +++ b/client/src/components/Chat/Messages/Content/hydration.tsx @@ -8,8 +8,8 @@ import { import type { FullToolCall, TMessageContentParts } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import { ToolContentRequestContext, ToolContentPendingContext } from './disclosure'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import { useToolCallPartQuery } from '~/data-provider'; -import { useMessageContext } from '~/Providers'; import { useLocalize } from '~/hooks'; type ToolCallPart = Extract; @@ -66,7 +66,7 @@ export function PreviewedToolCallPart({ children: (part: TMessageContentParts) => ReactNode; }) { const localize = useLocalize(); - const { messageId, conversationId, partIndex } = useMessageContext(); + const { messageId, conversationId, partIndex } = useMessagePartsHost().useMessage(); const [requested, setRequested] = useState(false); const request = useCallback(() => setRequested(true), []); const toolCall = part.tool_call as FullToolCall; diff --git a/client/test/harness.tsx b/client/test/harness.tsx index d8c73401ea2..8740004136f 100644 --- a/client/test/harness.tsx +++ b/client/test/harness.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { RecoilRoot } from 'recoil'; import { createStore, Provider as JotaiProvider } from 'jotai'; import type { ChatSurface } from '~/components/Chat/Subagents/surface'; import type { ReactNode } from 'react'; @@ -6,6 +7,11 @@ import { ChatSurfaceProvider } from '~/components/Chat/Subagents/surface'; export type JotaiStore = ReturnType; +/** The app's Recoil root, for renders that use the app's default hosts, which read it. */ +export function AppStores({ children }: { children: ReactNode }) { + return {children}; +} + /** The application's own defaults, so a test that does not care about a * preference reads what a reader who never changed one would see. */ export const testChatSurface = (overrides: Partial = {}): ChatSurface => ({ diff --git a/packages/chat/src/components/__tests__/host.spec.tsx b/packages/chat/src/components/__tests__/host.spec.tsx index a6edf367357..228bb480b39 100644 --- a/packages/chat/src/components/__tests__/host.spec.tsx +++ b/packages/chat/src/components/__tests__/host.spec.tsx @@ -29,6 +29,7 @@ function createHost(enableUserMsgMarkdown = false): MessagePartsHost { useFontSize: () => 'text-base', useShowThinking: () => false, useAutoExpandTools: () => false, + useLaTeXParsing: () => false, useUserTextPreferences: () => ({ usernameDisplay: false, enableUserMsgMarkdown, diff --git a/packages/chat/src/react/__tests__/host.spec.tsx b/packages/chat/src/react/__tests__/host.spec.tsx index 8865f9033cf..194a5a292b1 100644 --- a/packages/chat/src/react/__tests__/host.spec.tsx +++ b/packages/chat/src/react/__tests__/host.spec.tsx @@ -14,6 +14,7 @@ const createHost = (fontSize: string): MessagePartsHost => ({ useFontSize: () => fontSize, useShowThinking: () => false, useAutoExpandTools: () => false, + useLaTeXParsing: () => false, useUserTextPreferences: () => ({ usernameDisplay: false, enableUserMsgMarkdown: false, diff --git a/packages/chat/src/react/host.tsx b/packages/chat/src/react/host.tsx index 0a9b64d950f..58296c54ad9 100644 --- a/packages/chat/src/react/host.tsx +++ b/packages/chat/src/react/host.tsx @@ -13,6 +13,10 @@ export type MessagePartMessage = { isLatestMessage?: boolean; /** Content type of the part after this one, when known. */ nextType?: string; + /** Conversation of that message, when it has one. */ + conversationId?: string | null; + /** Position of the part in the message's content, when it renders one part. */ + partIndex?: number; }; /** How the viewer prefers user-authored text to display. */ @@ -26,7 +30,7 @@ export type MessagePartsUserTextPreferences = { }; /** The signed-in user as the parts label it; `undefined` on a public share. */ -export type MessagePartsUser = { name?: string; username?: string } | undefined; +export type MessagePartsUser = { id?: string; name?: string; username?: string } | undefined; /** Shows a transient notification. */ export type MessagePartsToast = (toast: TShowToast) => void; @@ -70,6 +74,8 @@ export interface MessagePartsHost { useShowThinking: () => boolean; /** Whether the viewer wants tool cards and tool groups expanded by default. */ useAutoExpandTools: () => boolean; + /** Whether the viewer wants LaTeX in markdown rendered as math. */ + useLaTeXParsing: () => boolean; /** How the viewer prefers user-authored text to display. */ useUserTextPreferences: () => MessagePartsUserTextPreferences; /** The signed-in user. */