From eeb4ea226c64810e266e29bc6426fd95d3064456 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Jul 2026 22:31:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20perf:=20Warm=20Conversation=20Sw?= =?UTF-8?q?itches=20with=20Single-Navigation=20Focus=20Intent=20(#14334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧭 perf: Warm Conversation Switches with Single-Navigation Focus Intent * 🧭 fix: Drop Warm Message Cache When Conversation Revalidation Fails * 🧭 fix: Defer Departing-Convo Refetch and Gate Resume on Revalidation * 🧭 fix: Gate Stale-Cache Sends During Revalidation and Honor disableFocus --- client/src/components/Chat/ChatView.tsx | 18 +- .../useChatFunctions.regenerate.spec.tsx | 2 + .../__tests__/useFocusChatEffect.spec.tsx | 392 ++++-------------- client/src/hooks/Chat/useChatFunctions.ts | 32 +- client/src/hooks/Chat/useFocusChatEffect.ts | 48 +-- .../Conversations/useNavigateToConvo.tsx | 43 +- client/src/hooks/SSE/useEventHandlers.ts | 4 +- client/src/hooks/useNewConvo.ts | 17 +- client/src/utils/focus.ts | 18 + client/src/utils/index.ts | 1 + 10 files changed, 225 insertions(+), 350 deletions(-) create mode 100644 client/src/utils/focus.ts diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index c894e251d5..d37ca81042 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -50,7 +50,11 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje const fileMap = useFileMapContext(); - const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId( + const { + data: messagesTree = null, + isLoading, + isFetching, + } = useGetMessagesByConvoId( conversationId ?? '', { select: useCallback( @@ -61,6 +65,10 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje [fileMap], ), enabled: !!conversationId && conversationId !== Constants.SEARCH, + /** Refetch stale caches on mount: navigation invalidates (not removes) + * messages now, so a warm conversation renders instantly from cache and + * reconciles in the background instead of unmounting into a spinner. */ + refetchOnMount: true, }, { isStreaming: isSubmitting }, ); @@ -70,9 +78,11 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje useAdaptiveSSE(rootSubmission, chatHelpers, false, index); - // Auto-resume if navigating back to conversation with active job - // Wait for messages to load before resuming to avoid race condition - useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading); + // Auto-resume if navigating back to conversation with active job. + // Wait for messages to load AND the warm-cache background revalidation to + // settle: a stale invalidated cache mounts with isLoading false while the + // refetch is in flight, and resume must not build from (or race) it. + useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading && !isFetching); // Auto-send queued follow-up messages once a run finishes cleanly. useQueueDrain(index, conversationId, chatHelpers.ask); diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx index c41a20a3d3..ebf46e138d 100644 --- a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx @@ -20,6 +20,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ getQueryData: mockGetQueryData, + getQueryState: jest.fn(() => undefined), }), })); @@ -63,6 +64,7 @@ jest.mock('~/utils', () => ({ }, createDualMessageContent: jest.fn(() => []), getRouteChatProjectId: jest.fn(() => null), + requestChatFocus: jest.fn(), })); const userMessage = (messageId: string, parentMessageId = '00000000-0000-0000-0000-000000000000') => diff --git a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx index ba83b0aeb5..361439ac50 100644 --- a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx @@ -7,332 +7,122 @@ jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), })); -// Import the component under test and its dependencies import { renderHook } from '@testing-library/react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { requestChatFocus, consumeChatFocus, logger } from '~/utils'; import useFocusChatEffect from '../useFocusChatEffect'; -import { logger } from '~/utils'; + +const mockDesktopMedia = () => { + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(hover: hover)', + media: '', + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +}; + +const mockTouchMedia = () => { + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(pointer: coarse)', + media: '', + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +}; describe('useFocusChatEffect', () => { - // Reset mocks before each test beforeEach(() => { mockLog = jest.spyOn(logger, 'log').mockImplementation(() => {}); jest.clearAllMocks(); + consumeChatFocus(); (useNavigate as jest.Mock).mockReturnValue(mockNavigate); - - // Mock window.matchMedia - window.matchMedia = jest.fn().mockImplementation((query) => ({ - matches: query === '(hover: hover)', // Desktop has hover capability - media: '', - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })); - - // Set default location mock + mockDesktopMedia(); (useLocation as jest.Mock).mockReturnValue({ + key: 'entry-1', pathname: '/c/new', search: '', - state: { focusChat: true }, - }); - - // Set default window.location - window.history.replaceState({}, '', '/c/new'); - }); - - afterEach(() => { - window.history.replaceState({}, '', '/'); - }); - - describe('Basic functionality', () => { - test('should focus textarea when location.state.focusChat is true', () => { - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockTextAreaRef.current.focus).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/c/new', { - replace: true, - state: {}, - }); - expect(mockLog).toHaveBeenCalled(); - }); - - test('should not focus textarea when location.state.focusChat is false', () => { - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '', - state: { focusChat: false }, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - test('should not focus textarea when textAreaRef.current is null', () => { - const nullTextAreaRef = { current: null }; - - renderHook(() => useFocusChatEffect(nullTextAreaRef as any)); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - test('should not focus textarea on touchscreen devices', () => { - window.matchMedia = jest.fn().mockImplementation((query) => ({ - matches: query === '(pointer: coarse)', // Touchscreen has coarse pointer - media: '', - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalled(); + state: null, }); }); - describe('URL parameter handling', () => { - // Helper function to run tests with different URL scenarios - const testUrlScenario = ({ - windowLocationSearch, - reactRouterSearch, - expectedUrl, - testDescription, - }: { - windowLocationSearch: string; - reactRouterSearch: string; - expectedUrl: string; - testDescription: string; - }) => { - test(`${testDescription}`, () => { - window.history.replaceState({}, '', `/c/new${windowLocationSearch}`); + test('focuses the textarea when a chat focus was requested before navigation', () => { + requestChatFocus(); - // Mock React Router's location - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: reactRouterSearch, - state: { focusChat: true }, - }); + renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); + expect(mockTextAreaRef.current.focus).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalled(); + }); - expect(mockNavigate).toHaveBeenCalledWith( - expectedUrl, - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - }); - }; + test('does not focus when no chat focus was requested', () => { + renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - test('should use window.location.search instead of location.search', () => { - window.history.replaceState({}, '', '/c/new?agent_id=test_agent_id'); + expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '?endpoint=openAI&model=gpt-4o-mini', - state: { focusChat: true }, - }); + test('leaves the request pending when textAreaRef.current is null', () => { + requestChatFocus(); + const nullTextAreaRef = { current: null }; - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); + renderHook(() => useFocusChatEffect(nullTextAreaRef as any)); - expect(mockNavigate).toHaveBeenCalledWith( - // Should use window.location.search, not location.search - '/c/new?agent_id=test_agent_id', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(consumeChatFocus()).toBe(true); + }); + + test('consumes the request without focusing on touchscreen devices', () => { + mockTouchMedia(); + requestChatFocus(); + + renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); + + expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(consumeChatFocus()).toBe(false); + }); + + test('consumes the request exactly once across navigations', () => { + requestChatFocus(); + + const { rerender } = renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); + expect(mockTextAreaRef.current.focus).toHaveBeenCalledTimes(1); + + (useLocation as jest.Mock).mockReturnValue({ + key: 'entry-2', + pathname: '/c/abc', + search: '', + state: null, }); + rerender(); - testUrlScenario({ - windowLocationSearch: '?agent_id=agent123', - reactRouterSearch: '?endpoint=openAI&model=gpt-4', - expectedUrl: '/c/new?agent_id=agent123', - testDescription: 'should prioritize window.location.search with agent_id parameter', + expect(mockTextAreaRef.current.focus).toHaveBeenCalledTimes(1); + }); + + test('focuses again when a new request precedes the next navigation', () => { + requestChatFocus(); + + const { rerender } = renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); + expect(mockTextAreaRef.current.focus).toHaveBeenCalledTimes(1); + + requestChatFocus(); + (useLocation as jest.Mock).mockReturnValue({ + key: 'entry-2', + pathname: '/c/abc', + search: '', + state: null, }); + rerender(); - testUrlScenario({ - windowLocationSearch: '', - reactRouterSearch: '?endpoint=openAI&model=gpt-4', - expectedUrl: '/c/new', - testDescription: 'should use empty path when window.location.search is empty', - }); - - testUrlScenario({ - windowLocationSearch: '?agent_id=agent123&prompt=test', - reactRouterSearch: '', - expectedUrl: '/c/new?agent_id=agent123&prompt=test', - testDescription: 'should use window.location.search when React Router search is empty', - }); - - testUrlScenario({ - windowLocationSearch: '?agent_id=agent123', - reactRouterSearch: '?agent_id=differentAgent', - expectedUrl: '/c/new?agent_id=agent123', - testDescription: - 'should use window.location.search even when both have agent_id but with different values', - }); - - testUrlScenario({ - windowLocationSearch: '?agent_id=agent/with%20spaces&prompt=test%20query', - reactRouterSearch: '?endpoint=openAI', - expectedUrl: '/c/new?agent_id=agent/with%20spaces&prompt=test%20query', - testDescription: 'should handle URL parameters with special characters correctly', - }); - - testUrlScenario({ - windowLocationSearch: - '?agent_id=agent123&prompt=test&model=gpt-4&temperature=0.7&max_tokens=1000', - reactRouterSearch: '?endpoint=openAI', - expectedUrl: - '/c/new?agent_id=agent123&prompt=test&model=gpt-4&temperature=0.7&max_tokens=1000', - testDescription: 'should handle multiple URL parameters correctly', - }); - - testUrlScenario({ - windowLocationSearch: '?agent_id=agent123&broken=param=with=equals', - reactRouterSearch: '?endpoint=openAI', - expectedUrl: '/c/new?agent_id=agent123&broken=param=with=equals', - testDescription: 'should pass through malformed URL parameters unchanged', - }); - - test('should handle navigation immediately after URL parameter changes', () => { - window.history.replaceState({}, '', '/c/new?endpoint=openAI&model=gpt-4'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '?endpoint=openAI&model=gpt-4', - state: { focusChat: true }, - }); - - const { rerender } = renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).toHaveBeenCalledWith( - '/c/new?endpoint=openAI&model=gpt-4', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - - jest.clearAllMocks(); - - window.history.replaceState({}, '', '/c/new?agent_id=agent123'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new_changed', - search: '?endpoint=openAI&model=gpt-4', - state: { focusChat: true }, - }); - - rerender(); - - expect(mockNavigate).toHaveBeenCalledWith( - '/c/new_changed?agent_id=agent123', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - }); - - test('should handle undefined or null search params gracefully', () => { - window.history.replaceState({}, '', '/c/new'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: undefined, - state: { focusChat: true }, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).toHaveBeenCalledWith( - '/c/new', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - - jest.clearAllMocks(); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: null, - state: { focusChat: true }, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).toHaveBeenCalledWith( - '/c/new', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - }); - - test('should handle navigation when location.state is null', () => { - window.history.replaceState({}, '', '/c/new?agent_id=agent123'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '?endpoint=openAI&model=gpt-4', - state: null, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); - }); - - test('should handle navigation when location.state.focusChat is undefined', () => { - window.history.replaceState({}, '', '/c/new?agent_id=agent123'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '?endpoint=openAI&model=gpt-4', - state: { someOtherProp: true }, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockTextAreaRef.current.focus).not.toHaveBeenCalled(); - }); - - test('should handle navigation when both search params are empty', () => { - window.history.replaceState({}, '', '/c/new'); - - (useLocation as jest.Mock).mockReturnValue({ - pathname: '/c/new', - search: '', - state: { focusChat: true }, - }); - - renderHook(() => useFocusChatEffect(mockTextAreaRef as any)); - - expect(mockNavigate).toHaveBeenCalledWith( - '/c/new', - expect.objectContaining({ - replace: true, - state: {}, - }), - ); - }); + expect(mockTextAreaRef.current.focus).toHaveBeenCalledTimes(2); }); }); diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 2ab886db00..16fc0b9572 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -28,6 +28,7 @@ import type { SetterOrUpdater } from 'recoil'; import type { TAskFunction, ExtendedFile } from '~/common'; import { logger, + requestChatFocus, hasStreamStartFailed, createDualMessageContent, getRouteChatProjectId, @@ -40,6 +41,11 @@ import { startupConfigKey } from '~/data-provider'; import useUserKey from '~/hooks/Input/useUserKey'; import { useAuthContext } from '~/hooks'; +/** A revalidating cache younger than this is locally authoritative (the run + * that just streamed wrote it) and stays sendable; older ones wait for the + * refetch so a send can't fork from an outdated tail. */ +const STALE_SEND_REVALIDATION_MS = 5_000; + const logChatRequest = (request: Record) => { logger.log('=====================================\nAsk function called with:'); logger.dir(request); @@ -309,6 +315,29 @@ export default function useChatFunctions({ return false; } + /** + * Warm-switch revalidation guard: a navigation invalidates the target's + * cache and renders it while a background refetch reconciles. Deriving + * parentMessageId from that cache could fork from an outdated tail, so + * refuse (composer keeps the text) until the refetch settles — but only + * when the cache is actually old: a just-streamed cache (fresh + * `dataUpdatedAt`) is locally authoritative, and gating it would block + * rapid follow-ups during the post-run reconcile. + */ + if (isExistingConversation && overrideMessages == null) { + const messagesQueryState = queryClient.getQueryState([ + QueryKeys.messages, + conversationId, + ]); + const isRevalidating = + messagesQueryState?.isInvalidated === true && messagesQueryState.fetchStatus === 'fetching'; + const cacheAgeMs = Date.now() - (messagesQueryState?.dataUpdatedAt ?? 0); + if (isRevalidating && cacheAgeMs > STALE_SEND_REVALIDATION_MS) { + logger.warn('[useChatFunctions] Refusing to send while conversation history revalidates'); + return false; + } + } + if (isContinued && !latestMessage) { console.error('cannot continue AI message without latestMessage!'); return; @@ -402,7 +431,8 @@ export default function useChatFunctions({ currentMessages = []; conversationId = null; const projectSearch = chatProjectId ? `?projectId=${encodeURIComponent(chatProjectId)}` : ''; - navigate(`/c/new${projectSearch}`, { state: { focusChat: true } }); + requestChatFocus(); + navigate(`/c/new${projectSearch}`); } const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId; diff --git a/client/src/hooks/Chat/useFocusChatEffect.ts b/client/src/hooks/Chat/useFocusChatEffect.ts index 17c41fef01..63f578e7a4 100644 --- a/client/src/hooks/Chat/useFocusChatEffect.ts +++ b/client/src/hooks/Chat/useFocusChatEffect.ts @@ -1,36 +1,26 @@ import { useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { logger } from '~/utils'; +import { useLocation } from 'react-router-dom'; +import { consumeChatFocus, logger } from '~/utils'; export default function useFocusChatEffect(textAreaRef: React.RefObject) { const location = useLocation(); - const navigate = useNavigate(); useEffect(() => { - if (textAreaRef?.current && location.state?.focusChat) { - logger.log( - 'conversation', - `Focusing textarea on location state change: ${location.pathname}`, - ); - - const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)').matches; - const hasHover = window.matchMedia?.('(hover: hover)').matches; - - const path = `${location.pathname}${window.location.search ?? ''}`; - /* Early return if mobile-like: has coarse pointer OR lacks hover */ - if (hasCoarsePointer || !hasHover) { - navigate(path, { - replace: true, - state: {}, - }); - return; - } - - textAreaRef.current?.focus(); - - navigate(path, { - replace: true, - state: {}, - }); + if (!textAreaRef?.current || !consumeChatFocus()) { + return; } - }, [navigate, textAreaRef, location.pathname, location.state?.focusChat]); + logger.log('conversation', `Focusing textarea on navigation: ${location.pathname}`); + + const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)').matches; + const hasHover = window.matchMedia?.('(hover: hover)').matches; + + /* Skip focusing if mobile-like: has coarse pointer OR lacks hover */ + if (hasCoarsePointer || !hasHover) { + return; + } + + textAreaRef.current?.focus(); + /** `location.key` changes on EVERY navigation (including same-path pushes), + * so a pending focus request is always consumed by the navigation that + * requested it. */ + }, [textAreaRef, location.key, location.pathname]); } diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index a5c2ad0999..8222eb863e 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -14,12 +14,13 @@ import type { TStartupConfig, TModelsConfig, TConversation, + TMessage, } from 'librechat-data-provider'; import { clearModelForNonEphemeralAgent, getDefaultEndpoint, - clearMessagesCache, buildDefaultConvo, + requestChatFocus, logger, } from '~/utils'; import { useApplyModelSpecEffects } from '~/hooks/Agents'; @@ -65,12 +66,18 @@ const useNavigateToConvo = (index = 0) => { const convoData = { ...data }; clearModelForNonEphemeralAgent(convoData); setConversation(convoData); - navigate(`/c/${conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } }); + requestChatFocus(); + navigate(`/c/${conversationId ?? Constants.NEW_CONVO}`); } catch (error) { console.error('Error fetching conversation data on navigation', error); if (conversation) { + /** The conversation fetch failed (deleted convo, lost access): drop the + * warm message cache so stale contents can't render as current when the + * background revalidation fails too. */ + queryClient.removeQueries([QueryKeys.messages, conversationId]); setConversation(conversation as TConversation); - navigate(`/c/${conversationId}`, { state: { focusChat: true } }); + requestChatFocus(); + navigate(`/c/${conversationId}`); } } }; @@ -117,19 +124,37 @@ const useNavigateToConvo = (index = 0) => { }); } clearAllConversations(true); - clearMessagesCache(queryClient, currentConvoId); + /** + * Invalidate (not remove) the departing conversation's messages so + * switching back renders the warm cache instantly while a background + * refetch reconciles; the NEW_CONVO cache still resets for immediate + * optimistic messages. `refetchType: 'none'` because this observer is + * still mounted mid-switch — the default would immediately refetch the + * chat being LEFT; marking stale defers the fetch to the next mount. + */ + if (currentConvoId != null && currentConvoId !== Constants.NEW_CONVO) { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.messages, currentConvoId], + exact: true, + refetchType: 'none', + }); + } + queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], []); if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) { /** - * Remove (not just invalidate) the target's messages so a freshly-mounted - * ChatView refetches them even with `refetchOnMount: false`, including when - * navigating in from a non-chat route (e.g. /projects). + * Invalidate the target's messages: ChatView's query mounts with + * `refetchOnMount: true`, so a cached conversation renders immediately + * and revalidates in the background instead of unmounting into a + * spinner (the old removeQueries path), including when navigating in + * from a non-chat route (e.g. /projects). */ - queryClient.removeQueries([QueryKeys.messages, convo.conversationId]); + queryClient.invalidateQueries([QueryKeys.messages, convo.conversationId]); queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]); fetchFreshData(convo); } else { setConversation(convo); - navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } }); + requestChatFocus(); + navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`); } }; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 2d6a0bb822..6dc1501408 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -27,6 +27,7 @@ import { logger, setDraft, scrollToEnd, + requestChatFocus, getAllContentText, upsertConvoInAllQueries, updateConvoInAllQueries, @@ -751,7 +752,8 @@ export default function useEventHandlers({ setDraft({ id: currentConvoId, value: requestMessage?.text }); restorePendingQuotes(currentConvoId, requestMessage?.quotes); if (isNewChat) { - navigate(`/c/${Constants.NEW_CONVO}`, { replace: true, state: { focusChat: true } }); + requestChatFocus(); + navigate(`/c/${Constants.NEW_CONVO}`, { replace: true }); } return; } diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index fb34897a22..df6df51edd 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -32,6 +32,7 @@ import { getModelSpecPreset, hasModelSelection, buildDefaultConvo, + requestChatFocus, logger, } from '~/utils'; import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; @@ -261,15 +262,21 @@ const useNewConvo = (index = 0) => { document.title = appTitle; } const path = `/c/${Constants.NEW_CONVO}${getParams(conversation)}`; - navigate(path, { state: { focusChat: true } }); + /** Honor disableFocus here too: the transient focus intent survives + * follow-up navigations (unlike the old location.state), so e.g. + * SearchBar's clear-search must not have focus stolen back. */ + if (!disableFocus) { + requestChatFocus(); + } + navigate(path); return; } const path = `/c/${conversation.conversationId}${getParams(conversation)}`; - navigate(path, { - replace: true, - state: disableFocus ? {} : { focusChat: true }, - }); + if (!disableFocus) { + requestChatFocus(); + } + navigate(path, { replace: true }); }, [ endpointsConfig, diff --git a/client/src/utils/focus.ts b/client/src/utils/focus.ts new file mode 100644 index 0000000000..9204581870 --- /dev/null +++ b/client/src/utils/focus.ts @@ -0,0 +1,18 @@ +let pendingChatFocus = false; + +/** + * Transient cross-navigation focus intent for the chat composer. Carried + * outside `location.state` so consuming it needs no second state-clearing + * navigation, which doubled every router-context sweep per conversation + * switch; being transient, it also never re-fires from history entries on + * back/forward navigation. + */ +export const requestChatFocus = (): void => { + pendingChatFocus = true; +}; + +export const consumeChatFocus = (): boolean => { + const requested = pendingChatFocus; + pendingChatFocus = false; + return requested; +}; diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 55734bf679..bffddc118c 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -22,6 +22,7 @@ export * from './presets'; export * from './prompts'; export * from './textarea'; export * from './messages'; +export * from './focus'; export * from './tokens'; export * from './redirect'; export * from './languages';