🗂️ feat: Display Chat Title in Tab Setting (#14881)

* Add setting to toggle chat title in browser tab

Adds a General > Layout toggle controlling whether the browser tab shows
the conversation title or the app title, defaulting to on so existing
behaviour is unchanged.

The tab title had no single owner: several call sites assigned
document.title directly. Route the chat-title writers through a shared
setDocumentTitle helper so the setting applies consistently to sidebar
navigation, search results, SSE title generation, title polling, and the
share view.

* test: cover document title settings

* fix: address chat title review findings

* fix: keep app title for new chats

* fix: distinguish new chat title placeholder
This commit is contained in:
Marco Beretta 2026-08-16 14:59:31 +02:00 committed by GitHub
parent db675209e8
commit cf30661d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 331 additions and 15 deletions

View file

@ -5,8 +5,8 @@ import { useQueryClient } from '@tanstack/react-query';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type { ConversationCursorData } from '~/utils';
import { findConversationInInfinite, setDocumentTitle } from '~/utils';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { findConversationInInfinite } from '~/utils';
import store from '~/store';
export default function SearchButtons({ message }: { message: TMessage }) {
@ -38,7 +38,7 @@ export default function SearchButtons({ message }: { message: TMessage }) {
title = cachedConvo?.title ?? '';
}
document.title = title;
setDocumentTitle(title);
navigateToConvo(
cachedConvo ??
({

View file

@ -9,10 +9,10 @@ import { useGetStartupConfig, useUpdateConversationMutation } from '~/data-provi
import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks';
import ConversationEndpointIcon from './ConversationEndpointIcon';
import { areConversationRenderPropsEqual } from './utils';
import { cn, logger, setDocumentTitle } from '~/utils';
import { NotificationSeverity } from '~/common';
import ConvoActions from './ConvoActions';
import RenameForm from './RenameForm';
import { cn, logger } from '~/utils';
import ConvoLink from './ConvoLink';
import store from '~/store';
@ -161,9 +161,7 @@ function Conversation({
toggleNav();
if (typeof title === 'string' && title.length > 0) {
document.title = title;
}
setDocumentTitle(title);
navigateToConvo(conversation, {
currentConvoId,

View file

@ -24,6 +24,7 @@ import { toggleControl, ThemeSetting, LangSetting } from './controls';
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
import { EngineSTTSetting, EngineTTSSetting } from './SpeechControls';
import FontSizeSelector from '../SettingsTabs/Chat/FontSizeSelector';
import ChatTitleInTab from '../SettingsTabs/General/ChatTitleInTab';
import AdvancedPrompts from '../SettingsTabs/Chat/AdvancedPrompts';
import DuringRunAction from '../SettingsTabs/Chat/DuringRunAction';
import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
@ -115,6 +116,14 @@ export const registry: SettingEntry[] = [
switchId: 'showScrollButton',
}),
},
{
id: 'chatTitleInTab',
tab: GENERAL,
section: 'layout',
labelKey: 'com_nav_chat_title_in_tab',
keywords: ['tab', 'title', 'browser', 'window'],
Component: ChatTitleInTab,
},
// General · Accessibility
{
id: 'keepScreenAwake',

View file

@ -0,0 +1,46 @@
import { useRecoilCallback } from 'recoil';
import { useMatch } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import ToggleSwitch from '../ToggleSwitch';
import { setDocumentTitle } from '~/utils';
import store from '~/store';
export default function ChatTitleInTab() {
const queryClient = useQueryClient();
const conversationId = useMatch('/c/:conversationId')?.params.conversationId;
const handleCheckedChange = useRecoilCallback(
({ snapshot }) =>
(value: boolean) => {
if (!conversationId) {
return;
}
const cachedConversation = queryClient.getQueryData<TConversation>([
QueryKeys.conversation,
conversationId,
]);
const recoilConversation = snapshot.getLoadable(store.conversationByIndex(0)).getValue();
const conversation =
cachedConversation ??
(recoilConversation?.conversationId === conversationId ? recoilConversation : undefined);
setDocumentTitle(
conversationId === Constants.NEW_CONVO ? undefined : conversation?.title,
value,
);
},
[conversationId, queryClient],
);
return (
<ToggleSwitch
stateAtom={store.chatTitleInTab}
localizationKey="com_nav_chat_title_in_tab"
switchId="chatTitleInTab"
onCheckedChange={handleCheckedChange}
/>
);
}

View file

@ -0,0 +1,142 @@
import { RecoilRoot } from 'recoil';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryKeys, LocalStorageKeys } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TConversation } from 'librechat-data-provider';
import { CHAT_TITLE_IN_TAB_KEY } from '~/utils';
import ChatTitleInTab from '../ChatTitleInTab';
import store from '~/store';
const createConversation = (conversationId: string, title: string): TConversation =>
({ conversationId, title }) as TConversation;
const getToggle = () => screen.getByRole('switch', { name: 'Display chat title in tab' });
function renderToggle({
route,
recoilConversation,
cachedConversation,
}: {
route: string;
recoilConversation?: TConversation;
cachedConversation?: TConversation;
}) {
const queryClient = new QueryClient();
if (cachedConversation) {
queryClient.setQueryData(
[QueryKeys.conversation, cachedConversation.conversationId],
cachedConversation,
);
}
return render(
<MemoryRouter initialEntries={[route]}>
<QueryClientProvider client={queryClient}>
<RecoilRoot
initializeState={({ set }) => {
if (recoilConversation) {
set(store.conversationByIndex(0), recoilConversation);
}
}}
>
<ChatTitleInTab />
</RecoilRoot>
</QueryClientProvider>
</MemoryRouter>,
);
}
describe('ChatTitleInTab', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(LocalStorageKeys.APP_TITLE, 'LibreChat');
localStorage.setItem(CHAT_TITLE_IN_TAB_KEY, JSON.stringify(false));
document.title = '';
});
it('restores the active conversation title from the query cache', () => {
renderToggle({
route: '/c/conversation-1',
recoilConversation: createConversation('conversation-1', 'New Chat'),
cachedConversation: createConversation('conversation-1', 'Generated title'),
});
const toggle = getToggle();
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
expect(localStorage.getItem(CHAT_TITLE_IN_TAB_KEY)).toBe('true');
expect(document.title).toBe('Generated title');
});
it('falls back to the matching Recoil conversation when the query cache is empty', () => {
renderToggle({
route: '/c/conversation-1',
recoilConversation: createConversation('conversation-1', 'Cached sidebar title'),
});
const toggle = getToggle();
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
expect(localStorage.getItem(CHAT_TITLE_IN_TAB_KEY)).toBe('true');
expect(document.title).toBe('Cached sidebar title');
});
it('shows an existing conversation deliberately titled New Chat', () => {
renderToggle({
route: '/c/conversation-1',
recoilConversation: createConversation('conversation-1', 'New Chat'),
});
const toggle = getToggle();
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
expect(localStorage.getItem(CHAT_TITLE_IN_TAB_KEY)).toBe('true');
expect(document.title).toBe('New Chat');
});
it('keeps the app title when enabling titles for a new chat', () => {
localStorage.setItem(LocalStorageKeys.APP_TITLE, 'Custom LibreChat');
document.title = 'Custom LibreChat';
renderToggle({
route: '/c/new',
recoilConversation: createConversation('new', 'New Chat'),
});
const toggle = getToggle();
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
expect(localStorage.getItem(CHAT_TITLE_IN_TAB_KEY)).toBe('true');
expect(document.title).toBe('Custom LibreChat');
});
it('preserves page-specific titles outside chat routes', () => {
localStorage.setItem(CHAT_TITLE_IN_TAB_KEY, JSON.stringify(true));
document.title = 'Agent Marketplace | LibreChat';
renderToggle({
route: '/agents',
recoilConversation: createConversation('conversation-1', 'Previous chat'),
});
const toggle = getToggle();
expect(toggle).toBeChecked();
fireEvent.click(toggle);
expect(toggle).not.toBeChecked();
expect(localStorage.getItem(CHAT_TITLE_IN_TAB_KEY)).toBe('false');
expect(document.title).toBe('Agent Marketplace | LibreChat');
});
});

View file

@ -2,8 +2,8 @@ import { memo, useState, useCallback, useContext } from 'react';
import Cookies from 'js-cookie';
import { buildTree } from 'librechat-data-provider';
import { useParams, useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilCallback } from 'recoil';
import { CalendarDays, Settings, MessageSquarePlus } from 'lucide-react';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import {
Spinner,
@ -19,7 +19,7 @@ import {
useToastContext,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/Selectors';
import { cn, getResponseStatus, selectActiveBranchTail } from '~/utils';
import { cn, DEFAULT_APP_TITLE, getResponseStatus, selectActiveBranchTail } from '~/utils';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { useForkSharedConvoMutation } from '~/data-provider';
import { useGetSharedStartupConfig } from '~/data-provider';
@ -117,8 +117,11 @@ function SharedView() {
}, [shareId, forkSharedConvo, getActiveTargetIndex, data?.updatedAt]);
// configure document title
const chatTitleInTab = useRecoilValue(store.chatTitleInTab);
let docTitle = '';
if (config?.appTitle != null && data?.title != null) {
if (!chatTitleInTab) {
docTitle = config?.appTitle || DEFAULT_APP_TITLE;
} else if (config?.appTitle != null && data?.title != null) {
docTitle = `${data.title} | ${config.appTitle}`;
} else {
docTitle = data?.title ?? config?.appTitle ?? document.title;

View file

@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query';
import { apiBaseUrl, QueryKeys, request, dataService } from 'librechat-data-provider';
import type { Agents, TConversation, TPendingSteer } from 'librechat-data-provider';
import { isNotFoundError, updateConvoInAllQueries, setDocumentTitle } from '~/utils';
import { generationProtocolHeaders, withGenerationProtocolQuery } from './protocol';
import { isNotFoundError, updateConvoInAllQueries } from '~/utils';
import { useGetStartupConfig } from '../Endpoints';
export interface StreamStatusResponse {
@ -179,7 +179,7 @@ export function useTitleGeneration(enabled = true) {
updateConvoInAllQueries(queryClient, conversationId, (c) => ({ ...c, title }));
// Only update document title if this conversation is currently active
if (window.location.pathname.includes(conversationId)) {
document.title = title;
setDocumentTitle(title);
}
markTitleGenerationProcessed(conversationId);
setReadyToFetch((prev) => prev.filter((id) => id !== conversationId));

View file

@ -27,6 +27,8 @@ import {
logger,
setDraft,
scrollToEnd,
hasRealTitle,
setDocumentTitle,
requestChatFocus,
getAllContentText,
upsertConvoInAllQueries,
@ -67,9 +69,6 @@ type TTitleEvent = {
};
};
const hasRealTitle = (title?: string | null): title is string =>
title != null && title !== '' && title !== 'New Chat';
/** Skill caches refreshed when a chat turn authors a skill via `create_file`/`edit_file`. */
const SKILL_QUERY_KEYS = [
QueryKeys.skills,
@ -632,7 +631,7 @@ export default function useEventHandlers({
markTitleGenerationProcessed(conversationId);
if (location.pathname.includes(conversationId)) {
document.title = title;
setDocumentTitle(title);
}
if (setConversation && !isAddedRequest) {

View file

@ -477,6 +477,7 @@
"com_nav_change_picture": "Change picture",
"com_nav_chat_direction": "Chat direction",
"com_nav_chat_direction_selected": "Chat direction: {{direction}}",
"com_nav_chat_title_in_tab": "Display chat title in tab",
"com_nav_clear_all_chats": "Clear all chats",
"com_nav_clear_cache_confirm_message": "Are you sure you want to clear the cache?",
"com_nav_clear_conversation": "Clear conversations",

View file

@ -1,6 +1,7 @@
import { atom } from 'recoil';
import { SettingsViews, LocalStorageKeys } from 'librechat-data-provider';
import type { TOptionSettings } from '~/common';
import { CHAT_TITLE_IN_TAB_KEY } from '~/utils/documentTitle';
import { atomWithLocalStorage } from '~/store/utils';
import { STTEndpoints } from '~/common';
@ -58,6 +59,7 @@ const localStorageAtoms = {
),
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
newChatSwitchToHistory: atomWithLocalStorage('newChatSwitchToHistory', true),
chatTitleInTab: atomWithLocalStorage(CHAT_TITLE_IN_TAB_KEY, true),
// Chat settings
enterToSend: atomWithLocalStorage('enterToSend', true),

View file

@ -0,0 +1,79 @@
import { LocalStorageKeys } from 'librechat-data-provider';
import {
hasRealTitle,
setDocumentTitle,
CHAT_TITLE_IN_TAB_KEY,
isChatTitleInTabEnabled,
} from '../documentTitle';
describe('document title', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(LocalStorageKeys.APP_TITLE, 'LibreChat');
document.title = '';
});
afterEach(() => {
jest.restoreAllMocks();
});
it('uses a conversation title when chat titles are enabled', () => {
setDocumentTitle('Project status', true);
expect(document.title).toBe('Project status');
});
it('uses the app title when chat titles are disabled', () => {
setDocumentTitle('Project status', false);
expect(document.title).toBe('LibreChat');
});
it('uses the app title when the conversation title is empty', () => {
setDocumentTitle('', true);
expect(document.title).toBe('LibreChat');
});
it('uses a conversation deliberately titled New Chat when enabled', () => {
setDocumentTitle('New Chat', true);
expect(document.title).toBe('New Chat');
});
it('keeps rejecting the generated new chat placeholder as a real title', () => {
expect(hasRealTitle('New Chat')).toBe(false);
});
it('uses the default app title when no app title is stored', () => {
localStorage.removeItem(LocalStorageKeys.APP_TITLE);
setDocumentTitle('', true);
expect(document.title).toBe('LibreChat');
});
it('uses the default app title when the stored app title is empty', () => {
localStorage.setItem(LocalStorageKeys.APP_TITLE, '');
setDocumentTitle('', true);
expect(document.title).toBe('LibreChat');
});
it('uses the default app title when storage is unavailable', () => {
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage unavailable');
});
setDocumentTitle('', true);
expect(document.title).toBe('LibreChat');
});
it('defaults to enabled when the stored setting is malformed', () => {
localStorage.setItem(CHAT_TITLE_IN_TAB_KEY, 'not-json');
expect(isChatTitleInTabEnabled()).toBe(true);
});
});

View file

@ -0,0 +1,36 @@
import { LocalStorageKeys } from 'librechat-data-provider';
export const CHAT_TITLE_IN_TAB_KEY = 'chatTitleInTab';
export const DEFAULT_APP_TITLE = 'LibreChat';
export const hasRealTitle = (title?: string | null): title is string =>
title != null && title !== '' && title !== 'New Chat';
const getAppTitle = (): string => {
try {
return localStorage.getItem(LocalStorageKeys.APP_TITLE) || DEFAULT_APP_TITLE;
} catch {
return DEFAULT_APP_TITLE;
}
};
/** Reads the setting straight from localStorage so non-React callers stay in sync with the atom. */
export const isChatTitleInTabEnabled = (): boolean => {
try {
const saved = localStorage.getItem(CHAT_TITLE_IN_TAB_KEY);
return saved === null ? true : (JSON.parse(saved) as boolean);
} catch {
return true;
}
};
/**
* Sets the tab title to the conversation title, or to the app title when the
* conversation title is empty or the user opted out.
* Pass `enabled` when the atom's value is already known, since Recoil writes to
* localStorage after the change handler runs.
*/
export const setDocumentTitle = (title?: string | null, enabled?: boolean): void => {
const showChatTitle = enabled ?? isChatTitleInTabEnabled();
document.title = showChatTitle && title != null && title !== '' ? title : getAppTitle();
};

View file

@ -44,6 +44,7 @@ export * from './favoritesError';
export * from './approval';
export * from './steer';
export * from './activityLabels';
export * from './documentTitle';
export * from './numbers';
export { default as cn } from './cn';
export { default as logger } from './logger';