From f433e3484282d28b4019d3c4db01de76db4e60d1 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:24:40 +0200 Subject: [PATCH] refactor: Move Pane Preset and Option Settings Atoms to Jotai --- client/src/common/types.ts | 6 ++-- .../__tests__/ChatForm.approvalMode.spec.tsx | 2 -- .../__tests__/ChatForm.attachments.spec.tsx | 2 -- .../__tests__/ChatForm.enterToSend.spec.tsx | 2 -- .../__tests__/ChatForm.pasteUpload.spec.tsx | 2 -- .../__tests__/ChatForm.queryParams.spec.tsx | 2 -- .../src/components/Chat/Menus/PresetsMenu.tsx | 6 ++-- .../src/hooks/Chat/__tests__/facade.spec.tsx | 2 -- .../hooks/Chat/__tests__/provider.spec.tsx | 2 -- .../hooks/Chat/__tests__/submitting.spec.tsx | 8 ++--- client/src/hooks/Chat/contract.ts | 4 --- client/src/hooks/Chat/useChatHelpers.ts | 11 +++---- .../Config/__tests__/useClearStates.spec.tsx | 32 +++++++++++++++++++ client/src/hooks/Config/useClearStates.ts | 10 +++--- client/src/hooks/Conversations/usePresets.ts | 6 ++-- client/src/store/families.ts | 22 ++----------- packages/chat/src/react.ts | 2 ++ .../src/react/pane/__tests__/pane.spec.ts | 22 +++++++++++++ packages/chat/src/react/pane/index.ts | 3 ++ packages/chat/src/react/pane/options.ts | 17 ++++++++++ packages/chat/src/react/pane/preset.ts | 12 +++++++ 21 files changed, 113 insertions(+), 62 deletions(-) create mode 100644 packages/chat/src/react/pane/__tests__/pane.spec.ts create mode 100644 packages/chat/src/react/pane/index.ts create mode 100644 packages/chat/src/react/pane/options.ts create mode 100644 packages/chat/src/react/pane/preset.ts diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 3e0d75715a3..ef32a10b790 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -1,5 +1,6 @@ import { RefObject } from 'react'; import { FileSources, isEphemeralAgentId } from 'librechat-data-provider'; +import type { PaneOptionSettings } from '@librechat/chat/react'; import type { UseMutationResult } from '@tanstack/react-query'; import type * as t from 'librechat-data-provider'; import type { LucideIcon } from 'lucide-react'; @@ -506,10 +507,7 @@ export type PromptOption = MentionOption & { id: string; }; -export type TOptionSettings = { - showExamples?: boolean; - isCodeChat?: boolean; -}; +export type TOptionSettings = PaneOptionSettings; export interface ExtendedFile { file?: File; diff --git a/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx b/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx index d580a926482..0869a161260 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx @@ -61,8 +61,6 @@ function Harness() { ask: () => undefined, regenerate: () => undefined, setSiblingIdx: () => undefined, - showPopover: false, - setShowPopover: () => undefined, abortScroll: false, setAbortScroll: () => undefined, preset: null, diff --git a/client/src/components/Chat/Input/__tests__/ChatForm.attachments.spec.tsx b/client/src/components/Chat/Input/__tests__/ChatForm.attachments.spec.tsx index d490de9b82e..621964e5bfa 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.attachments.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.attachments.spec.tsx @@ -125,8 +125,6 @@ function Harness() { }, regenerate: () => undefined, setSiblingIdx: () => undefined, - showPopover: false, - setShowPopover: () => undefined, abortScroll: false, setAbortScroll: () => undefined, preset: null, diff --git a/client/src/components/Chat/Input/__tests__/ChatForm.enterToSend.spec.tsx b/client/src/components/Chat/Input/__tests__/ChatForm.enterToSend.spec.tsx index 0120014c337..d93625ad551 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.enterToSend.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.enterToSend.spec.tsx @@ -57,8 +57,6 @@ function Harness({ enterToSend }: { enterToSend: boolean }) { ask: () => undefined, regenerate: () => undefined, setSiblingIdx: () => undefined, - showPopover: false, - setShowPopover: () => undefined, abortScroll: false, setAbortScroll: () => undefined, preset: null, diff --git a/client/src/components/Chat/Input/__tests__/ChatForm.pasteUpload.spec.tsx b/client/src/components/Chat/Input/__tests__/ChatForm.pasteUpload.spec.tsx index ab35e165c3f..1268c153de4 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.pasteUpload.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.pasteUpload.spec.tsx @@ -93,8 +93,6 @@ function Harness() { ask: mockAsk, regenerate: () => undefined, setSiblingIdx: () => undefined, - showPopover: false, - setShowPopover: () => undefined, abortScroll: false, setAbortScroll: () => undefined, preset: null, diff --git a/client/src/components/Chat/Input/__tests__/ChatForm.queryParams.spec.tsx b/client/src/components/Chat/Input/__tests__/ChatForm.queryParams.spec.tsx index ee7e4ce1779..08b60788ddb 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.queryParams.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.queryParams.spec.tsx @@ -86,8 +86,6 @@ function Harness({ ask: onAsk, regenerate: () => undefined, setSiblingIdx: () => undefined, - showPopover: false, - setShowPopover: () => undefined, abortScroll: false, setAbortScroll: () => undefined, preset: null, diff --git a/client/src/components/Chat/Menus/PresetsMenu.tsx b/client/src/components/Chat/Menus/PresetsMenu.tsx index 4cf5edc7334..80b733dd7c9 100644 --- a/client/src/components/Chat/Menus/PresetsMenu.tsx +++ b/client/src/components/Chat/Menus/PresetsMenu.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Trans } from 'react-i18next'; -import { useRecoilValue } from 'recoil'; import { BookCopy } from 'lucide-react'; +import { presetFamily } from '@librechat/chat/react'; import { tConvoUpdateSchema } from 'librechat-data-provider'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; import { @@ -18,7 +19,6 @@ import { SaveAsPresetDialog } from '~/components/Endpoints'; import { EditPresetDialog, PresetItems } from './Presets'; import { useLocalize, usePresets } from '~/hooks'; import { useChatContext } from '~/Providers'; -import store from '~/store'; const PresetsMenu: FC = () => { const localize = useLocalize(); @@ -41,7 +41,7 @@ const PresetsMenu: FC = () => { presetToDelete, confirmDeletePreset, } = usePresets(); - const presetToEdit = useRecoilValue(store.presetByIndex(0)); + const presetToEdit = useAtomValue(presetFamily(0)); const openSaveAsDialog = () => { if (!conversation) { diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx index 18539eee2f3..186dc4e79d7 100644 --- a/client/src/hooks/Chat/__tests__/facade.spec.tsx +++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx @@ -65,8 +65,6 @@ const createContract = (overrides: Partial = {}): ChatContract => setFiles: noop, filesLoading: false, setFilesLoading: noop, - showPopover: false, - setShowPopover: noop, feedbackEnabled: false, ...overrides, }; diff --git a/client/src/hooks/Chat/__tests__/provider.spec.tsx b/client/src/hooks/Chat/__tests__/provider.spec.tsx index 3a3c09ef249..6df68434165 100644 --- a/client/src/hooks/Chat/__tests__/provider.spec.tsx +++ b/client/src/hooks/Chat/__tests__/provider.spec.tsx @@ -45,8 +45,6 @@ const contract: ChatContract = { setFiles: noop, filesLoading: false, setFilesLoading: noop, - showPopover: false, - setShowPopover: noop, feedbackEnabled: false, }; diff --git a/client/src/hooks/Chat/__tests__/submitting.spec.tsx b/client/src/hooks/Chat/__tests__/submitting.spec.tsx index 07ade0e2827..d3547c9f2a7 100644 --- a/client/src/hooks/Chat/__tests__/submitting.spec.tsx +++ b/client/src/hooks/Chat/__tests__/submitting.spec.tsx @@ -18,17 +18,17 @@ describe('useIsSubmitting beside a Recoil atom', () => { const commits: Array<{ text: string; submitting: boolean }> = []; const { result } = renderHook( () => { - const preset = useRecoilValue(store.presetByIndex(0)); + const prompt = useRecoilValue(store.activePromptByIndex(0)); const submitting = useIsSubmitting(0); - commits.push({ text: preset?.title ?? '', submitting }); - return useSetRecoilState(store.presetByIndex(0)); + commits.push({ text: prompt ?? '', submitting }); + return useSetRecoilState(store.activePromptByIndex(0)); }, { wrapper }, ); act(() => { jotaiStore.set(isSubmittingFamily(0), false); - result.current({ endpoint: null, title: 'next' }); + result.current('next'); }); const withNewText = commits.filter(({ text }) => text === 'next'); diff --git a/client/src/hooks/Chat/contract.ts b/client/src/hooks/Chat/contract.ts index 87b568a7230..e7e62133b1a 100644 --- a/client/src/hooks/Chat/contract.ts +++ b/client/src/hooks/Chat/contract.ts @@ -144,10 +144,6 @@ export type ChatFilesContract = { /** Chat-scoped view state that has no AI SDK counterpart. */ export type ChatViewContract = { - /** Whether the pane's settings popover is open. */ - showPopover: boolean; - /** Opens or closes the pane's settings popover. */ - setShowPopover: Dispatch>; /** Whether message feedback controls are enabled by the startup config. */ feedbackEnabled: boolean; }; diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index 4f0c85cdd7c..927db58ef22 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -4,6 +4,8 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useAtom, useSetAtom, useStore, useAtomValue } from 'jotai'; import { Constants, QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider'; import { + presetFamily, + optionSettingsFamily, activeGenerationCreatedAtByConvoId, activeGenerationProtocolVersionByConvoId, isSubmittingFamily, @@ -382,10 +384,9 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac [continueGeneration], ); - const [preset, setPreset] = useRecoilState(store.presetByIndex(index)); - const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index)); + const [preset, setPreset] = useAtom(presetFamily(index)); const [abortScroll, setAbortScroll] = useAtom(abortScrollFamily(index)); - const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(index)); + const [optionSettings, setOptionSettings] = useAtom(optionSettingsFamily(index)); /** Read once per chat rather than per message row: message rows never unmount, so a * per-row settings read would accumulate for the length of the conversation. */ @@ -412,8 +413,6 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac handleStopGenerating, handleRegenerate, handleContinue, - showPopover, - setShowPopover, abortScroll, setAbortScroll, preset, @@ -445,8 +444,6 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac handleStopGenerating, handleRegenerate, handleContinue, - showPopover, - setShowPopover, abortScroll, setAbortScroll, preset, diff --git a/client/src/hooks/Config/__tests__/useClearStates.spec.tsx b/client/src/hooks/Config/__tests__/useClearStates.spec.tsx index a93899ed70a..9e52e083d3d 100644 --- a/client/src/hooks/Config/__tests__/useClearStates.spec.tsx +++ b/client/src/hooks/Config/__tests__/useClearStates.spec.tsx @@ -4,10 +4,13 @@ import { act, renderHook } from '@testing-library/react'; import { createStore, Provider as JotaiProvider } from 'jotai'; import { Constants, ReasoningEffort } from 'librechat-data-provider'; import { + presetFamily, abortScrollFamily, + optionSettingsFamily, showStopButtonByIndex, submissionStartFamily, } from '@librechat/chat/react'; +import type { TPreset } from 'librechat-data-provider'; import { pendingQuotesByConvoId, pendingManualSkillsByConvoId, @@ -119,4 +122,33 @@ describe('useClearStates', () => { expected.map((kept) => (kept ? 1000 : null)), ); }); + /* The preset and option toggles of each pane are per-session choices: a pane + that survives sign-out must not hand them to the next account. */ + it.each([ + [false, [null, null]], + [true, ['kept', null]], + ])('resets per-pane preset and option settings (skipFirst %s)', async (skipFirst, expected) => { + const jotaiStore = createStore(); + for (const key of [0, 1]) { + jotaiStore.set(presetFamily(key), { title: 'kept' } as TPreset); + jotaiStore.set(optionSettingsFamily(key), { showExamples: true }); + } + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + set(store.conversationKeysAtom, [0, 1])}> + {children} + + + ); + + const { result } = renderHook(() => useClearStates(), { wrapper }); + await act(async () => { + await result.current(skipFirst); + }); + + expect([0, 1].map((key) => jotaiStore.get(presetFamily(key))?.title ?? null)).toEqual(expected); + expect( + [0, 1].map((key) => jotaiStore.get(optionSettingsFamily(key)).showExamples ?? null), + ).toEqual(expected.map((kept) => (kept ? true : null))); + }); }); diff --git a/client/src/hooks/Config/useClearStates.ts b/client/src/hooks/Config/useClearStates.ts index 27e53b69658..0615df9fccc 100644 --- a/client/src/hooks/Config/useClearStates.ts +++ b/client/src/hooks/Config/useClearStates.ts @@ -1,11 +1,14 @@ import { useStore } from 'jotai'; import { useRecoilCallback } from 'recoil'; import { Constants } from 'librechat-data-provider'; -import { isSubmittingFamily, useClearSubmissions } from '@librechat/chat/react'; import { + presetFamily, abortScrollFamily, + isSubmittingFamily, + optionSettingsFamily, showStopButtonByIndex, submissionStartFamily, + useClearSubmissions, } from '@librechat/chat/react'; import { getReasoningStateKey, @@ -57,13 +60,12 @@ export default function useClearStates() { } reset(store.filesByIndex(key)); - reset(store.presetByIndex(key)); + jotaiStore.set(presetFamily(key), null); jotaiStore.set(showStopButtonByIndex(key), false); jotaiStore.set(abortScrollFamily(key), false); jotaiStore.set(submissionStartFamily(key), null); jotaiStore.set(isSubmittingFamily(key), false); - reset(store.optionSettingsFamily(key)); - reset(store.showPopoverFamily(key)); + jotaiStore.set(optionSettingsFamily(key), {}); reset(store.showMentionPopoverFamily(key)); reset(store.showPlusPopoverFamily(key)); reset(store.showPromptsPopoverFamily(key)); diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index 93929fef108..28a949970f1 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import filenamify from 'filenamify'; import exportFromJSON from 'export-from-json'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; import { QueryKeys } from 'librechat-data-provider'; +import { presetFamily } from '@librechat/chat/react'; import { useQueryClient } from '@tanstack/react-query'; import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil'; import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query'; @@ -41,8 +43,8 @@ export default function usePresets(index = 0) { const setPresetModalVisible = useSetRecoilState(store.presetModalVisible); const [_defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset); const presetsQuery = useGetPresetsQuery({ enabled: !!user && isAuthenticated }); - const preset = useRecoilValue(store.presetByIndex(index)); - const setPreset = useSetRecoilState(store.presetByIndex(index)); + const preset = useAtomValue(presetFamily(index)); + const setPreset = useSetAtom(presetFamily(index)); const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const { data: modelsData } = useGetModelsQuery(); const { newConversation } = useNewConvo(index); diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 1bd8933ac20..4856057c704 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -10,8 +10,8 @@ import { useSetRecoilState, useRecoilCallback, } from 'recoil'; -import type { EModelEndpoint, TConversation, TPreset } from 'librechat-data-provider'; -import type { TOptionSettings, ExtendedFile } from '~/common'; +import type { EModelEndpoint, TConversation } from 'librechat-data-provider'; +import type { ExtendedFile } from '~/common'; import { clearModelForNonEphemeralAgent, createChatSearchParams, @@ -159,21 +159,6 @@ const conversationAssistantIdByIndex = selectorFamily({ - key: 'presetByIndex', - default: null, -}); - -const optionSettingsFamily = atomFamily({ - key: 'optionSettingsByIndex', - default: {}, -}); - -const showPopoverFamily = atomFamily({ - key: 'showPopoverByIndex', - default: false, -}); - const activePromptByIndex = atomFamily({ key: 'activePromptByIndex', default: undefined, @@ -320,9 +305,6 @@ export default { conversationKeysAtom, conversationByIndex, filesByIndex, - presetByIndex, - optionSettingsFamily, - showPopoverFamily, conversationIdByIndex, conversationEndpointByIndex, effectiveEndpointByIndex, diff --git a/packages/chat/src/react.ts b/packages/chat/src/react.ts index b246378d022..c742df44acb 100644 --- a/packages/chat/src/react.ts +++ b/packages/chat/src/react.ts @@ -74,3 +74,5 @@ export type { SteerNotification, SteerRequestCallbacks, } from './react/steer/host'; +export { presetFamily, optionSettingsFamily } from './react/pane'; +export type { PaneOptionSettings } from './react/pane'; diff --git a/packages/chat/src/react/pane/__tests__/pane.spec.ts b/packages/chat/src/react/pane/__tests__/pane.spec.ts new file mode 100644 index 00000000000..73669033106 --- /dev/null +++ b/packages/chat/src/react/pane/__tests__/pane.spec.ts @@ -0,0 +1,22 @@ +import { createStore } from 'jotai'; +import type { TPreset } from 'librechat-data-provider'; +import { presetFamily, optionSettingsFamily } from '../index'; + +describe('pane families', () => { + it('start empty for every pane', () => { + const store = createStore(); + expect(store.get(presetFamily(0))).toBeNull(); + expect(store.get(optionSettingsFamily(0))).toEqual({}); + }); + + it('keep each pane independent', () => { + const store = createStore(); + store.set(presetFamily(0), { title: 'first' } as TPreset); + store.set(optionSettingsFamily(0), { showExamples: true }); + + expect(store.get(presetFamily(1))).toBeNull(); + expect(store.get(optionSettingsFamily(1))).toEqual({}); + expect(store.get(presetFamily(0))?.title).toBe('first'); + expect(store.get(optionSettingsFamily(0)).showExamples).toBe(true); + }); +}); diff --git a/packages/chat/src/react/pane/index.ts b/packages/chat/src/react/pane/index.ts new file mode 100644 index 00000000000..3f76a16735f --- /dev/null +++ b/packages/chat/src/react/pane/index.ts @@ -0,0 +1,3 @@ +export { presetFamily } from './preset'; +export { optionSettingsFamily } from './options'; +export type { PaneOptionSettings } from './options'; diff --git a/packages/chat/src/react/pane/options.ts b/packages/chat/src/react/pane/options.ts new file mode 100644 index 00000000000..e76dac4a885 --- /dev/null +++ b/packages/chat/src/react/pane/options.ts @@ -0,0 +1,17 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { PrimitiveAtom } from 'jotai'; +import type { Family } from '../steer/family'; + +/** Legacy per-pane option toggles (examples, code chat). */ +export type PaneOptionSettings = { + showExamples?: boolean; + isCodeChat?: boolean; +}; + +/** Option toggles of one chat pane, keyed by pane index. */ +export const optionSettingsFamily: Family< + PaneOptionSettings, + PrimitiveAtom, + string | number +> = atomFamily((_index: string | number) => atom({})); diff --git a/packages/chat/src/react/pane/preset.ts b/packages/chat/src/react/pane/preset.ts new file mode 100644 index 00000000000..23a73555bdd --- /dev/null +++ b/packages/chat/src/react/pane/preset.ts @@ -0,0 +1,12 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { TPreset } from 'librechat-data-provider'; +import type { PrimitiveAtom } from 'jotai'; +import type { Family } from '../steer/family'; + +/** The preset applied to one chat pane, keyed by pane index. */ +export const presetFamily: Family< + TPreset | null, + PrimitiveAtom, + string | number +> = atomFamily((_index: string | number) => atom(null));