diff --git a/client/src/Providers/ChatHostContext.tsx b/client/src/Providers/ChatHostContext.tsx index becf077e124..cb110e39e1c 100644 --- a/client/src/Providers/ChatHostContext.tsx +++ b/client/src/Providers/ChatHostContext.tsx @@ -1,16 +1,12 @@ import { useMemo } from 'react'; -import { useSetAtom } from 'jotai'; import { Constants, QueryKeys } from 'librechat-data-provider'; import { hashQueryKey, useQueryClient } from '@tanstack/react-query'; import type { ChatHost, ChatMessageCache, ChatMessagesQuery } from '@librechat/chat/react'; import type { TMessage, TAgentQueuedTurnReceipt } from 'librechat-data-provider'; import type { QueryClient, QueryCacheNotifyEvent } from '@tanstack/react-query'; -import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; -import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/state'; import { useAbortStreamMutation, useGetMessagesByConvoId } from '~/data-provider'; import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns'; import { useChatSettings } from '~/Providers/ChatSettingsContext'; -import { getMappingOptions } from '~/hooks/Chat/outcomes'; import useTurnServices from '~/hooks/Chat/services'; import useNewConvo from '~/hooks/useNewConvo'; @@ -113,15 +109,11 @@ export const appChatHost: ChatHost = { usePaneConversation: (index) => useChatSettings().usePaneConversationState(index), useNewConversation: (index) => useNewConvo(index).newConversation, useTurnServices, - useLatestMessage: (index, key) => useLatestMessage(index, key), - useLatestMessageId: (index, key) => useLatestMessageId(index, key), - useSetSiblingIdx: (parentMessageId) => useSetAtom(siblingIdxFamily(siblingKey(parentMessageId))), /** `mutateAsync` is bound once per observer; the mutation result object is fresh every * render and would defeat the contract's memo. */ useAbortStream: () => useAbortStreamMutation().mutateAsync, useMessageCache, useFeedbackEnabled: () => useChatSettings().config.feedbackEnabled, - getMappingOptions, useMessages, }; diff --git a/client/src/Providers/SteerHostContext.tsx b/client/src/Providers/SteerHostContext.tsx index 9a05b601c1e..56b8fd6bd53 100644 --- a/client/src/Providers/SteerHostContext.tsx +++ b/client/src/Providers/SteerHostContext.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { QueryKeys } from 'librechat-data-provider'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; @@ -24,7 +25,6 @@ import { agentQueuedTurnsQueryKey } from '~/data-provider/SSE/queuedTurns'; import useCodeApprovalMode from '~/hooks/Agents/useCodeApprovalMode'; import { streamStatusQueryKey } from '~/data-provider/SSE/queries'; import { useChatSettings } from '~/Providers/ChatSettingsContext'; -import { useLatestMessage } from '~/hooks/Messages'; import { useSetFilesToDelete } from '~/hooks/Files'; import { useFileMapContext } from '~/Providers'; import useLocalize from '~/hooks/useLocalize'; @@ -113,9 +113,9 @@ export const appSteerHost: SteerHost = { useAgentQueuedTurns(conversationId, enabled, clientRequestIds, reconcileUntil, expectsReceipts) .data, useFetchStreamStatus: () => fetchStreamStatus, - useLatestMessage: (index, conversationId) => useLatestMessage(index, conversationId), - useCodeApprovalMode: (conversation, addedConversation, codeEnvironmentMode) => - useCodeApprovalMode(conversation, addedConversation, codeEnvironmentMode).selected, + useRouteConversationId: () => useParams().conversationId, + useCodeApprovalInputs: (conversation, addedConversation) => + useCodeApprovalMode(conversation, addedConversation).readInputs(), useLocalize, useNotify, useFileMap: useFileMapContext, 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 c46c8fba6e8..7bcc10fc212 100644 --- a/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/ChatForm.approvalMode.spec.tsx @@ -24,6 +24,7 @@ jest.mock('~/hooks/Agents/useCodeApprovalMode', () => ({ available: true, modes: ['ask', 'acceptEdits'], selected: 'ask', + readInputs: () => ({ conversation: null, reachable: { agents: [], complete: true } }), }), })); diff --git a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx index 3a81c168995..7436ffbc8ca 100644 --- a/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx +++ b/client/src/components/Chat/__tests__/ChatView.subagent.spec.tsx @@ -51,10 +51,6 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); -jest.mock('~/hooks/Chat/outcomes', () => ({ - getMappingOptions: () => ({}), -})); - jest.mock('~/Providers', () => { const Passthrough = ({ children }: { children: React.ReactNode }) => <>{children}; const Context = { Provider: Passthrough }; diff --git a/client/src/hooks/Chat/__tests__/facade.spec.tsx b/client/src/hooks/Chat/__tests__/facade.spec.tsx index a1f4eecd527..b328044beee 100644 --- a/client/src/hooks/Chat/__tests__/facade.spec.tsx +++ b/client/src/hooks/Chat/__tests__/facade.spec.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { getMappingOptions } from '@librechat/chat'; import { act, render, renderHook } from '@testing-library/react'; import { QueryKeys, Constants, ContentTypes } from 'librechat-data-provider'; import { useChat, ChatContext, useChatActions, resumeRequestsAtom } from '@librechat/chat/react'; @@ -8,7 +9,6 @@ import type { ChatContract } from '../contract'; import type { JotaiStore } from 'test/harness'; import { createMessageCache } from '~/Providers/ChatHostContext'; import { IsolatedAtomStore } from 'test/harness'; -import { getMappingOptions } from '../outcomes'; const userMessage: TMessage = { messageId: 'user-1', diff --git a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx index 17aa0d0c337..425380f9735 100644 --- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useSteering } from '@librechat/chat/react'; import { RecoilRoot, type MutableSnapshot } from 'recoil'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, renderHook, waitFor } from '@testing-library/react'; import { useQueueDrain, isSubmittingFamily, resetSubmitting } from '@librechat/chat/react'; import { claimQueuedIntent, releaseQueuedIntent, applyPendingAction } from '@librechat/chat'; @@ -17,12 +18,15 @@ import { } from '@librechat/chat'; import { Constants, + QueryKeys, ContentTypes, EModelEndpoint, LocalStorageKeys, ReasoningEffort, } from 'librechat-data-provider'; import { + siblingKey, + siblingIdxFamily, getReasoningStateKey, pendingQuotesByConvoId, pendingManualSkillsByConvoId, @@ -68,17 +72,51 @@ const mockEnqueueQueuedTurn = jest.fn(); const mockCancelQueuedTurn = jest.fn(); const mockShowToast = jest.fn(); const mockMarkUsage = jest.fn(); -let mockMessages: TMessage[] | undefined; -let mockLatestMessage: TMessage | null | undefined; +let mockRouteParams: { conversationId?: string } = {}; +let queryClient: QueryClient; let mockServerQueuedTurns: unknown[] | undefined; let mockFileMap: Record> = {}; const mockUseAgentQueuedTurns = jest.fn((..._args: unknown[]) => ({ data: mockServerQueuedTurns })); -const mockCodeApprovalMode = jest.fn((..._args: unknown[]) => ({ selected: mockApprovalMode })); +/** Raw inputs under which the package selects `mockApprovalMode`; `undefined` turns approvals off. */ +const mockCodeApprovalMode = jest.fn((conversation?: unknown, _added?: unknown) => ({ + readInputs: () => ({ + conversation, + preferred: mockApprovalMode, + statefulCodeSessions: { + approvalsEnabled: mockApprovalMode != null, + approvalModes: ['ask', 'acceptEdits', 'fullAccess'], + environments: [ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + configSchema: { + permissions: { + fileWrite: { allowed: ['ask', 'allow'], default: 'ask' }, + commandExecution: { allowed: ['ask', 'allow'], default: 'ask' }, + }, + }, + }, + ], + }, + reachable: { + agents: [ + { + id: 'agent_primary', + stateful_code_sessions: true, + tools: ['execute_code'], + code_environment_id: 'personal-vm', + }, + ], + complete: true, + }, + }), +})); jest.mock('~/hooks/Agents/useCodeApprovalMode', () => ({ __esModule: true, - default: (...args: unknown[]) => mockCodeApprovalMode(...args), + default: (conversation?: unknown, added?: unknown) => mockCodeApprovalMode(conversation, added), })); jest.mock('~/Providers', () => ({ @@ -129,11 +167,9 @@ jest.mock('~/data-provider', () => ({ (value as { generationProtocolVersion?: unknown }).generationProtocolVersion === 2, })); -jest.mock('~/hooks/Messages', () => ({ - useLatestMessage: () => - mockLatestMessage === undefined - ? (mockMessages?.[mockMessages.length - 1] ?? null) - : mockLatestMessage, +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => mockRouteParams, })); jest.mock('@librechat/client', () => ({ @@ -145,6 +181,23 @@ const agentsConversation = { endpoint: EModelEndpoint.agents, } as TConversation; +/** Writes the real message cache the hook reads its branch tail from, under one conversation key. */ +function seedMessages(messages: TMessage[] | undefined, key: string = CONVO_ID) { + if (messages == null) { + queryClient.removeQueries([QueryKeys.messages, key]); + return; + } + queryClient.setQueryData([QueryKeys.messages, key], messages); +} + +function SteerProvider({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); +} + type HookParams = Partial[0]>; const withActiveGeneration = @@ -173,10 +226,10 @@ function setup(params: HookParams = {}, initialize?: (snapshot: MutableSnapshot) - + {children} - + ); const rendered = renderHook( @@ -244,8 +297,9 @@ describe('useSteering', () => { jest.clearAllMocks(); mockApprovalMode = undefined; getDefaultStore().set(revealedQueuedTurnFamily(CONVO_ID), null); - mockMessages = undefined; - mockLatestMessage = undefined; + mockRouteParams = {}; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + getDefaultStore().set(siblingIdxFamily(siblingKey(CONVO_ID)), 0); mockServerQueuedTurns = undefined; mockFileMap = {}; }); @@ -319,7 +373,7 @@ describe('useSteering', () => { getDefaultStore().set(isSubmittingFamily(0), false); })} > - {children} + {children} ); const { result } = renderHook( @@ -410,7 +464,7 @@ describe('useSteering', () => { getDefaultStore().set(isSubmittingFamily(0), false); })} > - {children} + {children} ); const { result } = renderHook( @@ -532,7 +586,7 @@ describe('useSteering', () => { }); it('degrades to queue while paused on a tool approval', () => { - mockMessages = [ + seedMessages([ { messageId: 'resp-1', isCreatedByUser: false, @@ -548,7 +602,7 @@ describe('useSteering', () => { }, ], } as unknown as TMessage, - ]; + ]); // Explicitly request steer so this verifies the pause fallback to queue. const { result } = setup({}, () => { getDefaultStore().set(duringRunActionAtom, 'steer'); @@ -697,7 +751,7 @@ describe('useSteering', () => { const sendNow = jest.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); const rendered = renderHook( @@ -724,13 +778,13 @@ describe('useSteering', () => { } beforeEach(() => { - mockMessages = [ + seedMessages([ { messageId: 'visible-assistant-tail', isCreatedByUser: false, content: [], } as unknown as TMessage, - ]; + ]); }); it.each([undefined, 'ask', 'acceptEdits', 'fullAccess'] as const)( @@ -756,23 +810,54 @@ describe('useSteering', () => { result.current.steering.queueFromComposer('run later'); await Promise.resolve(); }); - expect(mockCodeApprovalMode).toHaveBeenLastCalledWith( - agentsConversation, - addedConversation, - undefined, - ); + expect(mockCodeApprovalMode).toHaveBeenLastCalledWith(agentsConversation, addedConversation); expect(mockEnqueueQueuedTurn.mock.calls[0]?.[0].codeApprovalMode).toBe('ask'); }); - it('gates the queued mode on the workspace mode the composer resolved', () => { - setupServerQueue(withActiveGeneration(), undefined, 'without_attached'); - expect(mockCodeApprovalMode).toHaveBeenLastCalledWith( - agentsConversation, - undefined, - 'without_attached', + const tailOf = (messageId: string) => + ({ messageId, isCreatedByUser: false, content: [] }) as unknown as TMessage; + + it("anchors on the saved conversation's cache, not the route's or another chat's", async () => { + mockRouteParams = { conversationId: 'route-conversation' }; + seedMessages([tailOf('route-tail')], 'route-conversation'); + seedMessages([tailOf('other-tail')], 'other-conversation'); + const { result } = setupServerQueue(); + + await act(async () => { + result.current.steering.queueFromComposer('stay on this chat'); + await Promise.resolve(); + }); + + expect(mockEnqueueQueuedTurn.mock.calls[0]?.[0].parentMessageId).toBe( + 'visible-assistant-tail', ); }); + it('follows the cache when history hydrates after the hook mounted', async () => { + const { result } = setupServerQueue(); + + await act(async () => { + seedMessages([tailOf('hydrated-tail')]); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await act(async () => { + result.current.steering.queueFromComposer('after hydration'); + await Promise.resolve(); + }); + + expect(mockEnqueueQueuedTurn.mock.calls[0]?.[0].parentMessageId).toBe('hydrated-tail'); + }); + + it('gates the queued mode on the workspace mode the composer resolved', async () => { + mockApprovalMode = 'acceptEdits'; + const { result } = setupServerQueue(withActiveGeneration(), undefined, 'without_attached'); + await act(async () => { + result.current.steering.queueFromComposer('run later'); + await Promise.resolve(); + }); + expect(mockEnqueueQueuedTurn.mock.calls[0]?.[0].codeApprovalMode).toBe('ask'); + }); + it('keeps startup turns local until the server generation epoch exists', async () => { const { result } = setupServerQueue( withActiveGeneration(() => { @@ -936,7 +1021,7 @@ describe('useSteering', () => { ['regenerated response', 'old-assistant_'], ['edited response', 'old-assistant'], ])('anchors an optimistic %s through its durable user parent', async (_label, messageId) => { - mockMessages = [ + seedMessages([ { messageId: 'user-message', parentMessageId: Constants.NO_PARENT, @@ -961,7 +1046,7 @@ describe('useSteering', () => { isCreatedByUser: false, clientQueueParentMessageId: 'user-message', } as TMessage, - ]; + ]); const { result } = setupServerQueue(); await act(async () => { @@ -979,13 +1064,13 @@ describe('useSteering', () => { }); it('preserves an exact persisted assistant id ending in an underscore', async () => { - mockMessages = [ + seedMessages([ { messageId: 'persisted-assistant_', parentMessageId: 'user-message', isCreatedByUser: false, } as TMessage, - ]; + ]); const { result } = setupServerQueue(); await act(async () => { @@ -1374,7 +1459,7 @@ describe('useSteering', () => { }); it('anchors enqueue to the selected branch tail instead of a later hidden sibling', async () => { - mockMessages = [ + seedMessages([ { messageId: 'visible-assistant-tail', isCreatedByUser: false, @@ -1385,8 +1470,8 @@ describe('useSteering', () => { isCreatedByUser: false, content: [], } as unknown as TMessage, - ]; - mockLatestMessage = mockMessages[0]; + ]); + getDefaultStore().set(siblingIdxFamily(siblingKey(CONVO_ID)), 1); const { result } = setupServerQueue(); await act(async () => { @@ -1644,7 +1729,7 @@ describe('useSteering', () => { ]); })} > - {children} + {children} ); const { result } = renderHook( @@ -1851,7 +1936,7 @@ describe('useSteering', () => { const stopGenerating = jest.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); const rendered = renderHook( @@ -2033,7 +2118,7 @@ describe('useSteering', () => { const sendNow = jest.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); const rendered = renderHook( @@ -2425,7 +2510,7 @@ describe('useSteering', () => { * answer: the opposite of what the action promises. */ it('refuses while paused on tool approval instead of aborting', () => { - mockMessages = [ + seedMessages([ { messageId: 'm1', conversationId: CONVO_ID, @@ -2441,13 +2526,13 @@ describe('useSteering', () => { }, ], } as unknown as TMessage, - ]; + ]); const { result, stopGenerating } = setup(); let consumed = true; act(() => { consumed = result.current.interruptSteer('do not abort me'); }); - mockMessages = undefined; + seedMessages(undefined); expect(consumed).toBe(false); expect(stopGenerating).not.toHaveBeenCalled(); @@ -2612,7 +2697,7 @@ describe('useSteering', () => { getDefaultStore().set(activeGenerationProtocolVersionByConvoId(otherConversationId), 2); }} > - {children} + {children} ); const { result, rerender } = renderHook( @@ -2675,7 +2760,7 @@ describe('useSteering', () => { const stopGenerating = jest.fn(); const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ); const rendered = renderHook( @@ -3807,7 +3892,7 @@ describe('useSteering', () => { } } > - {children} + {children} ); const rendered = renderHook( @@ -3906,9 +3991,9 @@ describe('useSteering', () => { render( - + - + , ); return { @@ -4179,7 +4264,7 @@ describe('useSteering', () => { it('restores an unchanged queued media item without re-marking its files', () => { // Paused on approval → steering unavailable while the run is live, so // sendQueuedNow restores the item unchanged: its files were already marked. - mockMessages = [ + seedMessages([ { messageId: 'resp-1', isCreatedByUser: false, @@ -4195,7 +4280,7 @@ describe('useSteering', () => { }, ], } as unknown as TMessage, - ]; + ]); const { result } = setupWithFiles(); const queued = { id: 'q-requeue', @@ -4240,7 +4325,7 @@ describe('useSteering', () => { reasoningStore, )} > - {children} + {children} ); diff --git a/client/src/hooks/Messages/useAttachments.ts b/client/src/hooks/Messages/useAttachments.ts index ee5e5ea0090..16916aa6494 100644 --- a/client/src/hooks/Messages/useAttachments.ts +++ b/client/src/hooks/Messages/useAttachments.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; +import { mergeAttachments } from '@librechat/chat'; import type { TAttachment } from 'librechat-data-provider'; import { useSearchResultsByTurn } from './useSearchResultsByTurn'; import { messageAttachmentsMapAtom } from '~/hooks/SSE/atoms'; -import { mergeAttachments } from '~/utils/attachments'; export default function useAttachments({ messageId, diff --git a/client/src/hooks/Messages/useLatestMessage.ts b/client/src/hooks/Messages/useLatestMessage.ts index 0fdb0760848..6bf2cfd64d4 100644 --- a/client/src/hooks/Messages/useLatestMessage.ts +++ b/client/src/hooks/Messages/useLatestMessage.ts @@ -1,44 +1,15 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useStore } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { Constants, QueryKeys } from 'librechat-data-provider'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useBranchTail, useBranchTailId, useBranchTailSelect } from '@librechat/chat/react'; import type { TMessage } from 'librechat-data-provider'; -import { - siblingIdxFamily, - siblingKey, - useSiblingIndexes, -} from '~/components/Chat/Messages/Thread/state'; -import { getMessageBranchSiblingParentIds, selectActiveBranchTail } from '~/utils'; +import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/state'; +import { selectActiveBranchTail } from '~/utils'; import store from '~/store'; -const EMPTY_PARENT_IDS: (string | null)[] = []; -const getParentLookupKey = siblingKey; - -function useMessagesCacheSelect( - messagesQueryId: string | null | undefined, - select: (messages: TMessage[]) => TData, -): TData | null { - const queryClient = useQueryClient(); - const queryKey = [QueryKeys.messages, messagesQueryId ?? '']; - - const { data } = useQuery( - queryKey, - async () => queryClient.getQueryData(queryKey) ?? [], - { - enabled: false, - select, - }, - ); - - if (!messagesQueryId) { - return null; - } - - return data ?? null; -} - function useLatestMessagesQueryId( index: string | number, conversationId: string | null, @@ -61,38 +32,13 @@ function useLatestMessagesQueryId( return conversationId; } -function useLatestMessageSiblingIndexes( - messagesQueryId: string | null | undefined, - rootSiblingKey: string | null, -) { - const selectParentIds = useCallback( - (messages: TMessage[]) => - getMessageBranchSiblingParentIds(messages, messages[0]?.conversationId ?? rootSiblingKey), - [rootSiblingKey], - ); - const parentIds = useMessagesCacheSelect(messagesQueryId, selectParentIds) ?? EMPTY_PARENT_IDS; - const parentKeys = useMemo(() => parentIds.map(siblingKey), [parentIds]); - return useSiblingIndexes(parentKeys); -} - export function useLatestMessage( index: string | number, messagesQueryIdOverride?: string | null, ): TMessage | null { const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); - const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); - const select = useCallback( - (messages: TMessage[]) => - selectActiveBranchTail( - messages, - messages[0]?.conversationId ?? conversationId, - (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, - ), - [conversationId, siblingIndexes], - ); - - return useMessagesCacheSelect(messagesQueryId, select); + return useBranchTail(conversationId, messagesQueryId); } export function useLatestMessageId( @@ -101,22 +47,18 @@ export function useLatestMessageId( ): string | null { const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); - const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); - const select = useCallback( - (messages: TMessage[]) => - selectActiveBranchTail( - messages, - messages[0]?.conversationId ?? conversationId, - (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, - )?.messageId ?? null, - [conversationId, siblingIndexes], - ); - - return useMessagesCacheSelect(messagesQueryId, select); + return useBranchTailId(conversationId, messagesQueryId); } export type TLatestMessageMeta = Pick; +const pickMeta = (tail: TMessage | null): TLatestMessageMeta | null => + tail && { + messageId: tail.messageId, + error: tail.error, + isCreatedByUser: tail.isCreatedByUser, + }; + /** * Metadata-only projection of the branch tail. Streaming deltas leave these * fields untouched, so React Query's structural sharing keeps the selected @@ -129,27 +71,7 @@ export function useLatestMessageMeta( ): TLatestMessageMeta | null { const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); - const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); - const select = useCallback( - (messages: TMessage[]): TLatestMessageMeta | null => { - const tail = selectActiveBranchTail( - messages, - messages[0]?.conversationId ?? conversationId, - (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, - ); - if (!tail) { - return null; - } - return { - messageId: tail.messageId, - error: tail.error, - isCreatedByUser: tail.isCreatedByUser, - }; - }, - [conversationId, siblingIndexes], - ); - - return useMessagesCacheSelect(messagesQueryId, select); + return useBranchTailSelect(conversationId, messagesQueryId, pickMeta); } /** diff --git a/client/src/utils/map.ts b/client/src/utils/map.ts index 9d73fbc162a..d24ce18fafd 100644 --- a/client/src/utils/map.ts +++ b/client/src/utils/map.ts @@ -1,29 +1,6 @@ import type * as t from 'librechat-data-provider'; -export { filterAttachmentsForPart } from '@librechat/chat'; - -/** Maps Attachments by `toolCallId` for quick lookup */ -export function mapAttachments(attachments: Array) { - const attachmentMap: Record = {}; - - for (const attachment of attachments) { - if (attachment === null || attachment === undefined) { - continue; - } - const key = attachment.toolCallId || ''; - if (key.length === 0) { - continue; - } - - if (!attachmentMap[key]) { - attachmentMap[key] = []; - } - - attachmentMap[key]?.push(attachment); - } - - return attachmentMap; -} +export { mapAttachments, filterAttachmentsForPart } from '@librechat/chat'; /** Maps Files by `file_id` for quick lookup */ export function mapFiles(files: t.TFile[]) { diff --git a/client/src/utils/media.ts b/client/src/utils/media.ts index 2a564c1833d..ce7ebd0f880 100644 --- a/client/src/utils/media.ts +++ b/client/src/utils/media.ts @@ -1,6 +1,9 @@ +import { attachmentIdentity } from '@librechat/chat'; import { imageExtRegex } from 'librechat-data-provider'; import type { TAttachment, TFile } from 'librechat-data-provider'; +export { attachmentIdentity }; + /** Sources the browser already resolves on its own. */ const ABSOLUTE_SOURCE_PATTERN = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i; @@ -37,43 +40,6 @@ function fileKeyOf(attachment: TAttachment): string | undefined { return file_id ?? filepath; } -function ownerOf(attachment: TAttachment): { - toolCallId?: string; - agentId?: string; -} { - const owned = attachment as { toolCallId?: string; agentId?: string }; - return { toolCallId: owned.toolCallId, agentId: owned.agentId }; -} - -/** - * Stable identity for an attachment: `file_id ?? filepath` scoped by - * `toolCallId` and `agentId`, else `type:toolCallId` with the same optional agent scope for unkeyed tool - * artifacts like file_search citations. `undefined` means the row has no - * stable identity and must never be compared to another — two entries that - * cannot be told apart are two entries, not one. - * - * The ownership dimensions are not decoration. Sibling code calls can share a - * claimed `file_id` for the same filename and handoff agents can repeat - * provider tool ids, so `file_id` alone collapses attachments the merge in - * `useAttachments` deliberately keeps as separate cards — which is why that - * merge is the caller this lives next to, rather than a second definition. - */ -export function attachmentIdentity(attachment: TAttachment): string | undefined { - const { type } = attachment as { type?: string }; - const { toolCallId, agentId } = ownerOf(attachment); - const fileKey = fileKeyOf(attachment); - if (fileKey) { - if (toolCallId == null) { - return fileKey; - } - return agentId ? `${fileKey}::${toolCallId}::${agentId}` : `${fileKey}::${toolCallId}`; - } - if (type != null && toolCallId != null) { - return agentId ? `${type}:${toolCallId}::${agentId}` : `${type}:${toolCallId}`; - } - return undefined; -} - /** * Identity for deciding whether two records render as the SAME artifact, which * is `attachmentIdentity` plus the host run step. diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index ae77c7cf11c..3f496b4068b 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -1,5 +1,9 @@ -import { getBranchSiblingIndexesForTarget } from '@librechat/chat'; -import { QueryKeys, Constants, buildTree, ContentTypes } from 'librechat-data-provider'; +import { QueryKeys, Constants, ContentTypes } from 'librechat-data-provider'; +import { + selectActiveBranchTail, + getBranchSiblingIndexesForTarget, + getMessageBranchSiblingParentIds, +} from '@librechat/chat'; import { getPartKeyIndex, getAllContentText, @@ -37,69 +41,12 @@ export function isUnacknowledgedUserMessage(message: TMessage): boolean { ); } -export { getBranchSiblingIndexesForTarget }; -export type { BranchSiblingIndex }; - -type SiblingIndexLookup = (parentMessageId: string | null | undefined) => number; - -export const selectActiveBranchTail = ( - messages: TMessage[] | null | undefined, - rootSiblingKey: string | null | undefined, - getSiblingIndex: SiblingIndexLookup = () => 0, -): TMessage | null => { - const messagesTree = buildTree({ messages: messages ?? null }); - if (!messagesTree?.length) { - return null; - } - - let siblings = messagesTree; - let parentMessageId = rootSiblingKey; - let tail: TMessage | null = null; - - while (siblings.length > 0) { - const siblingIdx = getSiblingIndex(parentMessageId); - const normalizedSiblingIdx = siblingIdx >= 0 && siblingIdx < siblings.length ? siblingIdx : 0; - const activeSiblingIndex = siblings.length - normalizedSiblingIdx - 1; - const message = siblings[activeSiblingIndex] ?? siblings[siblings.length - 1]; - if (!message) { - return tail; - } - - tail = message; - parentMessageId = message.messageId; - siblings = message.children ?? []; - } - - return tail; -}; - -export const getMessageBranchSiblingParentIds = ( - messages: TMessage[] | null | undefined, - rootSiblingKey: string | null | undefined, -): (string | null)[] => { - const messagesTree = buildTree({ messages: messages ?? null }); - if (!messagesTree?.length) { - return []; - } - - const parentIds = new Set(); - const stack = [{ siblings: messagesTree, parentMessageId: rootSiblingKey, index: 0 }]; - while (stack.length > 0) { - const frame = stack[stack.length - 1]; - if (frame.index === 0 && frame.siblings.length > 1) { - parentIds.add(frame.parentMessageId ?? null); - } - const message = frame.siblings[frame.index++]; - if (!message) { - stack.pop(); - continue; - } - if (message.children?.length) { - stack.push({ siblings: message.children, parentMessageId: message.messageId, index: 0 }); - } - } - return Array.from(parentIds); +export { + selectActiveBranchTail, + getBranchSiblingIndexesForTarget, + getMessageBranchSiblingParentIds, }; +export type { BranchSiblingIndex }; export const getLatestText = (message?: TMessage | null, includeIndex?: boolean): string => { if (!message) { diff --git a/packages/chat/src/core/branch.ts b/packages/chat/src/core/branch.ts index 37f72316850..8c64af85d24 100644 --- a/packages/chat/src/core/branch.ts +++ b/packages/chat/src/core/branch.ts @@ -83,3 +83,64 @@ export function getBranchReasoning( } return undefined; } + +type SiblingIndexLookup = (parentMessageId: string | null | undefined) => number; + +export const selectActiveBranchTail = ( + messages: TMessage[] | null | undefined, + rootSiblingKey: string | null | undefined, + getSiblingIndex: SiblingIndexLookup = () => 0, +): TMessage | null => { + const messagesTree = buildTree({ messages: messages ?? null }); + if (!messagesTree?.length) { + return null; + } + + let siblings = messagesTree; + let parentMessageId = rootSiblingKey; + let tail: TMessage | null = null; + + while (siblings.length > 0) { + const siblingIdx = getSiblingIndex(parentMessageId); + const normalizedSiblingIdx = siblingIdx >= 0 && siblingIdx < siblings.length ? siblingIdx : 0; + const activeSiblingIndex = siblings.length - normalizedSiblingIdx - 1; + const message = siblings[activeSiblingIndex] ?? siblings[siblings.length - 1]; + if (!message) { + return tail; + } + + tail = message; + parentMessageId = message.messageId; + siblings = message.children ?? []; + } + + return tail; +}; + +export const getMessageBranchSiblingParentIds = ( + messages: TMessage[] | null | undefined, + rootSiblingKey: string | null | undefined, +): (string | null)[] => { + const messagesTree = buildTree({ messages: messages ?? null }); + if (!messagesTree?.length) { + return []; + } + + const parentIds = new Set(); + const stack = [{ siblings: messagesTree, parentMessageId: rootSiblingKey, index: 0 }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame.index === 0 && frame.siblings.length > 1) { + parentIds.add(frame.parentMessageId ?? null); + } + const message = frame.siblings[frame.index++]; + if (!message) { + stack.pop(); + continue; + } + if (message.children?.length) { + stack.push({ siblings: message.children, parentMessageId: message.messageId, index: 0 }); + } + } + return Array.from(parentIds); +}; diff --git a/client/src/utils/attachments.ts b/packages/chat/src/core/parts/attachments.ts similarity index 72% rename from client/src/utils/attachments.ts rename to packages/chat/src/core/parts/attachments.ts index ef2264d28f2..515d6aa2a0c 100644 --- a/client/src/utils/attachments.ts +++ b/packages/chat/src/core/parts/attachments.ts @@ -1,11 +1,47 @@ import type { TAttachment, TFile } from 'librechat-data-provider'; -import { attachmentIdentity } from '~/utils/media'; function fileKeyOf(attachment: TAttachment): string | undefined { const { file_id, filepath } = attachment as Partial; return file_id ?? filepath; } +function ownerOf(attachment: TAttachment): { + toolCallId?: string; + agentId?: string; +} { + const owned = attachment as { toolCallId?: string; agentId?: string }; + return { toolCallId: owned.toolCallId, agentId: owned.agentId }; +} + +/** + * Stable identity for an attachment: `file_id ?? filepath` scoped by + * `toolCallId` and `agentId`, else `type:toolCallId` with the same optional agent scope for unkeyed tool + * artifacts like file_search citations. `undefined` means the row has no + * stable identity and must never be compared to another: two entries that + * cannot be told apart are two entries, not one. + * + * The ownership dimensions are not decoration. Sibling code calls can share a + * claimed `file_id` for the same filename and handoff agents can repeat + * provider tool ids, so `file_id` alone collapses attachments the merge in + * `useAttachments` deliberately keeps as separate cards, which is why that + * merge is the caller this lives next to, rather than a second definition. + */ +export function attachmentIdentity(attachment: TAttachment): string | undefined { + const { type } = attachment as { type?: string }; + const { toolCallId, agentId } = ownerOf(attachment); + const fileKey = fileKeyOf(attachment); + if (fileKey) { + if (toolCallId == null) { + return fileKey; + } + return agentId ? `${fileKey}::${toolCallId}::${agentId}` : `${fileKey}::${toolCallId}`; + } + if (type != null && toolCallId != null) { + return agentId ? `${type}:${toolCallId}::${agentId}` : `${type}:${toolCallId}`; + } + return undefined; +} + function toolCallIdOf(attachment: TAttachment): string | undefined { return (attachment as { toolCallId?: string }).toolCallId; } @@ -141,3 +177,28 @@ export function mergeAttachments( }); return liveOnly.length > 0 ? [...merged, ...liveOnly] : merged; } + +/** Maps Attachments by `toolCallId` for quick lookup */ +export function mapAttachments( + attachments: Array, +): Record { + const attachmentMap: Record = {}; + + for (const attachment of attachments) { + if (attachment === null || attachment === undefined) { + continue; + } + const key = attachment.toolCallId || ''; + if (key.length === 0) { + continue; + } + + if (!attachmentMap[key]) { + attachmentMap[key] = []; + } + + attachmentMap[key]?.push(attachment); + } + + return attachmentMap; +} diff --git a/packages/chat/src/core/parts/index.ts b/packages/chat/src/core/parts/index.ts index 57235923d38..eb2ba17f9aa 100644 --- a/packages/chat/src/core/parts/index.ts +++ b/packages/chat/src/core/parts/index.ts @@ -17,3 +17,5 @@ export * from './outcome'; export * from './output'; export * from './preparation'; export * from './memory'; +export * from './attachments'; +export * from './mapping'; diff --git a/client/src/hooks/Chat/outcomes.ts b/packages/chat/src/core/parts/mapping.ts similarity index 93% rename from client/src/hooks/Chat/outcomes.ts rename to packages/chat/src/core/parts/mapping.ts index c27c4f72bc6..b44d195e418 100644 --- a/client/src/hooks/Chat/outcomes.ts +++ b/packages/chat/src/core/parts/mapping.ts @@ -1,8 +1,8 @@ import { ContentTypes } from 'librechat-data-provider'; -import { isMemoryFailureOutput, getToolMeta } from '@librechat/chat'; import type { TMessage, TAttachment, UIMappingOptions } from 'librechat-data-provider'; -import { mergeAttachments } from '~/utils/attachments'; -import { mapAttachments } from '~/utils/map'; +import { mapAttachments, mergeAttachments } from './attachments'; +import { isMemoryFailureOutput } from './memory'; +import { getToolMeta } from './outcome'; type ToolContext = { content: TMessage['content']; @@ -77,7 +77,7 @@ const getFailureProse = (toolCall: StoredToolCall, background: boolean) => { }; /** - * The client's own tool outcome rules (`getToolMeta`: memory failure prose, background task + * The tool outcome rules (`getToolMeta`: memory failure prose, background task * status attachments), handed to the parts mapping, which reads only markers stored on the call. * A call with no step yet is scoped away from attachments its repeated provider id's other * steps own, as `summarizeSpan` does. diff --git a/packages/chat/src/react.ts b/packages/chat/src/react.ts index c0e74e50005..91a70b6351e 100644 --- a/packages/chat/src/react.ts +++ b/packages/chat/src/react.ts @@ -4,6 +4,7 @@ export * from './react/stream'; export * from './react/subagents'; export * from './react/usage'; export * from './react/thread'; +export * from './react/latest'; export * from './react/approval'; export { MessageContext, MessagePartIndex, useMessageContext } from './react/message'; export { diff --git a/packages/chat/src/react/__tests__/latest.spec.tsx b/packages/chat/src/react/__tests__/latest.spec.tsx new file mode 100644 index 00000000000..1a2c75c477d --- /dev/null +++ b/packages/chat/src/react/__tests__/latest.spec.tsx @@ -0,0 +1,56 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { createStore, Provider } from 'jotai'; +import { QueryKeys } from 'librechat-data-provider'; +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { TMessage } from 'librechat-data-provider'; +import { useBranchTail, useBranchTailId } from '../latest'; +import { siblingIdxFamily, siblingKey } from '../thread'; + +const message = (messageId: string, parentMessageId: string): TMessage => + ({ messageId, parentMessageId, conversationId: 'c1', text: messageId }) as TMessage; + +const NO_PARENT = '00000000-0000-0000-0000-000000000000'; +const messages = [message('user', NO_PARENT), message('older', 'user'), message('newer', 'user')]; + +function setup(queryId: string | null = 'c1') { + const queryClient = new QueryClient(); + queryClient.setQueryData([QueryKeys.messages, 'c1'], messages); + const store = createStore(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook( + () => ({ tail: useBranchTail('c1', queryId), id: useBranchTailId('c1', queryId) }), + { wrapper }, + ); + return { result, store }; +} + +describe('useBranchTail', () => { + it('reads the newest sibling by default', () => { + const { result } = setup(); + expect(result.current.tail?.messageId).toBe('newer'); + expect(result.current.id).toBe('newer'); + }); + + it('follows the selected sibling', () => { + const { result, store } = setup(); + act(() => { + store.set(siblingIdxFamily(siblingKey('user')), 1); + }); + expect(result.current.tail?.messageId).toBe('older'); + expect(result.current.id).toBe('older'); + }); + + it('reads nothing without a query id', () => { + const { result } = setup(null); + expect(result.current.tail).toBeNull(); + expect(result.current.id).toBeNull(); + }); +}); diff --git a/packages/chat/src/react/chat/__tests__/stop.spec.tsx b/packages/chat/src/react/chat/__tests__/stop.spec.tsx index 87f9b2f97af..9583e7db984 100644 --- a/packages/chat/src/react/chat/__tests__/stop.spec.tsx +++ b/packages/chat/src/react/chat/__tests__/stop.spec.tsx @@ -55,13 +55,9 @@ const createHost = (conversation: TConversation | null): ChatHost => ({ usePaneConversation: () => ({ conversation, setConversation: noop }), useNewConversation: () => noop, useTurnServices: () => turnServices, - useLatestMessage: () => null, - useLatestMessageId: () => null, - useSetSiblingIdx: () => noop, useAbortStream: () => mockAbortStream, useMessageCache: () => cache, useFeedbackEnabled: () => false, - getMappingOptions: () => ({}), useMessages: () => ({ messages: null, isLoading: false, isFetching: false }), }); diff --git a/packages/chat/src/react/chat/contract.ts b/packages/chat/src/react/chat/contract.ts index 9d0c4a2b440..5c6f1cb8247 100644 --- a/packages/chat/src/react/chat/contract.ts +++ b/packages/chat/src/react/chat/contract.ts @@ -143,7 +143,7 @@ export type ChatViewContract = { /** Whether message feedback controls are enabled by the startup config. */ feedbackEnabled: boolean; /** - * The app's tool outcome rules for viewing one message as a `UIMessage`, given the + * The tool outcome rules for viewing one message as a `UIMessage`, given the * attachments that arrived for it outside the message cache. Stable across renders. */ getMappingOptions: (live: TAttachment[] | undefined) => UIMappingOptions; diff --git a/packages/chat/src/react/chat/helpers.ts b/packages/chat/src/react/chat/helpers.ts index 6546fda813b..a780a4f1827 100644 --- a/packages/chat/src/react/chat/helpers.ts +++ b/packages/chat/src/react/chat/helpers.ts @@ -5,8 +5,11 @@ import type { MouseEvent } from 'react'; import type { ChatContract } from './contract'; import { isSubmittingFamily, submissionByIndex, useIsSubmitting } from '../submission'; import { presetFamily, filesFamily, optionSettingsFamily } from '../pane'; +import { useBranchTail, useBranchTailId } from '../latest'; +import { siblingIdxFamily, siblingKey } from '../thread'; import { useLiveAttachments } from '../stream/atoms'; import { abortScrollFamily } from '../generation'; +import { getMappingOptions } from '../../core'; import { getMessageCacheIds } from './cache'; import { useStopGenerating } from './stop'; import { useChatHost } from './host'; @@ -33,14 +36,16 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac const isSubmitting = useIsSubmitting(index); const setIsSubmitting = useSetAtom(isSubmittingFamily(index)); - const latestMessage = host.useLatestMessage(index, queryParam); + const tailRootKey = conversationId ?? null; + const tailQueryId = conversationId ? queryParam : null; + const latestMessage = useBranchTail(tailRootKey, tailQueryId); - const latestMessageId = host.useLatestMessageId(index, queryParam) ?? undefined; + const latestMessageId = useBranchTailId(tailRootKey, tailQueryId) ?? undefined; const latestMessageDepth = latestMessage?.depth; const latestMessageRef = useRef(latestMessage); latestMessageRef.current = latestMessage; - const setSiblingIdx = host.useSetSiblingIdx(latestMessage?.parentMessageId ?? null); + const setSiblingIdx = useSetAtom(siblingIdxFamily(siblingKey(latestMessage?.parentMessageId))); /** The setter is rebound whenever the tail's parent changes (every turn); the * ref keeps `handleContinue` referentially stable so rows do not re-render. */ const setSiblingIdxRef = useRef(setSiblingIdx); @@ -168,7 +173,6 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac /** 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. */ const feedbackEnabled = host.useFeedbackEnabled(); - const { getMappingOptions } = host; return useMemo( (): ChatContract => ({ @@ -238,7 +242,6 @@ export default function useChatHelpers(index = 0, paramId?: string): ChatContrac filesLoading, setFilesLoading, feedbackEnabled, - getMappingOptions, ], ); } diff --git a/packages/chat/src/react/chat/host.tsx b/packages/chat/src/react/chat/host.tsx index 6b4f8e9ae25..3c49a567cc8 100644 --- a/packages/chat/src/react/chat/host.tsx +++ b/packages/chat/src/react/chat/host.tsx @@ -5,13 +5,11 @@ import type { TMessage, TModelSpec, TSubmission, - TAttachment, TConversation, TPendingSteer, TEphemeralAgent, TEndpointsConfig, TAgentQueuedTurnReceipt, - UIMappingOptions, } from 'librechat-data-provider'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import type { ExtendedFile, TurnCodeInputs } from '../../core'; @@ -154,19 +152,11 @@ export interface ChatHost { useNewConversation(index: number): ChatContract['newConversation']; /** What the pane's turns need from the app; see {@link TurnServices}. */ useTurnServices(params: ChatTurnsParams): TurnServices; - /** The tail of the branch on screen for the pane, read from the messages cached under `key`. */ - useLatestMessage(index: number, key: string): TMessage | null; - /** The id of {@link ChatHost.useLatestMessage}, subscribed on its own. */ - useLatestMessageId(index: number, key: string): string | null; - /** Selects the visible sibling under a parent message. */ - useSetSiblingIdx(parentMessageId: string | null): (update: SetStateAction) => void; /** Stops a resumable generation. */ useAbortStream(): (params: AbortStreamParams) => Promise; useMessageCache(): ChatMessageCache; /** Whether message feedback controls are on. */ useFeedbackEnabled(): boolean; - /** The app's tool outcome rules for viewing one message; see {@link ChatContract}. */ - getMappingOptions(live: TAttachment[] | undefined): UIMappingOptions; /** Loads a conversation's messages; `isStreaming` lets the query hold off refetching under a * live stream. An absent id loads nothing. */ useMessages( diff --git a/packages/chat/src/react/latest.ts b/packages/chat/src/react/latest.ts new file mode 100644 index 00000000000..c582e4cbf63 --- /dev/null +++ b/packages/chat/src/react/latest.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from 'react'; +import { QueryKeys } from 'librechat-data-provider'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { TMessage } from 'librechat-data-provider'; +import { getMessageBranchSiblingParentIds, selectActiveBranchTail } from '../core'; +import { siblingKey, useSiblingIndexes } from './thread'; + +const EMPTY_PARENT_IDS: (string | null)[] = []; + +function useMessagesCacheSelect( + messagesQueryId: string | null | undefined, + select: (messages: TMessage[]) => TData, +): TData | null { + const queryClient = useQueryClient(); + const queryKey = [QueryKeys.messages, messagesQueryId ?? '']; + + const { data } = useQuery( + queryKey, + async () => queryClient.getQueryData(queryKey) ?? [], + { + enabled: false, + select, + }, + ); + + if (!messagesQueryId) { + return null; + } + + return data ?? null; +} + +/** + * A projection of the tail of the branch on screen, read from the messages cached under + * `messagesQueryId`. `rootSiblingKey` keys the root's siblings when the messages name no + * conversation. `pick` must be stable; a projection streaming leaves untouched stays referentially + * stable through the query's structural sharing, so its readers skip per-delta renders. + */ +export function useBranchTailSelect( + rootSiblingKey: string | null, + messagesQueryId: string | null | undefined, + pick: (tail: TMessage | null) => TData | null, +): TData | null { + const selectParentIds = useCallback( + (messages: TMessage[]) => + getMessageBranchSiblingParentIds(messages, messages[0]?.conversationId ?? rootSiblingKey), + [rootSiblingKey], + ); + const parentIds = useMessagesCacheSelect(messagesQueryId, selectParentIds) ?? EMPTY_PARENT_IDS; + const parentKeys = useMemo(() => parentIds.map(siblingKey), [parentIds]); + const siblingIndexes = useSiblingIndexes(parentKeys); + const select = useCallback( + (messages: TMessage[]) => + pick( + selectActiveBranchTail( + messages, + messages[0]?.conversationId ?? rootSiblingKey, + (parentId) => siblingIndexes[siblingKey(parentId)] ?? 0, + ), + ), + [pick, rootSiblingKey, siblingIndexes], + ); + return useMessagesCacheSelect(messagesQueryId, select); +} + +const pickTail = (tail: TMessage | null): TMessage | null => tail; +const pickTailId = (tail: TMessage | null): string | null => tail?.messageId ?? null; + +/** The tail of the branch on screen. */ +export function useBranchTail( + rootSiblingKey: string | null, + messagesQueryId: string | null | undefined, +): TMessage | null { + return useBranchTailSelect(rootSiblingKey, messagesQueryId, pickTail); +} + +/** The id of {@link useBranchTail}, subscribed on its own. */ +export function useBranchTailId( + rootSiblingKey: string | null, + messagesQueryId: string | null | undefined, +): string | null { + return useBranchTailSelect(rootSiblingKey, messagesQueryId, pickTailId); +} diff --git a/packages/chat/src/react/steer/__tests__/host.spec.tsx b/packages/chat/src/react/steer/__tests__/host.spec.tsx index fcfda4f692a..58371fb4abe 100644 --- a/packages/chat/src/react/steer/__tests__/host.spec.tsx +++ b/packages/chat/src/react/steer/__tests__/host.spec.tsx @@ -24,8 +24,8 @@ const createHost = (label: string): SteerHost => ({ useQueuedTurns: () => undefined, useQueuedTurnCache: () => ({ read: () => ({}), subscribe: () => () => undefined }), useFetchStreamStatus: () => async () => ({ active: false }), - useLatestMessage: () => null, - useCodeApprovalMode: () => undefined, + useRouteConversationId: () => undefined, + useCodeApprovalInputs: () => ({ conversation: null, reachable: { agents: [], complete: true } }), useLocalize: () => (key) => `${label}:${key}`, useNotify: () => () => undefined, useFileMap: () => undefined, diff --git a/packages/chat/src/react/steer/host.tsx b/packages/chat/src/react/steer/host.tsx index 811226f13a9..55b000d683f 100644 --- a/packages/chat/src/react/steer/host.tsx +++ b/packages/chat/src/react/steer/host.tsx @@ -1,9 +1,6 @@ import { createContext, useContext } from 'react'; import type { - TMessage, TConversation, - CodeApprovalMode, - CodeEnvironmentMode, TAgentQueuedTurnReceipt, TEnqueueAgentQueuedTurnRequest, } from 'librechat-data-provider'; @@ -20,6 +17,7 @@ import type { } from '../../core/types'; import type { StreamStatusResponse } from '../../core/resumable/host'; import type { FileDeliveryMetadataMap } from '../../core/files'; +import type { CodeApprovalModeInputs } from '../../core/code'; /** Callbacks of a fire-and-forget request, called once it settles. */ export type SteerRequestCallbacks = { @@ -119,14 +117,14 @@ export interface SteerHost { useQueuedTurnCache(conversationId: string): QueuedTurnCache; /** Reads a conversation's generation state, including steers it could not deliver. */ useFetchStreamStatus(): (conversationId: string) => Promise; - /** The latest message of the pane, on the branch on screen. */ - useLatestMessage(index: number, conversationId: string | null): TMessage | null; - /** The code approval mode a new turn would run under. */ - useCodeApprovalMode( + /** The conversation id in the route, when the route names one. */ + useRouteConversationId(): string | undefined; + /** Storage read: the raw values a new turn's code approval mode is computed from, as the app + * holds them. The package derives the mode; see `resolveCodeApprovalModes`. */ + useCodeApprovalInputs( conversation: TConversation | null, addedConversation?: TConversation | null, - codeEnvironmentMode?: CodeEnvironmentMode, - ): CodeApprovalMode | undefined; + ): Omit; useLocalize(): (key: SteerMessageKey) => string; useNotify(): (notification: SteerNotification) => void; /** Delivery metadata of the files the user owns, by id. */ diff --git a/packages/chat/src/react/steering/useSteering.ts b/packages/chat/src/react/steering/useSteering.ts index fd31f0b1690..18fa46c170c 100644 --- a/packages/chat/src/react/steering/useSteering.ts +++ b/packages/chat/src/react/steering/useSteering.ts @@ -27,12 +27,18 @@ import { activeGenerationCreatedAtByConvoId, activeGenerationProtocolVersionByConvoId, } from '../steer/atoms'; -import { hasLiveRunPause, selectLiveMessageState, carriedSteerContext } from '../../core'; +import { + hasLiveRunPause, + carriedSteerContext, + selectLiveMessageState, + resolveCodeApprovalModes, +} from '../../core'; import { getReasoningStateKey, pendingReasoningOverrideFamily } from '../composer'; import useQueuedTurnReceipts from './receipts'; import { useSteerHost } from '../steer/host'; import useDuringRunActions from './actions'; import useQueuedRecovery from './recovery'; +import { useBranchTail } from '../latest'; import useComposerTake from './composer'; import useQueueEnqueue from './enqueue'; import useSubmitSteer from './submit'; @@ -152,11 +158,11 @@ export default function useSteering({ setDuringRunDefaultAction: setDefaultAction, } = host.useSettings(); - const codeApprovalMode = host.useCodeApprovalMode( - conversation, - addedConversation, + const codeApprovalInputs = host.useCodeApprovalInputs(conversation, addedConversation); + const codeApprovalMode = resolveCodeApprovalModes({ + ...codeApprovalInputs, codeEnvironmentMode, - ); + }).selected; const endpoint = conversation?.endpointType ?? conversation?.endpoint; const steerable = !isAssistantsEndpoint(endpoint); const hasRealConvoId = @@ -212,7 +218,13 @@ export default function useSteering({ /** Capture a durable branch anchor into a server queued turn. Optimistic * assistant placeholders are siblings of their eventual persisted response, * so they anchor through their stable user parent. */ - const latestMessage = host.useLatestMessage(index, hasRealConvoId ? conversationId : null); + const paneConversationId = conversation?.conversationId ?? null; + const routeConversationId = host.useRouteConversationId(); + const unsavedQueryId = (index === 0 ? routeConversationId : undefined) ?? paneConversationId; + const latestMessage = useBranchTail( + paneConversationId, + paneConversationId && (hasRealConvoId ? conversationId : unsavedQueryId), + ); const liveMessageState = useMemo(() => selectLiveMessageState(latestMessage), [latestMessage]); /** Both approval cards and `ask_user_question` suspend the current * generation while keeping its submission slot occupied. Answer mode hides