diff --git a/client/src/Providers/ChatSettingsContext.tsx b/client/src/Providers/ChatSettingsContext.tsx new file mode 100644 index 00000000000..9034f787894 --- /dev/null +++ b/client/src/Providers/ChatSettingsContext.tsx @@ -0,0 +1,32 @@ +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. 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. */ + duringRunDefaultAction: DuringRunAction; + setDuringRunDefaultAction: (action: DuringRunAction) => void; + /** Whether a steer interrupts the running step instead of waiting for the next one. */ + steerInterruptsByDefault: 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, + resetVisibleArtifacts: () => undefined, +}; + +export const ChatSettingsContext = createContext(defaultChatSettings); + +export const useChatSettings = () => useContext(ChatSettingsContext); 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/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..e6bf42298b7 100644 --- a/client/src/hooks/Chat/__tests__/facade.spec.tsx +++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx @@ -1,11 +1,11 @@ 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'; -import { useChat } from '../facade'; +import { useChat, useChatActions } from '../facade'; const userMessage: TMessage = { messageId: 'user-1', @@ -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(); @@ -599,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/__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__/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..6db5ac2857b 100644 --- a/client/src/hooks/Chat/facade.ts +++ b/client/src/hooks/Chat/facade.ts @@ -8,6 +8,8 @@ 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 { 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'; @@ -225,6 +227,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(); @@ -236,6 +241,110 @@ const getActiveBranch = (byId: Map, tailId: string | undefined return branch; }; +/** + * 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. + */ +const useMessagesSubscription = (messagesKey: string) => { + const queryClient = useQueryClient(); + const queryHash = useMemo(() => hashQueryKey([QueryKeys.messages, messagesKey]), [messagesKey]); + const subscribe = useCallback( + (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], + ); + 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 @@ -254,17 +363,7 @@ export function useChat(): UseChatHelpers { stopGenerating, } = useChatContext(); - const queryClient = useQueryClient(); - const queryHash = useMemo(() => hashQueryKey([QueryKeys.messages, messagesKey]), [messagesKey]); - const subscribe = useCallback( - (onChange: () => void) => - queryClient.getQueryCache().subscribe((event) => { - if (event.query.queryHash === queryHash) { - onChange(); - } - }), - [queryClient, queryHash], - ); + 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 @@ -304,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/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/useCompactConversation.ts b/client/src/hooks/Chat/useCompactConversation.ts index 332a94cce17..80afaaf4ca1 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 { useChatActions } 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 } = useChatActions(); + 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/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..7a62220cb03 --- /dev/null +++ b/client/src/routes/ChatSettings.tsx @@ -0,0 +1,32 @@ +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 resetVisibleArtifacts = useResetRecoilState(store.visibleArtifacts); + + const settings = useMemo( + () => ({ + duringRunDefaultAction, + setDuringRunDefaultAction, + steerInterruptsByDefault, + resetVisibleArtifacts, + }), + [ + duringRunDefaultAction, + setDuringRunDefaultAction, + steerInterruptsByDefault, + 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..91c37a01877 --- /dev/null +++ b/client/src/routes/__tests__/ChatSettings.spec.tsx @@ -0,0 +1,61 @@ +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); + }); + + expect(result.current.settings).toMatchObject({ + duringRunDefaultAction: 'queue', + steerInterruptsByDefault: 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)), +);