From c20d9859c97ec6768ac84d135cfbc24a3dda0c2e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:30:45 +0200 Subject: [PATCH 1/2] refactor: Move Run Display, Live Attachment and PTC Trace Atoms to Jotai --- .../src/Providers/MessagePartsHostContext.tsx | 6 +- .../Input/__tests__/BrowserAudio.spec.tsx | 3 +- .../Chat/Input/__tests__/StreamAudio.spec.tsx | 3 +- .../Messages/Content/ActivityPhaseGroup.tsx | 2 +- .../Parts/__tests__/ExecuteCode.test.tsx | 40 ++--- .../Parts/__tests__/PtcToolTrace.test.tsx | 17 +- .../Content/__tests__/LiveParity.test.tsx | 2 +- .../__tests__/useAutoplayTrigger.spec.tsx | 3 +- client/src/hooks/Audio/useAutoplayTrigger.ts | 4 +- client/src/hooks/Audio/usePauseGlobalAudio.ts | 4 +- client/src/hooks/Chat/useChatHelpers.ts | 5 +- client/src/hooks/Config/useClearStates.ts | 3 +- .../useAttachmentPreviewSync.loop.spec.tsx | 13 +- .../useAttachmentPreviewSync.spec.tsx | 67 +++++--- .../hooks/Files/useAttachmentPreviewSync.ts | 6 +- .../__tests__/useAttachments.spec.tsx | 13 +- client/src/hooks/Messages/useAttachments.ts | 6 +- .../Messages/useConversationUIResources.ts | 8 +- .../SSE/__tests__/stepEquivalence.spec.ts | 13 +- .../__tests__/useAttachmentHandler.spec.tsx | 15 +- .../SSE/__tests__/useResumableSSE.spec.ts | 15 +- .../SSE/__tests__/useStepHandler.spec.ts | 28 ++-- client/src/hooks/SSE/atoms.ts | 41 +++++ client/src/{store => hooks/SSE}/ptc.ts | 8 +- client/src/{store => hooks/SSE}/sandbox.ts | 0 client/src/hooks/SSE/useAttachmentHandler.ts | 6 +- client/src/hooks/SSE/useResumablePane.ts | 3 +- client/src/hooks/SSE/useSSE.ts | 5 +- client/src/hooks/SSE/useStepHandler.ts | 145 ++++++++---------- client/src/store/families.ts | 6 - client/src/store/index.ts | 2 - client/src/store/misc.ts | 47 +----- 32 files changed, 277 insertions(+), 262 deletions(-) create mode 100644 client/src/hooks/SSE/atoms.ts rename client/src/{store => hooks/SSE}/ptc.ts (89%) rename client/src/{store => hooks/SSE}/sandbox.ts (100%) diff --git a/client/src/Providers/MessagePartsHostContext.tsx b/client/src/Providers/MessagePartsHostContext.tsx index 603da1065ce..5a969b9d192 100644 --- a/client/src/Providers/MessagePartsHostContext.tsx +++ b/client/src/Providers/MessagePartsHostContext.tsx @@ -22,12 +22,14 @@ import type { MessagePartsHost, } from '~/hooks/Chat/contract'; import type { Artifact } from '~/common'; -import store, { ptcTraceByToolCallId, ptcTraceKey, sandboxStartingByToolCallId } from '~/store'; import { useMessageContext, useFileMapContext, useAgentsMapContext } from '~/Providers'; import { getAppPartRenderers } from '~/components/Chat/Messages/Content/Parts/slots'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; +import { ptcTraceByToolCallId, ptcTraceKey } from '~/hooks/SSE/ptc'; +import { sandboxStartingByToolCallId } from '~/hooks/SSE/sandbox'; import { showThinkingAtom } from '~/store/showThinking'; import { fontSizeAtom } from '~/store/fontSize'; +import store from '~/store'; function useAppMessage(): MessagePartMessage { const { messageId, isSubmitting, isLatestMessage, nextType } = useMessageContext(); @@ -119,7 +121,7 @@ export const appMessagePartsHost: MessagePartsHost = { useToast: () => useToastContext().showToast, useSandboxStarting: (toolCallId) => useAtomValue(sandboxStartingByToolCallId(toolCallId)), usePtcTrace: (messageId, toolCallId) => - useRecoilValue( + useAtomValue( ptcTraceByToolCallId(toolCallId && messageId ? ptcTraceKey(messageId, toolCallId) : ''), ), useToolArtifactClaim: (artifactId) => useRecoilState(store.toolArtifactClaim(artifactId)), diff --git a/client/src/components/Chat/Input/__tests__/BrowserAudio.spec.tsx b/client/src/components/Chat/Input/__tests__/BrowserAudio.spec.tsx index 20aed50578d..1185e5eced2 100644 --- a/client/src/components/Chat/Input/__tests__/BrowserAudio.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/BrowserAudio.spec.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { isSubmittingFamily, resetSubmitting } from '@librechat/chat/react'; import { Constants, QueryKeys, ContentTypes } from 'librechat-data-provider'; import type { TMessage, TConversation } from 'librechat-data-provider'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import BrowserAudio from '../BrowserAudio'; import store from '~/store'; @@ -68,7 +69,7 @@ const renderBrowserAudio = ({ initializeState={({ set }) => { set(store.voice, voiceName); set(store.conversationByIndex(0), { conversationId } as TConversation); - set(store.activeRunFamily(0), activeRunId); + getDefaultStore().set(activeRunFamily(0), activeRunId); set(store.audioRunFamily(0), audioRunId); getDefaultStore().set(isSubmittingFamily(0), isSubmitting); }} diff --git a/client/src/components/Chat/Input/__tests__/StreamAudio.spec.tsx b/client/src/components/Chat/Input/__tests__/StreamAudio.spec.tsx index 8559273fe91..258da534463 100644 --- a/client/src/components/Chat/Input/__tests__/StreamAudio.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/StreamAudio.spec.tsx @@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { isSubmittingFamily, resetSubmitting } from '@librechat/chat/react'; import type { TMessage, TConversation } from 'librechat-data-provider'; import { getTTSCacheKey, setCachedTTSAudio, getTTSCacheGeneration } from '~/utils'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import StreamAudio from '../StreamAudio'; import store from '~/store'; @@ -55,7 +56,7 @@ describe('StreamAudio cache', () => { initializeState={({ set }) => { set(store.automaticPlayback, true); set(store.conversationByIndex(0), { conversationId } as TConversation); - set(store.activeRunFamily(0), 'run-1'); + getDefaultStore().set(activeRunFamily(0), 'run-1'); set(store.audioRunFamily(0), null); getDefaultStore().set(isSubmittingFamily(0), false); }} diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index 37efc9d47ab..b10cfbdb0a3 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -33,10 +33,10 @@ import { FOLD_RAIL_CLASSES, ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from './rows'; import { getOutcomeStatus, isSpanFailed, summarizeSpan } from './outcome'; import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming'; import useThrottledValue from '~/hooks/Messages/useThrottledValue'; +import { sandboxStartingByToolCallId } from '~/hooks/SSE/sandbox'; import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; import { getActivityLabelText } from '~/utils/activityLabels'; import { MCPAppViews } from '~/components/MCPUIResource'; -import { sandboxStartingByToolCallId } from '~/store'; import { MessageSurfaceContext } from '../ui/surface'; import useClockFormat from '~/hooks/useClockFormat'; import { cn, getMessageTimestamp } from '~/utils'; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx index 5b38329b3f5..b13972dbd7d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { render } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; import type { PtcTraceEntry } from '~/common'; -import { ptcTraceByToolCallId, ptcTraceKey } from '~/store/ptc'; +import { ptcTraceByToolCallId, ptcTraceKey } from '~/hooks/SSE/ptc'; import { MessageContext } from '~/Providers/MessageContext'; import ExecuteCode from '../ExecuteCode'; import store from '~/store'; @@ -147,25 +148,28 @@ describe('ExecuteCode programmatic tool trace', () => { const TOOL_CALL_ID = 'call_ptc_1'; const MESSAGE_ID = 'response-msg-1'; - const renderWithTrace = (entries: PtcTraceEntry[]) => - render( - { - set(store.autoExpandTools, true); - set(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID)), { entries, dropped: 0 }); - }} - > - - - + const renderWithTrace = (entries: PtcTraceEntry[]) => { + const traceStore = createStore(); + traceStore.set(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID)), { + entries, + dropped: 0, + }); + return render( + set(store.autoExpandTools, true)}> + + + + + , ); + }; it('lists the inner tool calls the running program has made', () => { const { getByText } = renderWithTrace([ diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx index 3053caf7f80..24af73352e9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; +import { RecoilRoot } from 'recoil'; import { render, screen } from '@testing-library/react'; +import { useSetAtom, Provider as JotaiProvider } from 'jotai'; import type { PtcTraceEntry } from '~/common'; -import { ptcTraceByToolCallId, ptcTraceKey } from '~/store/ptc'; +import { ptcTraceByToolCallId, ptcTraceKey } from '~/hooks/SSE/ptc'; import { MessageContext } from '~/Providers/MessageContext'; import PtcToolTrace from '../PtcToolTrace'; @@ -39,7 +40,7 @@ const MESSAGE_ID = 'response-msg-1'; function renderTrace(entries: PtcTraceEntry[], messageId = MESSAGE_ID, dropped = 0) { const Seed = () => { - const set = useSetRecoilState(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID))); + const set = useSetAtom(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID))); React.useEffect(() => { if (entries.length > 0) { set({ entries, dropped }); @@ -50,10 +51,12 @@ function renderTrace(entries: PtcTraceEntry[], messageId = MESSAGE_ID, dropped = return render( - - - - + + + + + + , ); } diff --git a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx index 28cc3a7ea1a..f5119b99f14 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx @@ -11,8 +11,8 @@ import type { TMessageContentParts, SearchResultData, } from 'librechat-data-provider'; +import { sandboxStartingByToolCallId } from '~/hooks/SSE/sandbox'; import { resolveAskUserQuestionPart } from '~/utils/approval'; -import { sandboxStartingByToolCallId } from '~/store'; import ContentParts from '../ContentParts'; import { getLiveActivity } from '../live'; import * as mcpHooks from '~/hooks/MCP'; diff --git a/client/src/hooks/Audio/__tests__/useAutoplayTrigger.spec.tsx b/client/src/hooks/Audio/__tests__/useAutoplayTrigger.spec.tsx index 0abc74999c2..fec2383ff19 100644 --- a/client/src/hooks/Audio/__tests__/useAutoplayTrigger.spec.tsx +++ b/client/src/hooks/Audio/__tests__/useAutoplayTrigger.spec.tsx @@ -8,6 +8,7 @@ import { isSubmittingFamily, resetSubmitting } from '@librechat/chat/react'; import { Constants, QueryKeys, ContentTypes } from 'librechat-data-provider'; import type { TMessage, TConversation } from 'librechat-data-provider'; import useAutoplayTrigger from '../useAutoplayTrigger'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import store from '~/store'; const conversationId = 'convo-1'; @@ -30,7 +31,7 @@ const renderTrigger = (message: TMessage) => { { set(store.conversationByIndex(0), { conversationId } as TConversation); - set(store.activeRunFamily(0), 'run-1'); + getDefaultStore().set(activeRunFamily(0), 'run-1'); set(store.audioRunFamily(0), null); getDefaultStore().set(isSubmittingFamily(0), false); }} diff --git a/client/src/hooks/Audio/useAutoplayTrigger.ts b/client/src/hooks/Audio/useAutoplayTrigger.ts index 8f3a5f33721..453c1602627 100644 --- a/client/src/hooks/Audio/useAutoplayTrigger.ts +++ b/client/src/hooks/Audio/useAutoplayTrigger.ts @@ -1,8 +1,10 @@ +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useIsSubmitting } from '@librechat/chat/react'; import { getSpeechText } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import store from '~/store'; export type TAutoplayTrigger = { @@ -18,7 +20,7 @@ export type TAutoplayTrigger = { * speakable text (reasoning alone is not), and its run must not have been played already. */ export default function useAutoplayTrigger(index: string | number = 0): TAutoplayTrigger { - const activeRunId = useRecoilValue(store.activeRunFamily(index)); + const activeRunId = useAtomValue(activeRunFamily(index)); const audioRunId = useRecoilValue(store.audioRunFamily(index)); const isSubmitting = useIsSubmitting(index); const latestMessage = useLatestMessage(index); diff --git a/client/src/hooks/Audio/usePauseGlobalAudio.ts b/client/src/hooks/Audio/usePauseGlobalAudio.ts index b14e39a23dc..1b8fd6377f2 100644 --- a/client/src/hooks/Audio/usePauseGlobalAudio.ts +++ b/client/src/hooks/Audio/usePauseGlobalAudio.ts @@ -1,12 +1,14 @@ import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; import { useRecoilState, useSetRecoilState } from 'recoil'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import { globalAudioId } from '~/common'; import store from '~/store'; function usePauseGlobalAudio(index = 0) { /* Global Audio Variables */ const setAudioRunId = useSetRecoilState(store.audioRunFamily(index)); - const setActiveRunId = useSetRecoilState(store.activeRunFamily(index)); + const setActiveRunId = useSetAtom(activeRunFamily(index)); const setGlobalIsPlaying = useSetRecoilState(store.globalAudioPlayingFamily(index)); const setIsGlobalAudioFetching = useSetRecoilState(store.globalAudioFetchingFamily(index)); const [globalAudioURL, setGlobalAudioURL] = useRecoilState(store.globalAudioURLFamily(index)); diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index 4f0c85cdd7c..698cbd1d15e 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react'; +import { useRecoilState } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; -import { useRecoilState, useRecoilValue } from 'recoil'; import { useAtom, useSetAtom, useStore, useAtomValue } from 'jotai'; import { Constants, QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider'; import { @@ -19,6 +19,7 @@ import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatest import { useAbortStreamMutation, supportsGenerationProtocolV2 } from '~/data-provider'; import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/state'; import { useChatSettings } from '~/Providers/ChatSettingsContext'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; import useChatFunctions from '~/hooks/Chat/useChatFunctions'; import useSteerConvert from '~/hooks/Chat/useSteerConvert'; import { resolveAbortSteerTarget } from '~/utils'; @@ -28,7 +29,7 @@ import { useAbortCleanup } from './abort'; import store from '~/store'; /** The live attachments map the stream and preview polling write. */ -const useLiveAttachments = () => useRecoilValue(store.messageAttachmentsMap); +const useLiveAttachments = () => useAtomValue(messageAttachmentsMapAtom); // this to be set somewhere else export default function useChatHelpers(index = 0, paramId?: string): ChatContract { diff --git a/client/src/hooks/Config/useClearStates.ts b/client/src/hooks/Config/useClearStates.ts index 27e53b69658..6f0ee3d6e7f 100644 --- a/client/src/hooks/Config/useClearStates.ts +++ b/client/src/hooks/Config/useClearStates.ts @@ -16,6 +16,7 @@ import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/ import { showFilesDialogAtom, filesDialogTriggerAtom } from '~/store/filesDialog'; import { showSkillsPopoverFamily } from '~/components/Chat/Input/skillsState'; import { clearLocalStorage } from '~/utils/localStorage'; +import { activeRunFamily } from '~/hooks/SSE/atoms'; import store from '~/store'; export default function useClearStates() { @@ -72,7 +73,7 @@ export default function useClearStates() { reset(store.globalAudioURLFamily(key)); reset(store.globalAudioFetchingFamily(key)); reset(store.globalAudioPlayingFamily(key)); - reset(store.activeRunFamily(key)); + jotaiStore.set(activeRunFamily(key), null); reset(store.audioRunFamily(key)); jotaiStore.set(siblingIdxFamily(siblingKey(key.toString())), 0); } diff --git a/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.loop.spec.tsx b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.loop.spec.tsx index 7927af68d08..4c4e5533ea4 100644 --- a/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.loop.spec.tsx +++ b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.loop.spec.tsx @@ -23,6 +23,7 @@ import { RecoilRoot } from 'recoil'; import { Tools } from 'librechat-data-provider'; import { render } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; import type { TFile, TAttachment, @@ -103,7 +104,9 @@ describe('useAttachmentPreviewSync — #13916 infinite-loop regression', () => { expect(() => render( - + + + , ), ).not.toThrow(); @@ -134,7 +137,9 @@ describe('useAttachmentPreviewSync — #13916 infinite-loop regression', () => { render( - + + + , ); @@ -157,7 +162,9 @@ describe('useAttachmentPreviewSync — #13916 infinite-loop regression', () => { expect(() => render( - + + + , ), ).not.toThrow(); diff --git a/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx index ffa17d53a70..fc4be337129 100644 --- a/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx +++ b/client/src/hooks/Files/__tests__/useAttachmentPreviewSync.spec.tsx @@ -17,10 +17,16 @@ import { useEffect } from 'react'; import { Tools } from 'librechat-data-provider'; -import { getDefaultStore, useSetAtom } from 'jotai'; import { renderHook } from '@testing-library/react'; import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; import { isSubmittingFamily, resetSubmitting } from '@librechat/chat/react'; +import { + Provider as JotaiProvider, + getDefaultStore, + createStore, + useSetAtom, + useAtomValue, +} from 'jotai'; import type { TAttachment, TFile, @@ -39,8 +45,13 @@ jest.mock('~/data-provider', () => ({ })); import useAttachmentPreviewSync from '../useAttachmentPreviewSync'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; -const wrapper = ({ children }: { children: ReactNode }) => {children}; +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); const messageId = 'msg-1'; const fileId = 'fid-1'; @@ -104,7 +115,7 @@ function setup({ * for tests that simulate `isAnySubmitting` toggling — the * selector reads `conversationKeysAtom` × `isSubmittingFamily(key)` * so we have to populate both for the selector to fire. */ - const setMap = useSetRecoilState(store.messageAttachmentsMap); + const setMap = useSetAtom(messageAttachmentsMapAtom); const setKeys = useSetRecoilState(store.conversationKeysAtom); const setSubmitting = useSetAtom(isSubmittingFamily(0)); useEffect(() => { @@ -114,7 +125,7 @@ function setup({ setKeys([0]); setSubmitting(isSubmitting); }, [setMap, setKeys, setSubmitting]); - const map = useRecoilValue(store.messageAttachmentsMap); + const map = useAtomValue(messageAttachmentsMapAtom); ref.current = map; return null; }; @@ -156,7 +167,7 @@ function setup({ * flag so the test can assert the hook flipped it on the edge. * * `isSubmittingAtMount` seeds `isSubmittingFamily(0)` *before* the hook - * runs (via `initializeState`) so the hook's mount-time snapshot read + * runs (on the store the provider reads) so the hook's mount-time snapshot read * captures the intended value. Setting it after mount via a child * effect would race with the hook's first render and the gate would * see the default (`false`), defeating the whole point of the test. @@ -189,22 +200,22 @@ function setupWithTransitions( return null; }; + const jotaiStore = createStore(); + jotaiStore.set(isSubmittingFamily(0), isSubmittingAtMount); const { rerender } = renderHook( ({ attachment }: { attachment: TAttachment }) => useAttachmentPreviewSync(attachment), { initialProps: { attachment: makeAttachment({ status: 'pending' }) }, wrapper: ({ children }: { children: ReactNode }) => ( - { - getDefaultStore().set(isSubmittingFamily(0), isSubmittingAtMount); - }} - > - - - {children} - + + + + + {children} + + ), }, @@ -477,10 +488,14 @@ describe('useAttachmentPreviewSync', () => { initialProps: { attachment: makeAttachment({ messageId: owner }) }, wrapper: ({ children }: { children: ReactNode }) => ( - - - {children} - + + + + {children} + + ), }, @@ -517,10 +532,14 @@ describe('useAttachmentPreviewSync', () => { initialProps: { attachment: makeAttachment({ messageId: owner }) }, wrapper: ({ children }: { children: ReactNode }) => ( - - - {children} - + + + + {children} + + ), }, diff --git a/client/src/hooks/Files/useAttachmentPreviewSync.ts b/client/src/hooks/Files/useAttachmentPreviewSync.ts index 4300519312b..314c61c702f 100644 --- a/client/src/hooks/Files/useAttachmentPreviewSync.ts +++ b/client/src/hooks/Files/useAttachmentPreviewSync.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from 'react'; -import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { useSetAtom } from 'jotai'; +import { useRecoilCallback } from 'recoil'; import type { TAttachment, TFile, TFilePreview } from 'librechat-data-provider'; import { useMessageContext } from '~/Providers/MessageContext'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; import { useFilePreview } from '~/data-provider'; import { useShareContext } from '~/Providers'; import store from '~/store'; @@ -61,7 +63,7 @@ interface UseAttachmentPreviewSyncResult { export default function useAttachmentPreviewSync( attachment: TAttachment | undefined, ): UseAttachmentPreviewSyncResult { - const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap); + const setAttachmentsMap = useSetAtom(messageAttachmentsMapAtom); const { isSubmitting, messageId: contextMessageId } = useMessageContext(); /* `useRecoilCallback` writes without subscribing this hook to * another message or file's pending→ready flag. */ diff --git a/client/src/hooks/Messages/__tests__/useAttachments.spec.tsx b/client/src/hooks/Messages/__tests__/useAttachments.spec.tsx index a0de6992068..5c6ed611d99 100644 --- a/client/src/hooks/Messages/__tests__/useAttachments.spec.tsx +++ b/client/src/hooks/Messages/__tests__/useAttachments.spec.tsx @@ -11,18 +11,19 @@ */ import { useEffect } from 'react'; +import { RecoilRoot } from 'recoil'; import { Tools } from 'librechat-data-provider'; import { renderHook } from '@testing-library/react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; +import { Provider as JotaiProvider, useSetAtom } from 'jotai'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { ReactNode } from 'react'; -import store from '~/store'; type AttachmentFixture = TFile & TAttachmentMetadata; jest.mock('~/hooks/useLocalize', () => () => (key: string) => key); import useAttachments from '../useAttachments'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; const messageId = 'msg-1'; @@ -67,7 +68,7 @@ function setup({ liveMap?: Record; }) { const Seed = () => { - const setMap = useSetRecoilState(store.messageAttachmentsMap); + const setMap = useSetAtom(messageAttachmentsMapAtom); useEffect(() => { if (liveMap) { setMap(liveMap); @@ -79,8 +80,10 @@ function setup({ return renderHook(() => useAttachments({ messageId, attachments }), { wrapper: ({ children }: { children: ReactNode }) => ( - - {children} + + + {children} + ), }); diff --git a/client/src/hooks/Messages/useAttachments.ts b/client/src/hooks/Messages/useAttachments.ts index 612453a29a9..ee5e5ea0090 100644 --- a/client/src/hooks/Messages/useAttachments.ts +++ b/client/src/hooks/Messages/useAttachments.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai'; import type { TAttachment } from 'librechat-data-provider'; import { useSearchResultsByTurn } from './useSearchResultsByTurn'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; import { mergeAttachments } from '~/utils/attachments'; -import store from '~/store'; export default function useAttachments({ messageId, @@ -12,7 +12,7 @@ export default function useAttachments({ messageId?: string; attachments?: TAttachment[]; }) { - const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap); + const messageAttachmentsMap = useAtomValue(messageAttachmentsMapAtom); const messageAttachments = useMemo( () => mergeAttachments(attachments, messageAttachmentsMap[messageId ?? '']), [attachments, messageAttachmentsMap, messageId], diff --git a/client/src/hooks/Messages/useConversationUIResources.ts b/client/src/hooks/Messages/useConversationUIResources.ts index 28e9aa035a6..f282970a08d 100644 --- a/client/src/hooks/Messages/useConversationUIResources.ts +++ b/client/src/hooks/Messages/useConversationUIResources.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai'; import { Tools } from 'librechat-data-provider'; import type { TAttachment, UIResource } from 'librechat-data-provider'; +import { conversationAttachmentsFamily } from '~/hooks/SSE/atoms'; import { useOptionalMessagesOperations } from '~/Providers'; -import store from '~/store'; /** * Hook to collect all UI resources in a conversation, indexed by resource ID. @@ -18,9 +18,7 @@ export function useConversationUIResources( ): Map { const { getMessages } = useOptionalMessagesOperations(); - const conversationAttachmentsMap = useRecoilValue( - store.conversationAttachmentsSelector(conversationId), - ); + const conversationAttachmentsMap = useAtomValue(conversationAttachmentsFamily(conversationId)); return useMemo(() => { const map = new Map(); diff --git a/client/src/hooks/SSE/__tests__/stepEquivalence.spec.ts b/client/src/hooks/SSE/__tests__/stepEquivalence.spec.ts index e8d636a0057..b1246267afb 100644 --- a/client/src/hooks/SSE/__tests__/stepEquivalence.spec.ts +++ b/client/src/hooks/SSE/__tests__/stepEquivalence.spec.ts @@ -1,7 +1,7 @@ import React from 'react'; import { createHash } from 'crypto'; +import { RecoilRoot } from 'recoil'; import { createStore, Provider } from 'jotai'; -import { RecoilRoot, useRecoilCallback } from 'recoil'; import { renderHook, act } from '@testing-library/react'; import { Constants, @@ -25,8 +25,8 @@ import { takeRegisteredSubagentProgressKeys, subagentParentStreamOpenByToolCallId, } from '~/components/Chat/Subagents/state'; -import { ptcTraceByToolCallId, ptcTraceKey } from '~/store/ptc'; -import { sandboxStartingByToolCallId } from '~/store/sandbox'; +import { ptcTraceByToolCallId, ptcTraceKey } from '~/hooks/SSE/ptc'; +import { sandboxStartingByToolCallId } from '~/hooks/SSE/sandbox'; import useStepHandler from '~/hooks/SSE/useStepHandler'; /** @@ -478,12 +478,7 @@ function run(hook: typeof useStepHandler, row: Row) { onSkillAuthoringComplete: () => log.callbacks.push('skill'), onSubagentIndexChange: (id) => log.callbacks.push(`subagent-index:${id}`), }), - readPtc: useRecoilCallback( - ({ snapshot }) => - (key: string) => - snapshot.getLoadable(ptcTraceByToolCallId(key)).getValue(), - [], - ), + readPtc: (key: string) => store.get(ptcTraceByToolCallId(key)), }), { wrapper }, ); diff --git a/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx index c51f783b999..fced598e8f4 100644 --- a/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx +++ b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx @@ -14,10 +14,11 @@ * unrelated citations both show up. */ +import { RecoilRoot } from 'recoil'; import { Tools } from 'librechat-data-provider'; -import { RecoilRoot, useRecoilValue } from 'recoil'; import { QueryClient } from '@tanstack/react-query'; import { renderHook, act } from '@testing-library/react'; +import { Provider as JotaiProvider, useAtomValue } from 'jotai'; import type { TFile, TAttachment, @@ -25,12 +26,16 @@ import type { TAttachmentMetadata, } from 'librechat-data-provider'; import type { ReactNode } from 'react'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; import useAttachmentHandler from '../useAttachmentHandler'; -import store from '~/store'; type AttachmentFixture = TFile & TAttachmentMetadata; -const wrapper = ({ children }: { children: ReactNode }) => {children}; +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); const submission = {} as EventSubmission; const messageId = 'msg-1'; @@ -70,7 +75,7 @@ function setup() { const { result } = renderHook( () => { const handler = useAttachmentHandler(queryClient); - const map = useRecoilValue(store.messageAttachmentsMap); + const map = useAtomValue(messageAttachmentsMapAtom); ref.current = map; return handler; }, @@ -363,7 +368,7 @@ describe('useAttachmentHandler upsert-by-file_id', () => { const { result } = renderHook( () => { const handler = useAttachmentHandler(queryClient); - const map = useRecoilValue(store.messageAttachmentsMap); + const map = useAtomValue(messageAttachmentsMapAtom); ref.current = map; return handler; }, diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index b5c41c121fb..8bf9ef83289 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -103,7 +103,6 @@ const mockQueryClient = { }), }; -const mockActiveRunAtom = { key: 'activeRun' }; /** The follow-up queue a restored submission lands back in. */ const queuedIn = (conversationId: string) => getDefaultStore().get(queuedMessagesByConvoId(conversationId)); @@ -120,12 +119,7 @@ const mockSeedSteerChips = jest.fn(); const mockSettleAppliedSteerParts = jest.fn(); const mockConvertLocalSteersToQueued = jest.fn(); const mockUpdateGenerationEpoch = jest.fn(); -const mockUseSetRecoilStateMock = jest.fn((atom: unknown) => { - if (atom === mockActiveRunAtom) { - return mockSetActiveRun; - } - return jest.fn(); -}); +const mockUseSetRecoilStateMock = jest.fn((_atom: unknown) => jest.fn()); function mockUseSetRecoilState(atom: unknown) { return mockUseSetRecoilStateMock(atom); } @@ -183,6 +177,9 @@ jest.mock('jotai', () => { if (atom === generation.showStopButtonByIndex(0)) { return mockSetShowStopButton; } + if (atom === jest.requireActual('~/hooks/SSE/atoms').activeRunFamily(0)) { + return mockSetActiveRun; + } return actual.useSetAtom(atom); }, }; @@ -190,9 +187,7 @@ jest.mock('jotai', () => { jest.mock('~/store', () => ({ __esModule: true, - default: { - activeRunFamily: jest.fn(() => mockActiveRunAtom), - }, + default: {}, })); jest.mock('~/hooks/AuthContext', () => ({ diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index c31ef1b3c1a..294eb3c171a 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -1,7 +1,7 @@ import React from 'react'; -import { useStore, useAtomValue } from 'jotai'; -import { RecoilRoot, useRecoilCallback } from 'recoil'; +import { RecoilRoot } from 'recoil'; import { renderHook, act } from '@testing-library/react'; +import { useStore, useAtomValue, createStore, Provider as JotaiProvider } from 'jotai'; import { Constants, StepTypes, @@ -25,9 +25,9 @@ import { subagentProgressByToolCallId, subagentProgressKey, } from '~/components/Chat/Subagents/state'; -import { ptcTraceByToolCallId, ptcTraceKey, PTC_TRACE_MAX_ENTRIES } from '~/store/ptc'; +import { ptcTraceByToolCallId, ptcTraceKey, PTC_TRACE_MAX_ENTRIES } from '~/hooks/SSE/ptc'; +import { sandboxStartingByToolCallId } from '~/hooks/SSE/sandbox'; import { resolveAskUserQuestionPart } from '~/utils/approval'; -import { sandboxStartingByToolCallId } from '~/store/sandbox'; import useStepHandler from '~/hooks/SSE/useStepHandler'; import { IsolatedAtomStore } from 'test/harness'; @@ -4569,20 +4569,22 @@ describe('useStepHandler', () => { describe('PTC inner tool-call trace', () => { const renderWithTraceReader = () => { + const traceStore = createStore(); const hookResult = renderHook( () => { const stepHandler = useStepHandler(createHookParams()); - const read = useRecoilCallback( - ({ snapshot }) => - (messageId: string, toolCallId: string): PtcTrace => - snapshot - .getLoadable(ptcTraceByToolCallId(ptcTraceKey(messageId, toolCallId))) - .valueOrThrow(), - [], - ); + const read = (messageId: string, toolCallId: string): PtcTrace => + traceStore.get(ptcTraceByToolCallId(ptcTraceKey(messageId, toolCallId))); return { ...stepHandler, read }; }, - { wrapper: RecoilRoot }, + { + wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement( + RecoilRoot, + null, + React.createElement(JotaiProvider, { store: traceStore }, children), + ), + }, ); return { result: hookResult.result, diff --git a/client/src/hooks/SSE/atoms.ts b/client/src/hooks/SSE/atoms.ts new file mode 100644 index 00000000000..a1ec8127a19 --- /dev/null +++ b/client/src/hooks/SSE/atoms.ts @@ -0,0 +1,41 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { TAttachment } from 'librechat-data-provider'; + +/** The run the pane at this index is displaying, renewed by every `created` or resume `sync`; + * audio autoplay keys "already played" on it. */ +export const activeRunFamily = atomFamily((_index: string | number | null) => + atom(null), +); + +/** Attachments that arrived while a message streamed, by message id, ahead of the saved copy. */ +export const messageAttachmentsMapAtom = atom>({}); + +/** The streamed attachments that belong to one conversation, by message id. */ +export const conversationAttachmentsFamily = atomFamily((conversationId: string | undefined) => + atom((get) => { + if (!conversationId) { + return {}; + } + + const attachmentsMap = get(messageAttachmentsMapAtom); + const result: Record = {}; + + // Filter to only include attachments for this conversation + Object.entries(attachmentsMap).forEach(([messageId, attachments]) => { + if (!attachments || attachments.length === 0) { + return; + } + + const relevantAttachments = attachments.filter( + (attachment) => attachment.conversationId === conversationId, + ); + + if (relevantAttachments.length > 0) { + result[messageId] = relevantAttachments; + } + }); + + return result; + }), +); diff --git a/client/src/store/ptc.ts b/client/src/hooks/SSE/ptc.ts similarity index 89% rename from client/src/store/ptc.ts rename to client/src/hooks/SSE/ptc.ts index 78350325d2c..b1b9dba668e 100644 --- a/client/src/store/ptc.ts +++ b/client/src/hooks/SSE/ptc.ts @@ -1,4 +1,5 @@ -import { atomFamily } from 'recoil'; +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; import type { PtcTrace } from '~/common'; /** @@ -30,7 +31,4 @@ export const EMPTY_PTC_TRACE: PtcTrace = { entries: [], dropped: 0 }; * nothing persists them on the message. Cleared on conversation switch rather * than at run boundaries, so a finished program's trace stays readable. */ -export const ptcTraceByToolCallId = atomFamily({ - key: 'ptcTraceByToolCallId', - default: EMPTY_PTC_TRACE, -}); +export const ptcTraceByToolCallId = atomFamily((_key: string) => atom(EMPTY_PTC_TRACE)); diff --git a/client/src/store/sandbox.ts b/client/src/hooks/SSE/sandbox.ts similarity index 100% rename from client/src/store/sandbox.ts rename to client/src/hooks/SSE/sandbox.ts diff --git a/client/src/hooks/SSE/useAttachmentHandler.ts b/client/src/hooks/SSE/useAttachmentHandler.ts index ddbc5982da4..5501e91936c 100644 --- a/client/src/hooks/SSE/useAttachmentHandler.ts +++ b/client/src/hooks/SSE/useAttachmentHandler.ts @@ -1,4 +1,4 @@ -import { useSetRecoilState } from 'recoil'; +import { useSetAtom } from 'jotai'; import { QueryKeys, Tools } from 'librechat-data-provider'; import type { MemoriesResponse, @@ -7,9 +7,9 @@ import type { TFile, } from 'librechat-data-provider'; import type { QueryClient } from '@tanstack/react-query'; +import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; import { invalidateRecentFiles } from '~/utils/files'; import { handleMemoryArtifact } from '~/utils/memory'; -import store from '~/store'; /** Identity of a re-emitted web-search snapshot: its tool call, turn, agent, * and host step. Scoped to `web_search` because that is the flow whose server @@ -31,7 +31,7 @@ const searchSnapshotKeyOf = (attachment: TAttachment): string | undefined => { }; export default function useAttachmentHandler(queryClient?: QueryClient) { - const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap); + const setAttachmentsMap = useSetAtom(messageAttachmentsMapAtom); return ({ data }: { data: TAttachment; submission: EventSubmission }) => { const { messageId } = data; diff --git a/client/src/hooks/SSE/useResumablePane.ts b/client/src/hooks/SSE/useResumablePane.ts index d987ed77518..a0fbf760fab 100644 --- a/client/src/hooks/SSE/useResumablePane.ts +++ b/client/src/hooks/SSE/useResumablePane.ts @@ -18,6 +18,7 @@ import { } from '~/hooks/Chat/queue'; import { pendingApprovalActionFamily } from '~/components/Chat/approval/state'; import { clearComposerDrafts, resolveRunEndTarget } from '~/utils'; +import { activeRunFamily } from './atoms'; import store from '~/store'; /** @@ -72,7 +73,7 @@ export default function useResumablePane({ setGenerationEpoch: ResumablePane['setGenerationEpoch']; }): { pane: ResumablePane; queue: ResumableQueue } { const jotaiStore = useStore(); - const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex)); + const setActiveRunId = useSetAtom(activeRunFamily(runIndex)); const setAbortScroll = useSetAtom(abortScrollFamily(runIndex)); const setSubmission = useSetAtom(submissionByIndex(runIndex)); const setShowStopButton = useSetAtom(showStopButtonByIndex(runIndex)); diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 3a6f6b9d1a3..4e3e4f7efb5 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { v4 } from 'uuid'; -import { useSetRecoilState } from 'recoil'; import { useStore, useSetAtom } from 'jotai'; import { useQueryClient } from '@tanstack/react-query'; import { abortScrollFamily, showStopButtonByIndex } from '@librechat/chat/react'; @@ -28,7 +27,7 @@ import { useAuthContext } from '~/hooks/AuthContext'; import useEventHandlers from './useEventHandlers'; import useUsageHandler from './useUsageHandler'; import useLocalize from '~/hooks/useLocalize'; -import store from '~/store'; +import { activeRunFamily } from './atoms'; type ChatHelpers = Pick< EventHandlerParams, @@ -48,7 +47,7 @@ export default function useSSE( runIndex = 0, ) { const jotaiStore = useStore(); - const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex)); + const setActiveRunId = useSetAtom(activeRunFamily(runIndex)); const { token, isAuthenticated } = useAuthContext(); const transport = useChatTransport(); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index ba0c5fe5831..cb2a9e13ca9 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -1,6 +1,5 @@ import { useCallback, useRef } from 'react'; import { useStore } from 'jotai'; -import { useRecoilCallback } from 'recoil'; import { reduceStep, flushSummary, @@ -27,12 +26,8 @@ import { subagentProgressByToolCallId, takeRegisteredSubagentProgressKeys, } from '~/components/Chat/Subagents/state'; -import { - sandboxStartingByToolCallId, - ptcTraceByToolCallId, - PTC_TRACE_MAX_ENTRIES, - ptcTraceKey, -} from '~/store'; +import { ptcTraceByToolCallId, EMPTY_PTC_TRACE, PTC_TRACE_MAX_ENTRIES, ptcTraceKey } from './ptc'; +import { sandboxStartingByToolCallId } from './sandbox'; import { MESSAGE_UPDATE_INTERVAL } from '~/common'; type TUseStepHandler = { @@ -147,6 +142,7 @@ export default function useStepHandler({ /** PTC tool call ids with a live trace, so the atoms can be released. */ const knownPtcAtomKeys = useRef(new Set()); + const ptcStore = useStore(); /** * Folds one `on_ptc_tool_call` envelope into its program's trace: the @@ -154,65 +150,60 @@ export default function useStepHandler({ * place by `call_id`. Order follows the sandbox's dispatch order, which is * what the code reads like; a round trip can settle out of order. */ - const applyPtcToolCall = useRecoilCallback( - ({ set }) => - (event: PtcToolCallEvent, parentMessageId: string): void => { - const { tool_call_id: toolCallId, call_id: callId, name, status } = event; - /** No parent message means no occurrence to scope this to; a raw - * `tool_call_id` would leak the rows into whichever card reused it. */ - if (!toolCallId || !callId || !parentMessageId) { - return; - } - const atomKey = ptcTraceKey(parentMessageId, toolCallId); - knownPtcAtomKeys.current.add(atomKey); - set(ptcTraceByToolCallId(atomKey), (previous) => { - const index = previous.entries.findIndex((entry) => entry.callId === callId); - const entry = { - callId, - name, - status, - ...(event.args ? { args: event.args } : {}), - ...(event.error ? { error: event.error } : {}), - ...(event.durationMs != null ? { durationMs: event.durationMs } : {}), - }; - - if (index !== -1) { - const next = [...previous.entries]; - next[index] = { ...previous.entries[index], ...entry }; - return { entries: next, dropped: previous.dropped }; - } + const applyPtcToolCall = useCallback( + (event: PtcToolCallEvent, parentMessageId: string): void => { + const { tool_call_id: toolCallId, call_id: callId, name, status } = event; + /** No parent message means no occurrence to scope this to; a raw + * `tool_call_id` would leak the rows into whichever card reused it. */ + if (!toolCallId || !callId || !parentMessageId) { + return; + } + const atomKey = ptcTraceKey(parentMessageId, toolCallId); + knownPtcAtomKeys.current.add(atomKey); + ptcStore.set(ptcTraceByToolCallId(atomKey), (previous) => { + const index = previous.entries.findIndex((entry) => entry.callId === callId); + const entry = { + callId, + name, + status, + ...(event.args ? { args: event.args } : {}), + ...(event.error ? { error: event.error } : {}), + ...(event.durationMs != null ? { durationMs: event.durationMs } : {}), + }; - /** A settle whose row is gone, evicted by the cap, or pruned across - * a resume gap, must not reappear at the tail out of order. */ - if (status !== 'running') { - return previous; - } + if (index !== -1) { + const next = [...previous.entries]; + next[index] = { ...previous.entries[index], ...entry }; + return { entries: next, dropped: previous.dropped }; + } - const appended = [...previous.entries, entry]; - const overflow = appended.length - PTC_TRACE_MAX_ENTRIES; - if (overflow <= 0) { - return { entries: appended, dropped: previous.dropped }; - } - return { - entries: appended.slice(overflow), - dropped: previous.dropped + overflow, - }; - }); - }, - [], - ); + /** A settle whose row is gone, evicted by the cap, or pruned across + * a resume gap, must not reappear at the tail out of order. */ + if (status !== 'running') { + return previous; + } - const resetPtcAtoms = useRecoilCallback( - ({ reset }) => - (): void => { - for (const atomKey of knownPtcAtomKeys.current) { - reset(ptcTraceByToolCallId(atomKey)); + const appended = [...previous.entries, entry]; + const overflow = appended.length - PTC_TRACE_MAX_ENTRIES; + if (overflow <= 0) { + return { entries: appended, dropped: previous.dropped }; } - knownPtcAtomKeys.current.clear(); - }, - [], + return { + entries: appended.slice(overflow), + dropped: previous.dropped + overflow, + }; + }); + }, + [ptcStore], ); + const resetPtcAtoms = useCallback((): void => { + for (const atomKey of knownPtcAtomKeys.current) { + ptcStore.set(ptcTraceByToolCallId(atomKey), EMPTY_PTC_TRACE); + } + knownPtcAtomKeys.current.clear(); + }, [ptcStore]); + /** * Settles rows still marked `running` after a stream gap as `interrupted`. * Inner calls carry no durable state: they are not content parts, so the @@ -226,26 +217,20 @@ export default function useStepHandler({ * gone is dropped rather than re-appended out of order, and the call would * vanish from the trace despite having run. Settled rows are untouched. */ - const prunePtcTraces = useRecoilCallback( - ({ set }) => - (): void => { - for (const atomKey of knownPtcAtomKeys.current) { - set(ptcTraceByToolCallId(atomKey), (previous) => - previous.entries.some((entry) => entry.status === 'running') - ? { - ...previous, - entries: previous.entries.map((entry) => - entry.status === 'running' - ? { ...entry, status: 'interrupted' as const } - : entry, - ), - } - : previous, - ); - } - }, - [], - ); + const prunePtcTraces = useCallback((): void => { + for (const atomKey of knownPtcAtomKeys.current) { + ptcStore.set(ptcTraceByToolCallId(atomKey), (previous) => + previous.entries.some((entry) => entry.status === 'running') + ? { + ...previous, + entries: previous.entries.map((entry) => + entry.status === 'running' ? { ...entry, status: 'interrupted' as const } : entry, + ), + } + : previous, + ); + } + }, [ptcStore]); /** * Per-token deltas fold into the stream state immediately (authoritative), but diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 1bd8933ac20..579c6c6fb6d 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -228,11 +228,6 @@ const globalAudioPlayingFamily = atomFamily({ default: false, }); -const activeRunFamily = atomFamily({ - key: 'activeRunByIndex', - default: null, -}); - const audioRunFamily = atomFamily({ key: 'audioRunByIndex', default: null, @@ -336,7 +331,6 @@ export default { useSetConversationAtom, showMentionPopoverFamily, globalAudioURLFamily, - activeRunFamily, audioRunFamily, globalAudioPlayingFamily, globalAudioFetchingFamily, diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 2cf84ec0244..24ae5dfd771 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -11,8 +11,6 @@ import misc from './misc'; export * from './agents'; export * from './mcp'; export * from './favorites'; -export * from './sandbox'; -export * from './ptc'; export default { ...artifacts, diff --git a/client/src/store/misc.ts b/client/src/store/misc.ts index 0f347c5586b..3c3066c375d 100644 --- a/client/src/store/misc.ts +++ b/client/src/store/misc.ts @@ -1,51 +1,8 @@ -import { atom, selectorFamily } from 'recoil'; -import { TAttachment } from 'librechat-data-provider'; +import { atom } from 'recoil'; import { atomWithLocalStorage } from './utils'; const hideBannerHint = atomWithLocalStorage('hideBannerHint', [] as string[]); -const messageAttachmentsMap = atom>({ - key: 'messageAttachmentsMap', - default: {}, -}); - -/** - * Selector to get attachments for a specific conversation. - */ -const conversationAttachmentsSelector = selectorFamily< - Record, - string | undefined ->({ - key: 'conversationAttachments', - get: - (conversationId) => - ({ get }) => { - if (!conversationId) { - return {}; - } - - const attachmentsMap = get(messageAttachmentsMap); - const result: Record = {}; - - // Filter to only include attachments for this conversation - Object.entries(attachmentsMap).forEach(([messageId, attachments]) => { - if (!attachments || attachments.length === 0) { - return; - } - - const relevantAttachments = attachments.filter( - (attachment) => attachment.conversationId === conversationId, - ); - - if (relevantAttachments.length > 0) { - result[messageId] = relevantAttachments; - } - }); - - return result; - }, -}); - const queriesEnabled = atom({ key: 'queriesEnabled', default: true, @@ -81,8 +38,6 @@ const shortcutsEnabled = atomWithLocalStorage('keyboardShortcutsEnabled export default { hideBannerHint, - messageAttachmentsMap, - conversationAttachmentsSelector, queriesEnabled, showShortcutsDialog, keyboardDeleteTarget, From 921fbe9d518f5ecaddd55b723ba5aad0e3d0c646 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:54:58 +0200 Subject: [PATCH 2/2] fix: Evict Reset PTC Trace Atoms From the Family Cache --- client/src/hooks/SSE/__tests__/useStepHandler.spec.ts | 3 +++ client/src/hooks/SSE/useStepHandler.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index 294eb3c171a..a53f4307b51 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -4871,12 +4871,15 @@ describe('useStepHandler', () => { ); }); expect(getEntries('call_ptc')).toHaveLength(1); + const removeSpy = jest.spyOn(ptcTraceByToolCallId, 'remove'); act(() => { (result.current as unknown as { resetPtcAtoms: () => void }).resetPtcAtoms(); }); expect(getEntries('call_ptc')).toEqual([]); + expect(removeSpy).toHaveBeenCalledWith(ptcTraceKey('response-msg-1', 'call_ptc')); + removeSpy.mockRestore(); }); }); }); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index cb2a9e13ca9..b2287795e9c 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -200,6 +200,7 @@ export default function useStepHandler({ const resetPtcAtoms = useCallback((): void => { for (const atomKey of knownPtcAtomKeys.current) { ptcStore.set(ptcTraceByToolCallId(atomKey), EMPTY_PTC_TRACE); + ptcTraceByToolCallId.remove(atomKey); } knownPtcAtomKeys.current.clear(); }, [ptcStore]);