diff --git a/AGENTS.md b/AGENTS.md index 13a1570a26..78e72785f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,3 +33,14 @@ why any new custom CSS cannot be expressed by the shared system. See the detaile When adding or changing code that mutates user documents, invalidate the auth user document cache for affected users, including bulk role and user mutations. See the detailed policy in `CLAUDE.md` under “Auth cache invalidation”. + +## Client state ownership + +The client is migrating from Recoil to Jotai — convert the areas you touch, not the whole store. +Split by ownership: state a feature both writes and reads is feature-owned, so convert it to Jotai +and keep it inside the feature; app-global preferences and shell state a feature merely consumes +(`maximizeChatSpace`, `showScrollButton`, `enterToSend`, artifact visibility) must be passed in +through props or a small host-supplied context rather than reached for through `~/store`. Passing +them in is what lets a feature move to its own workspace later without a rewrite, and it keeps the +Jotai conversion scoped to the state a feature owns. See the detailed policy in `CLAUDE.md` under +“Client State Ownership”. diff --git a/CLAUDE.md b/CLAUDE.md index 7594b29027..640ae92297 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,22 @@ Multi-line imports count total character length across all lines. Consolidate va - React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations. - QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`. +### Client State Ownership + +The client is migrating from Recoil to Jotai. Convert the areas you touch rather than +migrating wholesale, and split the work by who owns the state: + +- **Feature-owned state** — atoms a single feature both writes and reads. Convert these to + Jotai as you touch them, and keep them inside the feature. +- **App-global state** — preferences and shell state a feature merely consumes + (`maximizeChatSpace`, `showScrollButton`, `enterToSend`, artifact visibility). A feature + that could plausibly be extracted must not reach into `~/store` for these; accept them + through props or a small context the host supplies. + +Passing app-global state in — rather than reaching for it — is what lets a feature move to +its own workspace later without a rewrite, and it keeps the Jotai conversion scoped to the +state a feature actually owns instead of dragging the global migration forward early. + ### Data-Provider Integration - Endpoints: `packages/data-provider/src/api-endpoints.ts` diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index be4588632c..c87cb854d3 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; +import { useSetAtom } from 'jotai'; import { ChevronRight, Users } from 'lucide-react'; import { EModelEndpoint } from 'librechat-data-provider'; -import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; import type { PartMetadata, TAttachment, @@ -9,12 +9,13 @@ import type { TMessageContentParts, } from 'librechat-data-provider'; import type { SubagentTickerLine } from '~/utils/subagentContent'; -import store, { +import { activeSubagentPanel, - subagentProgressByToolCallId, subagentProgressKey, -} from '~/store'; + useSubagentProgress, +} from '~/components/Chat/Subagents/state'; import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters'; +import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface'; import { MessageContext } from '~/Providers/MessageContext'; import { useShareContext } from '~/Providers/ShareContext'; import MessageIcon from '~/components/Share/MessageIcon'; @@ -40,7 +41,7 @@ interface SubagentCallProps { output?: string | null; attachments?: TAttachment[]; /** Aggregated content parts the backend attached to the tool_call at - * message-save time. Takes precedence over the in-memory Recoil atom + * message-save time. Takes precedence over the in-memory atom * so a page refresh shows the same history the user saw live. Older * runs recorded before the persistence path landed will not have this * field; those fall back to the atom (or the raw `output` string). */ @@ -151,7 +152,7 @@ function useThrottledValue(value: T, intervalMs: number, enabled: boolean): T * artifacts-style panel that renders the child's aggregated activity through * the shared child-activity module, so every subagent mode uses one deep view. * - * Progress is sourced from the `subagentProgressByToolCallId` Recoil atom + * Progress is sourced from the `subagentProgressByToolCallId` atom * family, populated by `useStepHandler` as `ON_SUBAGENT_UPDATE` SSE * envelopes arrive. The atom is keyed by the parent message and * `tool_call_id`, since providers may reuse tool IDs across turns. @@ -172,12 +173,9 @@ export default function SubagentCall({ const parentMessageContext = useContext(MessageContext); const parentMessageId = parentMessageContext.messageId?.trim() ?? ''; const partIndex = parentMessageContext.partIndex ?? 0; - const progress = useRecoilValue( - subagentProgressByToolCallId(subagentProgressKey(parentMessageId, toolCallId, partIndex)), - ); - const setSelectedSubagent = useSetRecoilState(activeSubagentPanel); - const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); - const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const progress = useSubagentProgress(subagentProgressKey(parentMessageId, toolCallId, partIndex)); + const setSelectedSubagent = useSetAtom(activeSubagentPanel); + const openPanel = useOpenSubagentPanel(); const agentsMap = useAgentsMapContext(); const backgroundHandle = useMemo( () => parseSubagentBackgroundHandle(output, args), @@ -301,10 +299,14 @@ export default function SubagentCall({ const canOpenLiveForeground = isSharedConvo !== true && backgroundHandle == null && hasParentContext; return ( - hasParentContext && (canOpenDurablePanel || canOpenLiveForeground || hasRenderableFallback) + /** No host, no panel to open — a search result renders this same card. */ + openPanel != null && + hasParentContext && + (canOpenDurablePanel || canOpenLiveForeground || hasRenderableFallback) ); }, [ backgroundHandle, + openPanel, canOpenDurablePanel, initialProgress, isSharedConvo, @@ -369,17 +371,9 @@ export default function SubagentCall({ }, [panelSelection, parentMessageId, partIndex, setSelectedSubagent, toolCallId]); const openDetails = useCallback(() => { - if (!canOpenDetails) return; - resetCurrentArtifactId(); - setArtifactsVisible(false); - setSelectedSubagent(panelSelection); - }, [ - canOpenDetails, - panelSelection, - resetCurrentArtifactId, - setArtifactsVisible, - setSelectedSubagent, - ]); + if (!canOpenDetails || openPanel == null) return; + openPanel(panelSelection); + }, [canOpenDetails, openPanel, panelSelection]); return ( <> diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index 5d9ae75054..80e6da7654 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { useAtomValue, useStore } from 'jotai'; import { MemoryRouter } from 'react-router-dom'; -import { RecoilRoot, useRecoilCallback, useRecoilValue } from 'recoil'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import type { SubagentUpdateEvent } from 'librechat-data-provider'; import type { @@ -8,7 +9,7 @@ import type { SubagentContentPart, SubagentTickerState, } from '~/utils/subagentContent'; -import type { ActiveSubagentPanel, SubagentProgress } from '~/store/subagents'; +import type { ActiveSubagentPanel, SubagentProgress } from '~/components/Chat/Subagents/state'; import { foldSubagentEvent, foldSubagentEventIntoTicker, @@ -19,9 +20,10 @@ import { activeSubagentPanel, subagentProgressByToolCallId, subagentProgressKey, -} from '~/store/subagents'; +} from '~/components/Chat/Subagents/state'; import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; import { MessageContext } from '~/Providers/MessageContext'; +import { ChatSurfaceHarness } from 'test/harness'; jest.mock('~/hooks', () => ({ useLocalize: @@ -108,42 +110,41 @@ function renderWithState(args: { const setter = { current: null as null | ((next: SubagentProgress | null) => void) }; let selection: ActiveSubagentPanel | null = null; const SeedHelper = () => { - setter.current = useRecoilCallback( - ({ set }) => - (next: SubagentProgress | null) => - set( - subagentProgressByToolCallId(subagentProgressKey('parent-message', args.toolCallId, 0)), - next, - ), - [], - ); + const store = useStore(); + setter.current = (next: SubagentProgress | null) => + store.set( + subagentProgressByToolCallId(subagentProgressKey('parent-message', args.toolCallId, 0)), + next, + ); return null; }; const SelectionObserver = () => { - selection = useRecoilValue(activeSubagentPanel); + selection = useAtomValue(activeSubagentPanel); return null; }; const rendered = render( - - - - - - - + + + + + + + + + , ); act(() => setter.current?.(args.progress ?? null)); @@ -257,7 +258,7 @@ describe('SubagentCall', () => { it('opens a foreground legacy invocation in the shared panel with persisted activity', () => { let selection: ActiveSubagentPanel | null = null; const Observer = () => { - selection = useRecoilValue(activeSubagentPanel); + selection = useAtomValue(activeSubagentPanel); return null; }; const persistedContent = [ @@ -270,24 +271,26 @@ describe('SubagentCall', () => { ] as Parameters[0]['persistedContent']; render( - - - - - - + + + + + + + + , ); @@ -342,18 +345,20 @@ describe('SubagentCall', () => { render( - - - - - + + + + + + + , ); @@ -361,17 +366,47 @@ describe('SubagentCall', () => { }); it('disables a nested fallback that cannot remain attached to the conversation host', () => { + render( + + + + + + + + + , + ); + + expect(screen.getByRole('button', { name: 'Ran agent' })).toBeDisabled(); + }); + + /** Search results render this same card through `Part`, and that route is not + * a chat surface — there is no panel on it to open. The card still has to + * render; it just cannot be opened from there. */ + it('renders outside a chat surface with its details control disabled', () => { render( diff --git a/client/src/components/Chat/Messages/Content/Wakeup.tsx b/client/src/components/Chat/Messages/Content/Wakeup.tsx index 0a5985f2e2..30cbb68089 100644 --- a/client/src/components/Chat/Messages/Content/Wakeup.tsx +++ b/client/src/components/Chat/Messages/Content/Wakeup.tsx @@ -1,17 +1,17 @@ import { memo, useCallback, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { Button } from '@librechat/client'; import { ChevronDown, Users } from 'lucide-react'; -import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; +import type { ActiveSubagentPanel } from '~/components/Chat/Subagents/state'; import type { WakeupDisplay, WakeupTask } from './Parts/wakeup'; -import type { ActiveSubagentPanel } from '~/store/subagents'; import type { TranslationKeys } from '~/hooks'; import { subagentStatusIcon, subagentStatusLabelKey } from '~/components/Chat/Subagents/status'; import { useParentSubagents } from '~/components/Chat/Subagents/ParentSubagentsProvider'; import { durableSubagentSelection } from '~/components/Chat/Subagents/eventSelection'; import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; +import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface'; import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; import { useShareContext } from '~/Providers/ShareContext'; -import { activeSubagentPanel } from '~/store/subagents'; import { cn, getToolDisplayLabel } from '~/utils'; import { useMessageContext } from '~/Providers'; import { StackedToolIcons } from './ToolOutput'; @@ -41,9 +41,7 @@ function WakeupTaskCard({ const { isSharedConvo } = useShareContext(); const { messageId } = useMessageContext(); const { byThreadId } = useParentSubagents(); - const setSelection = useSetRecoilState(activeSubagentPanel); - const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); - const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const openPanel = useOpenSubagentPanel(); const child = task.threadId == null ? undefined : byThreadId.get(task.threadId); const selection = useMemo(() => { /** Share pages have no authenticated durable-thread panel; a conversation @@ -83,11 +81,9 @@ function WakeupTaskCard({ const hasResult = task.result.trim() !== ''; const openActivity = useCallback(() => { - if (selection == null) return; - resetCurrentArtifactId(); - setArtifactsVisible(false); - setSelection(selection); - }, [resetCurrentArtifactId, selection, setArtifactsVisible, setSelection]); + if (selection == null || openPanel == null) return; + openPanel(selection); + }, [openPanel, selection]); return (
@@ -99,7 +95,7 @@ function WakeupTaskCard({ /> {title !== '' && {title}} {localize(subagentStatusLabelKey(status))} - {selection != null && ( + {selection != null && openPanel != null && ( /** The trigger identity attributes let the panel's close handler * return keyboard focus to this button. */