mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧭 perf: Warm Conversation Switches with Single-Navigation Focus Intent (#14334)
* 🧭 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
This commit is contained in:
parent
74de989bde
commit
eeb4ea226c
10 changed files with 225 additions and 350 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => {
|
||||
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<TMessage[]>([
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement>) {
|
||||
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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TMessage[]>([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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
client/src/utils/focus.ts
Normal file
18
client/src/utils/focus.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue