🧭 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:
Danny Avila 2026-07-20 22:31:44 -04:00 committed by GitHub
parent 74de989bde
commit eeb4ea226c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 225 additions and 350 deletions

View file

@ -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);

View file

@ -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') =>

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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]);
}

View file

@ -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}`);
}
};

View file

@ -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;
}

View file

@ -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
View 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;
};

View file

@ -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';