From 4bd832a1e0b144918d91c0b6b50c95c805b2aa13 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:37 +0200 Subject: [PATCH 1/3] refactor: Supply App-Global Chat Settings From the Host The chat hooks read the during-run default, steer interrupts, save drafts, temporary chat and the artifacts reset from a ChatSettings context that Root fills from the existing Recoil atoms. liveAppliedSteerIds moves to Jotai with its reader and writer, and manual compaction submits through the useChat facade. --- client/src/Providers/ChatSettingsContext.tsx | 36 ++++++++++ .../Chat/Messages/Content/Parts/SteerPart.tsx | 8 ++- .../Parts/__tests__/SteerPart.test.tsx | 54 +++++++-------- .../src/hooks/Chat/__tests__/facade.spec.tsx | 68 +++++++++++++++++-- .../__tests__/useCompactConversation.spec.tsx | 35 +++++++--- .../hooks/Chat/__tests__/useNewChat.spec.ts | 7 +- .../hooks/Chat/__tests__/useSteering.spec.tsx | 3 +- client/src/hooks/Chat/facade.ts | 30 ++++++-- client/src/hooks/Chat/index.ts | 1 - client/src/hooks/Chat/useChatFunctions.ts | 3 +- .../src/hooks/Chat/useCompactConversation.ts | 11 +-- client/src/hooks/Chat/useIdChangeEffect.ts | 5 +- client/src/hooks/Chat/useNewChat.ts | 3 +- client/src/hooks/Chat/useSteering.ts | 13 ++-- client/src/hooks/SSE/useResumableSSE.ts | 5 +- client/src/routes/ChatSettings.tsx | 38 +++++++++++ client/src/routes/Root.tsx | 11 ++- .../routes/__tests__/ChatSettings.spec.tsx | 65 ++++++++++++++++++ client/src/store/families.ts | 24 ------- client/src/store/steer.ts | 15 ++++ 20 files changed, 340 insertions(+), 95 deletions(-) create mode 100644 client/src/Providers/ChatSettingsContext.tsx create mode 100644 client/src/routes/ChatSettings.tsx create mode 100644 client/src/routes/__tests__/ChatSettings.spec.tsx diff --git a/client/src/Providers/ChatSettingsContext.tsx b/client/src/Providers/ChatSettingsContext.tsx new file mode 100644 index 00000000000..42777b86e37 --- /dev/null +++ b/client/src/Providers/ChatSettingsContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext } from 'react'; + +/** Composer action while a run is in flight: fold the text into the run, or queue a new turn. */ +export type DuringRunAction = 'steer' | 'queue'; + +/** + * App-global preferences the chat reads but does not own. The host supplies them, so the chat + * hooks never reach into the app's state store for shell settings. + */ +export type ChatSettings = { + /** Default composer action while a run is in flight. */ + duringRunDefaultAction: DuringRunAction; + setDuringRunDefaultAction: (action: DuringRunAction) => void; + /** Whether a steer interrupts the running step instead of waiting for the next one. */ + steerInterruptsByDefault: boolean; + /** Whether composer drafts persist across conversations. */ + saveDrafts: boolean; + /** Whether new turns are sent as a temporary (unsaved) chat. */ + isTemporary: boolean; + /** Closes the artifacts panel, called when the active conversation changes. */ + resetVisibleArtifacts: () => void; +}; + +/** Stock values, used when no host supplies settings (isolated renders and tests). */ +export const defaultChatSettings: ChatSettings = { + duringRunDefaultAction: 'steer', + setDuringRunDefaultAction: () => undefined, + steerInterruptsByDefault: false, + saveDrafts: true, + isTemporary: false, + resetVisibleArtifacts: () => undefined, +}; + +export const ChatSettingsContext = createContext(defaultChatSettings); + +export const useChatSettings = () => useContext(ChatSettingsContext); diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index 1fb2fe72329..23a99c3f552 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -1,8 +1,10 @@ import { memo, useMemo, useState, useEffect, useCallback } from 'react'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; +import { useAtomValue, useSetAtom } from 'jotai'; import type { TFile, TMessage } from 'librechat-data-provider'; import SteerReceipt, { type SteerReceiptState } from '~/components/Chat/Steering/Receipt'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; +import { liveAppliedSteerFamily, liveAppliedSteerIdsAtom } from '~/store/steer'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import MessageQuotes from '~/components/Chat/Messages/Content/MessageQuotes'; import { cn, hydrateFileDeliveryMetadata, usesImagePreview } from '~/utils'; @@ -95,8 +97,8 @@ const SteerPart = memo(function SteerPart({ * identity consumes its id whether it animated or not, so nothing lingers. * The membership selector scopes the subscription to THIS id — stamping or * consuming one steer never re-renders the other mounted parts. */ - const isLiveApplied = useRecoilValue(store.liveAppliedSteerFamily(steerId ?? '')); - const setLiveAppliedIds = useSetRecoilState(store.liveAppliedSteerIds); + const isLiveApplied = useAtomValue(liveAppliedSteerFamily(steerId ?? '')); + const setLiveAppliedIds = useSetAtom(liveAppliedSteerIdsAtom); const [captured, setCaptured] = useState<{ id: string | undefined; animate: boolean }>({ id: steerId, animate: isLiveApplied, diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx index 25b6a15dbca..b47c6c8dca7 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; +import { RecoilRoot } from 'recoil'; import { QueryKeys } from 'librechat-data-provider'; +import { Provider, createStore, useAtomValue } from 'jotai'; import { render, screen, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { TMessage } from 'librechat-data-provider'; import { STEER_ICON } from '~/components/Chat/Steering/identity'; +import { liveAppliedSteerIdsAtom } from '~/store/steer'; import SteerPart from '../SteerPart'; import store from '~/store'; @@ -243,21 +245,24 @@ describe('SteerPart presentation', () => { describe('SteerPart live receipt draw-in', () => { function LiveIdsProbe() { - const ids = useRecoilValue(store.liveAppliedSteerIds); + const ids = useAtomValue(liveAppliedSteerIdsAtom); return
{ids.join(',')}
; } + function seededStore(liveIds: string[]) { + const jotaiStore = createStore(); + jotaiStore.set(liveAppliedSteerIdsAtom, liveIds); + return jotaiStore; + } + function renderLive(liveIds: string[]) { return render( - { - set(store.user, SEEDED_USER as never); - set(store.liveAppliedSteerIds, liveIds); - }} - > - - - , + + set(store.user, SEEDED_USER as never)}> + + + + , ); } @@ -286,28 +291,23 @@ describe('SteerPart live receipt draw-in', () => { ); + const jotaiStore = seededStore(['s2']); const { rerender } = render( - { - set(store.user, SEEDED_USER as never); - set(store.liveAppliedSteerIds, ['s2']); - }} - > - {partFor('s1')} - , + + set(store.user, SEEDED_USER as never)}> + {partFor('s1')} + + , ); expect(appliedChecks()).not.toHaveClass('animate-in'); expect(screen.getByTestId('live-ids')).toHaveTextContent('s2'); rerender( - { - set(store.user, SEEDED_USER as never); - set(store.liveAppliedSteerIds, ['s2']); - }} - > - {partFor('s2')} - , + + set(store.user, SEEDED_USER as never)}> + {partFor('s2')} + + , ); expect(appliedChecks()).toHaveClass('animate-in'); expect(screen.getByTestId('live-ids').textContent).toBe(''); diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx index 4b19e0e44df..ed923d5b4a4 100644 --- a/client/src/hooks/Chat/__tests__/facade.spec.tsx +++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react'; +import { act, render, renderHook } from '@testing-library/react'; import { QueryKeys, ContentTypes } from 'librechat-data-provider'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { ChatContract } from '../contract'; import { ChatContext } from '~/Providers/ChatContext'; @@ -65,6 +65,9 @@ const createContract = (overrides: Partial = {}): ChatContract => }; }; +/** The facade delivers cache writes on a microtask, outside the render that caused them. */ +const flushCacheNotify = () => Promise.resolve(); + /** Renders `useChat` under the real `ChatContext`; `rerender` swaps the contract value. */ const renderChat = (initial: ChatContract) => { let contract = initial; @@ -224,7 +227,7 @@ describe('useChat', () => { expect(result.current.messages).toHaveLength(2); }); - it('re-reads messages when the message cache is written', () => { + it('re-reads messages when the message cache is written', async () => { let messages: TMessage[] = [userMessage, response({ text: 'Old' })]; const contract = createContract({ getMessages: jest.fn(() => messages), @@ -236,11 +239,12 @@ describe('useChat', () => { queryClient.setQueryData([QueryKeys.messages, 'convo-1'], next); }); - act(() => { + await act(async () => { result.current.setMessages((views) => [ { ...views[0], parts: [{ type: 'text', text: 'Edited' }] }, views[1], ]); + await flushCacheNotify(); }); expect(result.current.messages[0].parts).toEqual([{ type: 'text', text: 'Edited' }]); @@ -274,7 +278,7 @@ describe('useChat', () => { expect(result.current.messages[1].parts).toEqual([{ type: 'text', text: 'Hello' }]); }); - it('follows each stream frame written to the cache with the same message references', () => { + it('follows each stream frame written to the cache with the same message references', async () => { const key = [QueryKeys.messages, 'convo-1']; const streaming = response({ content: [{ type: ContentTypes.TEXT, text: 'Hel' }] }); const queryClient = new QueryClient(); @@ -292,9 +296,10 @@ describe('useChat', () => { const { result } = renderHook(() => useChat(), { wrapper }); const before = queryClient.getQueryData(key); - act(() => { + await act(async () => { streaming.content = [{ type: ContentTypes.TEXT, text: 'Hello' }]; queryClient.setQueryData(key, [userMessage, streaming]); + await flushCacheNotify(); }); expect(queryClient.getQueryData(key)).toBe(before); @@ -302,15 +307,64 @@ describe('useChat', () => { expect(result.current.status).toBe('streaming'); const findAll = jest.spyOn(queryClient.getQueryCache(), 'findAll'); - act(() => { + await act(async () => { streaming.content = [{ type: ContentTypes.TEXT, text: 'Hello there' }]; queryClient.setQueryData(key, [userMessage, streaming]); + await flushCacheNotify(); }); expect(result.current.messages[1].parts).toEqual([{ type: 'text', text: 'Hello there' }]); expect(findAll).not.toHaveBeenCalled(); }); + it('never updates while another component mounts a query on its messages', async () => { + const key = [QueryKeys.messages, 'convo-1']; + const queryClient = new QueryClient(); + const contract = createContract({ + getMessages: jest.fn(() => queryClient.getQueryData(key)), + }); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + /** Mounting an observer with initial data creates and fills the query inside this render. */ + const Reader = () => { + useQuery({ queryKey: key, queryFn: () => [userMessage], initialData: [userMessage] }); + return null; + }; + const Probe = ({ withReader }: { withReader: boolean }) => { + const { messages } = useChat(); + return ( + <> + {messages.length} + {withReader && } + + ); + }; + const view = render( + + + + + , + ); + + await act(async () => { + view.rerender( + + + + + , + ); + await flushCacheNotify(); + }); + + expect(view.getByTestId('count')).toHaveTextContent('1'); + const renderPhaseUpdates = consoleError.mock.calls.filter(([message]) => + String(message).includes('Cannot update a component'), + ); + consoleError.mockRestore(); + expect(renderPhaseUpdates).toEqual([]); + }); + it('keeps its messages when another conversation is written', () => { const key = [QueryKeys.messages, 'convo-1']; const queryClient = new QueryClient(); diff --git a/client/src/hooks/Chat/__tests__/useCompactConversation.spec.tsx b/client/src/hooks/Chat/__tests__/useCompactConversation.spec.tsx index 72d74da27be..aef6500bc89 100644 --- a/client/src/hooks/Chat/__tests__/useCompactConversation.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useCompactConversation.spec.tsx @@ -1,10 +1,14 @@ +import React from 'react'; import { getDefaultStore } from 'jotai'; import { ContentTypes } from 'librechat-data-provider'; import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { TMessage } from 'librechat-data-provider'; import useCompactConversation, { compactingConversationAtom } from '../useCompactConversation'; const mockAsk = jest.fn(); +/** The message cache hands back one array until it is written, as React Query does. */ +const mockStoredMessages: TMessage[] = []; let mockContext: { index: number; isSubmitting: boolean; @@ -12,8 +16,18 @@ let mockContext: { }; let mockLatestMessage: TMessage | null; -jest.mock('~/Providers', () => ({ - useChatContext: () => ({ ...mockContext, ask: mockAsk }), +/** The hook reads status and submits through the real `useChat` facade over this contract. */ +jest.mock('~/Providers/ChatContext', () => ({ + useChatContext: () => ({ + ...mockContext, + ask: mockAsk, + getMessages: () => mockStoredMessages, + messagesKey: mockContext.conversation?.conversationId ?? '', + setMessages: jest.fn(), + latestMessageId: undefined, + regenerate: jest.fn(), + stopGenerating: jest.fn(), + }), })); jest.mock('~/hooks/Messages/useLatestMessage', () => ({ useLatestMessage: () => mockLatestMessage, @@ -39,6 +53,11 @@ const leaf = (overrides: Partial = {}): TMessage => ...overrides, }) as TMessage; +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + describe('useCompactConversation', () => { beforeEach(() => { mockAsk.mockClear(); @@ -53,7 +72,7 @@ describe('useCompactConversation', () => { it('drops a marker left by a compaction that finished while the view was away', () => { getDefaultStore().set(compactingConversationAtom, 'convo-1'); - const hook = renderHook(() => useCompactConversation()); + const hook = renderHook(() => useCompactConversation(), { wrapper }); expect(hook.result.current.isCompacting).toBe(false); /** The next ordinary turn in the same conversation is not a compaction. */ @@ -65,7 +84,7 @@ describe('useCompactConversation', () => { it('keeps the marker when it mounts into a compaction still streaming', () => { getDefaultStore().set(compactingConversationAtom, 'convo-1'); mockContext.isSubmitting = true; - const hook = renderHook(() => useCompactConversation()); + const hook = renderHook(() => useCompactConversation(), { wrapper }); expect(hook.result.current.isCompacting).toBe(true); mockContext.isSubmitting = false; @@ -75,7 +94,7 @@ describe('useCompactConversation', () => { }); it('submits a compaction anchored on the leaf itself', () => { - const { result } = renderHook(() => useCompactConversation()); + const { result } = renderHook(() => useCompactConversation(), { wrapper }); expect(result.current.canCompact).toBe(true); act(() => result.current.compact()); @@ -101,7 +120,7 @@ describe('useCompactConversation', () => { ], ])('cannot compact with %s', (_label, arrange) => { arrange(); - const { result } = renderHook(() => useCompactConversation()); + const { result } = renderHook(() => useCompactConversation(), { wrapper }); expect(result.current.canCompact).toBe(false); act(() => result.current.compact()); @@ -114,13 +133,13 @@ describe('useCompactConversation', () => { ['stored without a boundary', summaryPart({ boundary: undefined })], ])('lets an interrupted compaction (summary %s) be retried', (_label, part) => { mockLatestMessage = leaf({ text: '', content: [part] }); - const { result } = renderHook(() => useCompactConversation()); + const { result } = renderHook(() => useCompactConversation(), { wrapper }); expect(result.current.canCompact).toBe(true); }); it('reports compacting only for the conversation it submitted, until the turn settles', () => { - const hook = renderHook(() => useCompactConversation()); + const hook = renderHook(() => useCompactConversation(), { wrapper }); act(() => hook.result.current.compact()); mockContext.isSubmitting = true; diff --git a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts index 82ad2f879d9..4b6c543d4c4 100644 --- a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts +++ b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts @@ -67,10 +67,14 @@ jest.mock('recoil', () => ({ if (typeof key === 'string' && key.startsWith('isSubmitting')) { return mockState.isSubmitting; } - return mockState.saveDrafts; + return undefined; }, })); +jest.mock('~/Providers/ChatSettingsContext', () => ({ + useChatSettings: () => ({ saveDrafts: mockState.saveDrafts }), +})); + jest.mock('~/hooks/useNewConvo', () => ({ __esModule: true, default: () => ({ newConversation: mockNewConversation }), @@ -208,7 +212,6 @@ jest.mock('~/store', () => ({ conversationIdByIndex: (index: number) => `conversationIdByIndex-${index}`, filesByIndex: (index: number) => `files-by-index-${index}`, isSubmittingFamily: (index: number) => `isSubmitting-${index}`, - saveDrafts: 'saveDrafts', }, })); diff --git a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx index 673049636da..b99d3b24fe9 100644 --- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx @@ -22,6 +22,7 @@ import { clearAllDrafts, getPendingDraftId, getNewConversationDraftId } from '~/ import { recoveryDispositionsFamily } from '~/components/Chat/Steering/recovery'; import { claimQueuedIntent, releaseQueuedIntent } from '~/utils/queueIntent'; import useUpdateFiles from '~/hooks/Files/useUpdateFiles'; +import ChatSettingsProvider from '~/routes/ChatSettings'; import { applyPendingAction } from '~/utils/approval'; import useQueueDrain from '../useQueueDrain'; import store from '~/store'; @@ -134,7 +135,7 @@ function setup(params: HookParams = {}, initialize?: (snapshot: MutableSnapshot) initializeState={withActiveGeneration(initialize, params.conversationId ?? CONVO_ID)} > - {children} + {children} ); const rendered = renderHook( diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts index 5928a61ba14..7a0e1c4fd78 100644 --- a/client/src/hooks/Chat/facade.ts +++ b/client/src/hooks/Chat/facade.ts @@ -8,6 +8,7 @@ import { toUIMessage, } from 'librechat-data-provider'; import type { TMessage, UIMessage, TAttachment, UIMappingOptions } from 'librechat-data-provider'; +import type { QueryCacheNotifyEvent } from '@tanstack/react-query'; import type { TAskFunction } from '~/common'; import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/MemoryCall'; import { getToolMeta } from '~/components/Chat/Messages/Content/outcome'; @@ -225,6 +226,9 @@ export const getChatStatus = (isSubmitting: boolean, latest: TMessage | undefine return latest && isErrorMessage(latest) ? 'error' : 'ready'; }; +/** Cache events that add, drop or rewrite a query's data. */ +const dataEvents = new Set(['added', 'removed', 'updated']); + /** Ids on the active branch: the contract's tail and its ancestors. */ const getActiveBranch = (byId: Map, tailId: string | undefined) => { const branch = new Set(); @@ -256,13 +260,29 @@ export function useChat(): UseChatHelpers { const queryClient = useQueryClient(); const queryHash = useMemo(() => hashQueryKey([QueryKeys.messages, messagesKey]), [messagesKey]); + /** + * Only events that can change what `readSnapshot` reads. Observers mount inside other + * components' renders and the cache notifies synchronously, so the change is delivered after + * the current task instead of inside someone else's render. + */ const subscribe = useCallback( - (onChange: () => void) => - queryClient.getQueryCache().subscribe((event) => { - if (event.query.queryHash === queryHash) { - onChange(); + (onChange: () => void) => { + let active = true; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (event.query.queryHash !== queryHash || !dataEvents.has(event.type)) { + return; } - }), + queueMicrotask(() => { + if (active) { + onChange(); + } + }); + }); + return () => { + active = false; + unsubscribe(); + }; + }, [queryClient, queryHash], ); const snapshot = useRef<{ writes: number; stored?: TMessage[] }>(); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts index c6a7492b585..49afa243337 100644 --- a/client/src/hooks/Chat/index.ts +++ b/client/src/hooks/Chat/index.ts @@ -6,7 +6,6 @@ export { default as useTokenLimits } from './useTokenLimits'; export { default as useTokenUsage } from './useTokenUsage'; export { default as useAddedResponse } from './useAddedResponse'; export { default as useChatFunctions } from './useChatFunctions'; -export { default as useCompactConversation } from './useCompactConversation'; export { default as useGetAddedConvo } from './useGetAddedConvo'; export { default as useIdChangeEffect } from './useIdChangeEffect'; export { default as useFocusChatEffect } from './useFocusChatEffect'; diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index ee2d349ced7..ec79a1a5a74 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -52,6 +52,7 @@ import { withSubmittedCodeDecision } from '~/hooks/Agents/codeDecision'; import useCodeApprovalMode from '~/hooks/Agents/useCodeApprovalMode'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; +import { useChatSettings } from '~/Providers/ChatSettingsContext'; import useCodeWorkspace from '~/hooks/Agents/useCodeWorkspace'; import useGetSender from '~/hooks/Conversations/useGetSender'; import { activeUsageResponseIdFamily } from '~/store/usage'; @@ -241,7 +242,7 @@ export default function useChatFunctions({ const setFilesToDelete = useSetFilesToDelete(); const getEphemeralAgent = useGetEphemeralAgent(); const agentsMap = useAgentsMapContext(); - const isTemporary = useRecoilValue(store.isTemporary); + const { isTemporary } = useChatSettings(); const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? ''); const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index)); const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(index)); diff --git a/client/src/hooks/Chat/useCompactConversation.ts b/client/src/hooks/Chat/useCompactConversation.ts index 332a94cce17..03051876828 100644 --- a/client/src/hooks/Chat/useCompactConversation.ts +++ b/client/src/hooks/Chat/useCompactConversation.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef } from 'react'; import { atom, useAtom } from 'jotai'; import { Constants, isCompactedLeaf, isAssistantsEndpoint } from 'librechat-data-provider'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; -import { useChatContext } from '~/Providers'; +import { useChatContext } from '~/Providers/ChatContext'; +import { useChat } from './facade'; /** Conversation whose compaction this client submitted and is still streaming. */ export const compactingConversationAtom = atom(null); @@ -21,7 +22,9 @@ export const supportsCompaction = (endpoint?: string | null): boolean => * and is persisted as the boundary every later turn starts from. */ export default function useCompactConversation() { - const { ask, index, conversation, isSubmitting } = useChatContext(); + const { index, conversation } = useChatContext(); + const { sendMessage, status } = useChat(); + const isSubmitting = status === 'submitted' || status === 'streaming'; const latestMessage = useLatestMessage(index); const [compactingConversation, setCompactingConversation] = useAtom(compactingConversationAtom); @@ -61,7 +64,7 @@ export default function useCompactConversation() { setCompactingConversation(conversationId); /** The leaf is both the placeholder's parent and the server-side anchor * (`parentMessageId` is what the controller compacts up to). */ - const accepted = ask( + const accepted = sendMessage( { text: '', conversationId, @@ -73,7 +76,7 @@ export default function useCompactConversation() { if (accepted === false) { setCompactingConversation(null); } - }, [ask, canCompact, conversationId, latestMessage, setCompactingConversation]); + }, [sendMessage, canCompact, conversationId, latestMessage, setCompactingConversation]); return { compact, canCompact, isCompacting }; } diff --git a/client/src/hooks/Chat/useIdChangeEffect.ts b/client/src/hooks/Chat/useIdChangeEffect.ts index 7d0378915d3..d36d134d64c 100644 --- a/client/src/hooks/Chat/useIdChangeEffect.ts +++ b/client/src/hooks/Chat/useIdChangeEffect.ts @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react'; -import { useResetRecoilState } from 'recoil'; +import { useChatSettings } from '~/Providers/ChatSettingsContext'; import { logger } from '~/utils'; -import store from '~/store'; /** * Hook to reset visible artifacts when the conversation ID changes @@ -9,7 +8,7 @@ import store from '~/store'; */ export default function useIdChangeEffect(conversationId: string) { const lastConvoId = useRef(null); - const resetVisibleArtifacts = useResetRecoilState(store.visibleArtifacts); + const { resetVisibleArtifacts } = useChatSettings(); useEffect(() => { if (conversationId !== lastConvoId.current) { diff --git a/client/src/hooks/Chat/useNewChat.ts b/client/src/hooks/Chat/useNewChat.ts index 4f109400de8..94a041f570e 100644 --- a/client/src/hooks/Chat/useNewChat.ts +++ b/client/src/hooks/Chat/useNewChat.ts @@ -32,6 +32,7 @@ import { takeRetainedFileDeletions, } from '~/utils'; import { useGetFiles, useDeleteFilesMutation } from '~/data-provider'; +import { useChatSettings } from '~/Providers/ChatSettingsContext'; import useNewConvo from '~/hooks/useNewConvo'; import store from '~/store'; @@ -119,7 +120,7 @@ export default function useNewChat({ const { newConversation } = useNewConvo(index); const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const files = useRecoilValue(store.filesByIndex(index)); - const saveDrafts = useRecoilValue(store.saveDrafts); + const { saveDrafts } = useChatSettings(); const { data: fileList } = useGetFiles(); const { mutateAsync } = useDeleteFilesMutation(); /** The cleanup pass runs on a timer nobody asked for, so it reports nothing: a storage failure diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts index a83c74fe4c1..bbd919f17b5 100644 --- a/client/src/hooks/Chat/useSteering.ts +++ b/client/src/hooks/Chat/useSteering.ts @@ -26,6 +26,7 @@ import type { SettledQueuedTurnReceipt, } from '~/store/families'; import type { AgentQueuedTurnReceipt, GenerationProtocolVersion } from '~/data-provider'; +import type { DuringRunAction } from '~/Providers/ChatSettingsContext'; import type { QueueSendLock } from '~/utils/queueIntent'; import type { ExtendedFile, FileSetter } from '~/common'; import { @@ -60,6 +61,7 @@ import { } from '~/components/Chat/Input/Composer/state'; import { hasQueuedIntent, acquireQueueSendLock, releaseQueueSendLock } from '~/utils/queueIntent'; import { revealedQueuedTurnFamily, pendingSteerCancelClientIdsFamily } from '~/store/steer'; +import { useChatSettings } from '~/Providers/ChatSettingsContext'; import useCodeApprovalMode from '../Agents/useCodeApprovalMode'; import { markComposerFilesTaken } from '~/utils/composerFiles'; import { useSteerRehome } from '~/hooks/Chat/useSteerCancel'; @@ -71,8 +73,7 @@ import { useFileMapContext } from '~/Providers'; import useLocalize from '~/hooks/useLocalize'; import store from '~/store'; -/** During-run submit routes: inject into the live run, or queue for after it. */ -export type DuringRunAction = 'steer' | 'queue'; +export type { DuringRunAction }; /** Composer state consumed into a queued item alongside the text. */ export interface QueuedMessageContext { @@ -595,9 +596,11 @@ export default function useSteering({ const { mutate: markFilesUsage } = useMarkFilesUsageMutation(); const { mutate: enqueueAgentQueuedTurn } = useEnqueueAgentQueuedTurnMutation(); const { mutateAsync: cancelAgentQueuedTurn } = useCancelAgentQueuedTurnMutation(); - const defaultAction = useRecoilValue(store.duringRunDefaultAction); - const setDefaultAction = useSetRecoilState(store.duringRunDefaultAction); - const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault); + const { + duringRunDefaultAction: defaultAction, + setDuringRunDefaultAction: setDefaultAction, + steerInterruptsByDefault, + } = useChatSettings(); const { selected: codeApprovalMode } = useCodeApprovalMode(conversation, addedConversation); const endpoint = conversation?.endpointType ?? conversation?.endpoint; diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index a10a4dfca52..723513b8e03 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { v4 } from 'uuid'; -import { useStore } from 'jotai'; +import { useStore, useSetAtom } from 'jotai'; import { useQueryClient } from '@tanstack/react-query'; import { useSetRecoilState, useRecoilCallback } from 'recoil'; import { @@ -97,6 +97,7 @@ import useEventHandlers, { } from './useEventHandlers'; import { pendingApprovalActionFamily } from '~/components/Chat/approval/state'; import useSteerConvert from '~/hooks/Chat/useSteerConvert'; +import { liveAppliedSteerIdsAtom } from '~/store/steer'; import { useAuthContext } from '~/hooks/AuthContext'; import { createSSETransport } from './transport'; import { useFileMapContext } from '~/Providers'; @@ -965,7 +966,7 @@ export default function useResumableSSE( const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex)); const setSubmission = useSetRecoilState(store.submissionByIndex(runIndex)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(runIndex)); - const setLiveAppliedSteerIds = useSetRecoilState(store.liveAppliedSteerIds); + const setLiveAppliedSteerIds = useSetAtom(liveAppliedSteerIdsAtom); const streamRef = useRef(null); /** Removes the foreground re-attach listener owned by the newest diff --git a/client/src/routes/ChatSettings.tsx b/client/src/routes/ChatSettings.tsx new file mode 100644 index 00000000000..264b23547b4 --- /dev/null +++ b/client/src/routes/ChatSettings.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'; +import type { ReactNode } from 'react'; +import type { ChatSettings } from '~/Providers/ChatSettingsContext'; +import { ChatSettingsContext } from '~/Providers/ChatSettingsContext'; +import store from '~/store'; + +/** Supplies the chat's app-global preferences from the app's own settings store. */ +export default function ChatSettingsProvider({ children }: { children: ReactNode }) { + const [duringRunDefaultAction, setDuringRunDefaultAction] = useRecoilState( + store.duringRunDefaultAction, + ); + const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault); + const saveDrafts = useRecoilValue(store.saveDrafts); + const isTemporary = useRecoilValue(store.isTemporary); + const resetVisibleArtifacts = useResetRecoilState(store.visibleArtifacts); + + const settings = useMemo( + () => ({ + duringRunDefaultAction, + setDuringRunDefaultAction, + steerInterruptsByDefault, + saveDrafts, + isTemporary, + resetVisibleArtifacts, + }), + [ + duringRunDefaultAction, + setDuringRunDefaultAction, + steerInterruptsByDefault, + saveDrafts, + isTemporary, + resetVisibleArtifacts, + ], + ); + + return {children}; +} diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 25c264872f4..6cbe66937f2 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -44,6 +44,7 @@ import useSidebarToggle from '~/hooks/Nav/useSidebarToggle'; import useSidebarState from '~/hooks/Nav/useSidebarState'; import { TermsAndConditionsModal } from '~/components/ui'; import useDrawerSwipe from '~/hooks/Nav/useDrawerSwipe'; +import ChatSettingsProvider from './ChatSettings'; import { useHealthCheck } from '~/data-provider'; import { Banner } from '~/components/Banners'; import store from '~/store'; @@ -68,7 +69,7 @@ function KeyboardShortcutsProvider() { ); } -export default function Root() { +function RootLayout() { const [showTerms, setShowTerms] = useState(false); const [bannerHeight, setBannerHeight] = useState(0); /** Shared with the drawer so the two agree on the breakpoint-transition frame. */ @@ -245,3 +246,11 @@ export default function Root() { ); } + +export default function Root() { + return ( + + + + ); +} diff --git a/client/src/routes/__tests__/ChatSettings.spec.tsx b/client/src/routes/__tests__/ChatSettings.spec.tsx new file mode 100644 index 00000000000..c537c6e5719 --- /dev/null +++ b/client/src/routes/__tests__/ChatSettings.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil'; +import { defaultChatSettings, useChatSettings } from '~/Providers/ChatSettingsContext'; +import ChatSettingsProvider from '../ChatSettings'; +import store from '~/store'; + +const renderSettings = (initialize?: (snapshot: MutableSnapshot) => void) => + renderHook( + () => ({ + settings: useChatSettings(), + storedAction: useRecoilValue(store.duringRunDefaultAction), + visibleArtifacts: useRecoilValue(store.visibleArtifacts), + }), + { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }, + ); + +describe('ChatSettingsProvider', () => { + it('supplies the stored preferences to the chat', () => { + const { result } = renderSettings(({ set }) => { + set(store.duringRunDefaultAction, 'queue'); + set(store.steerInterruptsByDefault, true); + set(store.saveDrafts, false); + set(store.isTemporary, true); + }); + + expect(result.current.settings).toMatchObject({ + duringRunDefaultAction: 'queue', + steerInterruptsByDefault: true, + saveDrafts: false, + isTemporary: true, + }); + }); + + it('writes the during-run default back to the app store', () => { + const { result } = renderSettings(); + + act(() => result.current.settings.setDuringRunDefaultAction('queue')); + + expect(result.current.storedAction).toBe('queue'); + expect(result.current.settings.duringRunDefaultAction).toBe('queue'); + }); + + it('closes the artifacts panel through the host', () => { + const { result } = renderSettings(({ set }) => { + set(store.visibleArtifacts, { a1: undefined }); + }); + + act(() => result.current.settings.resetVisibleArtifacts()); + + expect(result.current.visibleArtifacts).toBeNull(); + }); + + it('falls back to the stock defaults without a host', () => { + const { result } = renderHook(() => useChatSettings()); + + expect(result.current).toBe(defaultChatSettings); + }); +}); diff --git a/client/src/store/families.ts b/client/src/store/families.ts index ebf4745a9e5..466f5575b0f 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -616,28 +616,6 @@ const appliedSteerIdsByConvoId = atomFamily({ default: [], }); -/** - * Steer ids whose applied event landed in THIS session, pending their one-shot - * receipt draw-in. `SteerPart` consumes its id on mount so the animation plays - * exactly once, at the live chip→inline hand-off — never on reload, share, or - * a later revisit. Global rather than per-conversation: steer ids are unique, - * and the applied part renders in surfaces that don't know their convo id. */ -const liveAppliedSteerIds = atom({ - key: 'liveAppliedSteerIds', - default: [], -}); - -/** Membership view of `liveAppliedSteerIds` so each `SteerPart` subscribes to - * its own id only: stamping/consuming one steer re-renders that part, not - * every mounted historical part in a long conversation. */ -const liveAppliedSteerFamily = selectorFamily({ - key: 'liveAppliedSteerFamily', - get: - (steerId) => - ({ get }) => - steerId.length > 0 && get(liveAppliedSteerIds).includes(steerId), -}); - /** Optimistic ids the server has proven accepted via ACK or SYNC. Separate * from `appliedSteerIdsByConvoId`: accepted-but-still-queued steers must not * be suppressed by terminal conversion, but a late POST error must not @@ -836,8 +814,6 @@ export default { pendingRunEndByConvoId, drainAfterAbortByIndex, appliedSteerIdsByConvoId, - liveAppliedSteerIds, - liveAppliedSteerFamily, acceptedSteerClientIdsByConvoId, activeGenerationCreatedAtByConvoId, activeGenerationProtocolVersionByConvoId, diff --git a/client/src/store/steer.ts b/client/src/store/steer.ts index b8c23633fe4..380a457f787 100644 --- a/client/src/store/steer.ts +++ b/client/src/store/steer.ts @@ -38,3 +38,18 @@ export const revealedQueuedTurnFamily = atomFamily((_conversationId: string) => export const pendingSteerCancelClientIdsFamily = atomFamily((_conversationId: string) => atom([]), ); + +/** + * Steer ids whose applied event landed in THIS session, pending their one-shot + * receipt draw-in. `SteerPart` consumes its id on mount so the animation plays + * exactly once, at the live chip to inline hand-off, never on reload, share, or + * a later revisit. Global rather than per-conversation: steer ids are unique, + * and the applied part renders in surfaces that don't know their convo id. */ +export const liveAppliedSteerIdsAtom = atom([]); + +/** Membership view of `liveAppliedSteerIdsAtom` so each `SteerPart` subscribes to + * its own id only: stamping/consuming one steer re-renders that part, not + * every mounted historical part in a long conversation. */ +export const liveAppliedSteerFamily = atomFamily((steerId: string) => + atom((get) => steerId.length > 0 && get(liveAppliedSteerIdsAtom).includes(steerId)), +); From 03e1ef3901a2965746b14db4a1d1abab68f34bd2 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:25 +0200 Subject: [PATCH 2/3] fix: Keep Compaction Off the Message Frame and Read One Interrupt Default Compaction reads status through useChatActions, which re-renders only when the status changes instead of mapping every stream frame. The during-run send button reads the interrupt default from the steering controls instead of a second store read. --- .../Chat/Input/DuringRunSendButton.tsx | 4 +- .../__tests__/DuringRunSendButton.test.tsx | 10 +- .../src/hooks/Chat/__tests__/facade.spec.tsx | 75 +++++++++- client/src/hooks/Chat/facade.ts | 138 +++++++++++++----- .../src/hooks/Chat/useCompactConversation.ts | 4 +- 5 files changed, 185 insertions(+), 46 deletions(-) diff --git a/client/src/components/Chat/Input/DuringRunSendButton.tsx b/client/src/components/Chat/Input/DuringRunSendButton.tsx index 5ea20bb2599..d2f0e57ace0 100644 --- a/client/src/components/Chat/Input/DuringRunSendButton.tsx +++ b/client/src/components/Chat/Input/DuringRunSendButton.tsx @@ -1,5 +1,4 @@ import React, { forwardRef, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; import { useWatch } from 'react-hook-form'; import { Zap, Clock, OctagonPause, ZapOff } from 'lucide-react'; import { composerSubmitClasses, SendActions, SendIcon } from '@librechat/client'; @@ -10,7 +9,6 @@ import type { SteeringControls } from '~/hooks/Chat/useSteering'; import { isMacPlatform, resolveComposerKeyDown } from '~/utils/shortcuts'; import useComposerBindings from '~/hooks/Input/useComposerBindings'; import { useLocalize } from '~/hooks'; -import store from '~/store'; /** The rows, the popover and the chord chips are shared with every other chat * surface that can submit more than one way — see `SendActions`. */ @@ -40,9 +38,9 @@ type DuringRunSendButtonProps = { const DuringRunSendButton = React.memo( forwardRef((props: DuringRunSendButtonProps, ref: React.ForwardedRef) => { const localize = useLocalize(); - const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault); const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings(); const { steering, enterToSend } = props; + const { steerInterruptsByDefault } = steering; const data = useWatch({ control: props.control }); const content = data?.text?.trim(); const primary = steering.effectiveAction; diff --git a/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx b/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx index a727673a2e0..571fd5dff09 100644 --- a/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx +++ b/client/src/components/Chat/Input/__tests__/DuringRunSendButton.test.tsx @@ -40,12 +40,14 @@ const steeringStub = ({ pausedOnApproval = false, canSteer = true, canControlGeneration = true, -}: StubOptions) => + steerInterruptsByDefault = false, +}: StubOptions & { steerInterruptsByDefault?: boolean }) => ({ effectiveAction: canSteer ? 'steer' : 'queue', canSteer, canControlGeneration, pausedOnApproval, + steerInterruptsByDefault, interruptSteer: mockInterruptSteer, steerFromComposer: mockSteerFromComposer, queueFromComposer: mockQueueFromComposer, @@ -83,12 +85,14 @@ function openMenu(options: MenuOptions = {}) { render( { - set(store.steerInterruptsByDefault, enterInterrupts); set(store.shortcutsEnabled, shortcutsEnabled); set(store.customShortcuts, customShortcuts); }} > - + , ); expect(screen.getByText('com_ui_interrupt_steer')).toBeInTheDocument(); diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx index ed923d5b4a4..e6bf42298b7 100644 --- a/client/src/hooks/Chat/__tests__/facade.spec.tsx +++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-quer import type { TConversation, TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { ChatContract } from '../contract'; import { ChatContext } from '~/Providers/ChatContext'; -import { useChat } from '../facade'; +import { useChat, useChatActions } from '../facade'; const userMessage: TMessage = { messageId: 'user-1', @@ -653,3 +653,76 @@ describe('useChat', () => { expect(contract.setMessages).toHaveBeenNthCalledWith(2, [userMessage, answered]); }); }); + +describe('useChatActions', () => { + const renderActions = (messages: TMessage[]) => { + const key = [QueryKeys.messages, 'convo-1']; + const queryClient = new QueryClient(); + queryClient.setQueryData(key, messages); + const contract = createContract({ + getMessages: jest.fn(() => queryClient.getQueryData(key)), + latestMessageId: 'response-1', + isSubmitting: true, + }); + let renders = 0; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const view = renderHook( + () => { + renders += 1; + return useChatActions(); + }, + { wrapper }, + ); + const write = async (next: TMessage[]) => { + await act(async () => { + queryClient.setQueryData(key, next); + await flushCacheNotify(); + }); + }; + return { ...view, contract, write, renders: () => renders }; + }; + + it('moves from submitted to streaming when the response gets content', async () => { + const { result, write } = renderActions([userMessage, response()]); + expect(result.current.status).toBe('submitted'); + + await write([userMessage, response({ content: [{ type: ContentTypes.TEXT, text: 'Hel' }] })]); + + expect(result.current.status).toBe('streaming'); + }); + + it('does not re-render for stream frames that keep the status', async () => { + const { result, write, renders } = renderActions([ + userMessage, + response({ content: [{ type: ContentTypes.TEXT, text: 'Hel' }] }), + ]); + const first = result.current; + const before = renders(); + + await write([userMessage, response({ content: [{ type: ContentTypes.TEXT, text: 'Hello' }] })]); + await write([ + userMessage, + response({ content: [{ type: ContentTypes.TEXT, text: 'Hello!' }] }), + ]); + + expect(renders()).toBe(before); + expect(result.current).toBe(first); + }); + + it('forwards its actions to the contract', () => { + const { result, contract } = renderActions([userMessage, response()]); + + expect(result.current.sendMessage).toBe(contract.ask); + expect(result.current.stop).toBe(contract.stopGenerating); + result.current.regenerate(); + expect(contract.regenerate).toHaveBeenCalledWith({ + messageId: 'response-1', + parentMessageId: 'user-1', + isCreatedByUser: false, + }); + }); +}); diff --git a/client/src/hooks/Chat/facade.ts b/client/src/hooks/Chat/facade.ts index 7a0e1c4fd78..6db5ac2857b 100644 --- a/client/src/hooks/Chat/facade.ts +++ b/client/src/hooks/Chat/facade.ts @@ -9,6 +9,7 @@ import { } from 'librechat-data-provider'; import type { TMessage, UIMessage, TAttachment, UIMappingOptions } from 'librechat-data-provider'; import type { QueryCacheNotifyEvent } from '@tanstack/react-query'; +import type { ChatContract } from './contract'; import type { TAskFunction } from '~/common'; import { isMemoryFailureOutput } from '~/components/Chat/Messages/Content/Parts/MemoryCall'; import { getToolMeta } from '~/components/Chat/Messages/Content/outcome'; @@ -241,30 +242,14 @@ const getActiveBranch = (byId: Map, tailId: string | undefined }; /** - * AI SDK `useChat`, read and called through `ChatContext`. It holds no state of its own: - * `messages` is `getMessages()` mapped per message, re-read when the message query cache is - * written, and every action forwards to the contract. + * Subscribes to writes of one conversation's cached messages. Only events that can change the + * data count: observers mount inside other components' renders and the cache notifies + * synchronously, so the change is delivered after the current task instead of inside someone + * else's render. */ -export function useChat(): UseChatHelpers { - const { - conversation, - getMessages, - messagesKey, - setMessages: setStoredMessages, - latestMessageId, - isSubmitting, - ask, - regenerate: regenerateTarget, - stopGenerating, - } = useChatContext(); - +const useMessagesSubscription = (messagesKey: string) => { const queryClient = useQueryClient(); const queryHash = useMemo(() => hashQueryKey([QueryKeys.messages, messagesKey]), [messagesKey]); - /** - * Only events that can change what `readSnapshot` reads. Observers mount inside other - * components' renders and the cache notifies synchronously, so the change is delivered after - * the current task instead of inside someone else's render. - */ const subscribe = useCallback( (onChange: () => void) => { let active = true; @@ -285,6 +270,100 @@ export function useChat(): UseChatHelpers { }, [queryClient, queryHash], ); + return { queryClient, queryHash, subscribe }; +}; + +/** Regenerates the response to `messageId`, or the latest message of the branch. */ +const useRegenerate = ( + getMessages: ChatContract['getMessages'], + latestMessageId: string | undefined, + regenerateTarget: ChatContract['regenerate'], +) => + useCallback( + (options?: { messageId?: string }) => { + const messageId = options?.messageId ?? latestMessageId; + const target = getMessages()?.find((message) => message.messageId === messageId); + regenerateTarget( + target + ? { + messageId: target.messageId, + parentMessageId: target.parentMessageId, + isCreatedByUser: target.isCreatedByUser, + } + : { messageId }, + ); + }, + [getMessages, latestMessageId, regenerateTarget], + ); + +/** The latest message, searched from the tail, where it almost always is. */ +const findLatest = (stored: TMessage[] | undefined, latestMessageId: string | undefined) => { + if (!stored || latestMessageId == null) { + return undefined; + } + for (let i = stored.length - 1; i >= 0; i--) { + if (stored[i].messageId === latestMessageId) { + return stored[i]; + } + } + return undefined; +}; + +/** The `useChat` members that need no message list. */ +export type ChatActions = Pick< + UseChatHelpers, + 'id' | 'status' | 'sendMessage' | 'regenerate' | 'stop' +>; + +/** + * `useChat` without `messages`, for controls that submit or read status: it re-renders when the + * status changes, not on every stream frame, and never maps the conversation. + */ +export function useChatActions(): ChatActions { + const { + conversation, + getMessages, + messagesKey, + latestMessageId, + isSubmitting, + ask, + regenerate: regenerateTarget, + stopGenerating, + } = useChatContext(); + const { subscribe } = useMessagesSubscription(messagesKey); + const readStatus = useCallback( + () => getChatStatus(isSubmitting, findLatest(getMessages(), latestMessageId)), + [getMessages, isSubmitting, latestMessageId], + ); + const status = useSyncExternalStore(subscribe, readStatus, readStatus); + const regenerate = useRegenerate(getMessages, latestMessageId, regenerateTarget); + const id = messagesKey || conversation?.conversationId || undefined; + + return useMemo( + () => ({ id, status, sendMessage: ask, regenerate, stop: stopGenerating }), + [id, status, ask, regenerate, stopGenerating], + ); +} + +/** + * AI SDK `useChat`, read and called through `ChatContext`. It holds no state of its own: + * `messages` is `getMessages()` mapped per message, re-read when the message query cache is + * written, and every action forwards to the contract. + */ +export function useChat(): UseChatHelpers { + const { + conversation, + getMessages, + messagesKey, + setMessages: setStoredMessages, + latestMessageId, + isSubmitting, + ask, + regenerate: regenerateTarget, + stopGenerating, + } = useChatContext(); + + const { queryClient, queryHash, subscribe } = useMessagesSubscription(messagesKey); const snapshot = useRef<{ writes: number; stored?: TMessage[] }>(); /** * A stream frame replaces a response's content on the same object, so structural sharing can @@ -324,22 +403,7 @@ export function useChat(): UseChatHelpers { [errorText], ); - const regenerate = useCallback( - (options?: { messageId?: string }) => { - const messageId = options?.messageId ?? latestMessageId; - const target = getMessages()?.find((message) => message.messageId === messageId); - regenerateTarget( - target - ? { - messageId: target.messageId, - parentMessageId: target.parentMessageId, - isCreatedByUser: target.isCreatedByUser, - } - : { messageId }, - ); - }, - [getMessages, latestMessageId, regenerateTarget], - ); + const regenerate = useRegenerate(getMessages, latestMessageId, regenerateTarget); const setMessages = useCallback( (update: UIMessage[] | ((messages: UIMessage[]) => UIMessage[])) => { diff --git a/client/src/hooks/Chat/useCompactConversation.ts b/client/src/hooks/Chat/useCompactConversation.ts index 03051876828..80afaaf4ca1 100644 --- a/client/src/hooks/Chat/useCompactConversation.ts +++ b/client/src/hooks/Chat/useCompactConversation.ts @@ -3,7 +3,7 @@ import { atom, useAtom } from 'jotai'; import { Constants, isCompactedLeaf, isAssistantsEndpoint } from 'librechat-data-provider'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { useChatContext } from '~/Providers/ChatContext'; -import { useChat } from './facade'; +import { useChatActions } from './facade'; /** Conversation whose compaction this client submitted and is still streaming. */ export const compactingConversationAtom = atom(null); @@ -23,7 +23,7 @@ export const supportsCompaction = (endpoint?: string | null): boolean => */ export default function useCompactConversation() { const { index, conversation } = useChatContext(); - const { sendMessage, status } = useChat(); + const { sendMessage, status } = useChatActions(); const isSubmitting = status === 'submitted' || status === 'streaming'; const latestMessage = useLatestMessage(index); const [compactingConversation, setCompactingConversation] = useAtom(compactingConversationAtom); From 4e189a1c882be6d3a25c4ea9239c7a30f3630c2e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:45:41 +0200 Subject: [PATCH 3/3] fix: Supply Only Preferences Every Chat Reader Can Take From the Host saveDrafts and isTemporary are also read by useNewConvo, useAutoSave and the upload path, and by ChatView, ChatForm and the resume hook, so a host value for them would split one preference across two sources. They stay on the store until all of their readers can move together. --- client/src/Providers/ChatSettingsContext.tsx | 10 +++------- client/src/hooks/Chat/__tests__/useNewChat.spec.ts | 7 ++----- client/src/hooks/Chat/useChatFunctions.ts | 3 +-- client/src/hooks/Chat/useNewChat.ts | 3 +-- client/src/routes/ChatSettings.tsx | 6 ------ client/src/routes/__tests__/ChatSettings.spec.tsx | 4 ---- 6 files changed, 7 insertions(+), 26 deletions(-) diff --git a/client/src/Providers/ChatSettingsContext.tsx b/client/src/Providers/ChatSettingsContext.tsx index 42777b86e37..9034f787894 100644 --- a/client/src/Providers/ChatSettingsContext.tsx +++ b/client/src/Providers/ChatSettingsContext.tsx @@ -5,7 +5,9 @@ export type DuringRunAction = 'steer' | 'queue'; /** * App-global preferences the chat reads but does not own. The host supplies them, so the chat - * hooks never reach into the app's state store for shell settings. + * hooks never reach into the app's state store for shell settings. A preference belongs here only + * once every chat reader of it takes it from here: a reader left on the store would act on a + * different value than a host that supplies its own. */ export type ChatSettings = { /** Default composer action while a run is in flight. */ @@ -13,10 +15,6 @@ export type ChatSettings = { setDuringRunDefaultAction: (action: DuringRunAction) => void; /** Whether a steer interrupts the running step instead of waiting for the next one. */ steerInterruptsByDefault: boolean; - /** Whether composer drafts persist across conversations. */ - saveDrafts: boolean; - /** Whether new turns are sent as a temporary (unsaved) chat. */ - isTemporary: boolean; /** Closes the artifacts panel, called when the active conversation changes. */ resetVisibleArtifacts: () => void; }; @@ -26,8 +24,6 @@ export const defaultChatSettings: ChatSettings = { duringRunDefaultAction: 'steer', setDuringRunDefaultAction: () => undefined, steerInterruptsByDefault: false, - saveDrafts: true, - isTemporary: false, resetVisibleArtifacts: () => undefined, }; diff --git a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts index 4b6c543d4c4..82ad2f879d9 100644 --- a/client/src/hooks/Chat/__tests__/useNewChat.spec.ts +++ b/client/src/hooks/Chat/__tests__/useNewChat.spec.ts @@ -67,14 +67,10 @@ jest.mock('recoil', () => ({ if (typeof key === 'string' && key.startsWith('isSubmitting')) { return mockState.isSubmitting; } - return undefined; + return mockState.saveDrafts; }, })); -jest.mock('~/Providers/ChatSettingsContext', () => ({ - useChatSettings: () => ({ saveDrafts: mockState.saveDrafts }), -})); - jest.mock('~/hooks/useNewConvo', () => ({ __esModule: true, default: () => ({ newConversation: mockNewConversation }), @@ -212,6 +208,7 @@ jest.mock('~/store', () => ({ conversationIdByIndex: (index: number) => `conversationIdByIndex-${index}`, filesByIndex: (index: number) => `files-by-index-${index}`, isSubmittingFamily: (index: number) => `isSubmitting-${index}`, + saveDrafts: 'saveDrafts', }, })); diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index ec79a1a5a74..ee2d349ced7 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -52,7 +52,6 @@ import { withSubmittedCodeDecision } from '~/hooks/Agents/codeDecision'; import useCodeApprovalMode from '~/hooks/Agents/useCodeApprovalMode'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; -import { useChatSettings } from '~/Providers/ChatSettingsContext'; import useCodeWorkspace from '~/hooks/Agents/useCodeWorkspace'; import useGetSender from '~/hooks/Conversations/useGetSender'; import { activeUsageResponseIdFamily } from '~/store/usage'; @@ -242,7 +241,7 @@ export default function useChatFunctions({ const setFilesToDelete = useSetFilesToDelete(); const getEphemeralAgent = useGetEphemeralAgent(); const agentsMap = useAgentsMapContext(); - const { isTemporary } = useChatSettings(); + const isTemporary = useRecoilValue(store.isTemporary); const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? ''); const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index)); const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(index)); diff --git a/client/src/hooks/Chat/useNewChat.ts b/client/src/hooks/Chat/useNewChat.ts index 94a041f570e..4f109400de8 100644 --- a/client/src/hooks/Chat/useNewChat.ts +++ b/client/src/hooks/Chat/useNewChat.ts @@ -32,7 +32,6 @@ import { takeRetainedFileDeletions, } from '~/utils'; import { useGetFiles, useDeleteFilesMutation } from '~/data-provider'; -import { useChatSettings } from '~/Providers/ChatSettingsContext'; import useNewConvo from '~/hooks/useNewConvo'; import store from '~/store'; @@ -120,7 +119,7 @@ export default function useNewChat({ const { newConversation } = useNewConvo(index); const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const files = useRecoilValue(store.filesByIndex(index)); - const { saveDrafts } = useChatSettings(); + const saveDrafts = useRecoilValue(store.saveDrafts); const { data: fileList } = useGetFiles(); const { mutateAsync } = useDeleteFilesMutation(); /** The cleanup pass runs on a timer nobody asked for, so it reports nothing: a storage failure diff --git a/client/src/routes/ChatSettings.tsx b/client/src/routes/ChatSettings.tsx index 264b23547b4..7a62220cb03 100644 --- a/client/src/routes/ChatSettings.tsx +++ b/client/src/routes/ChatSettings.tsx @@ -11,8 +11,6 @@ export default function ChatSettingsProvider({ children }: { children: ReactNode store.duringRunDefaultAction, ); const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault); - const saveDrafts = useRecoilValue(store.saveDrafts); - const isTemporary = useRecoilValue(store.isTemporary); const resetVisibleArtifacts = useResetRecoilState(store.visibleArtifacts); const settings = useMemo( @@ -20,16 +18,12 @@ export default function ChatSettingsProvider({ children }: { children: ReactNode duringRunDefaultAction, setDuringRunDefaultAction, steerInterruptsByDefault, - saveDrafts, - isTemporary, resetVisibleArtifacts, }), [ duringRunDefaultAction, setDuringRunDefaultAction, steerInterruptsByDefault, - saveDrafts, - isTemporary, resetVisibleArtifacts, ], ); diff --git a/client/src/routes/__tests__/ChatSettings.spec.tsx b/client/src/routes/__tests__/ChatSettings.spec.tsx index c537c6e5719..91c37a01877 100644 --- a/client/src/routes/__tests__/ChatSettings.spec.tsx +++ b/client/src/routes/__tests__/ChatSettings.spec.tsx @@ -26,15 +26,11 @@ describe('ChatSettingsProvider', () => { const { result } = renderSettings(({ set }) => { set(store.duringRunDefaultAction, 'queue'); set(store.steerInterruptsByDefault, true); - set(store.saveDrafts, false); - set(store.isTemporary, true); }); expect(result.current.settings).toMatchObject({ duringRunDefaultAction: 'queue', steerInterruptsByDefault: true, - saveDrafts: false, - isTemporary: true, }); });