From cf30661d20ac6a25f718fbafbcd8bb6d0ac63801 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:59:31 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Display=20Chat?= =?UTF-8?q?=20Title=20in=20Tab=20Setting=20(#14881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../Chat/Messages/SearchButtons.tsx | 4 +- client/src/components/Conversations/Convo.tsx | 6 +- .../src/components/Nav/Settings/registry.tsx | 9 ++ .../SettingsTabs/General/ChatTitleInTab.tsx | 46 ++++++ .../General/__tests__/ChatTitleInTab.spec.tsx | 142 ++++++++++++++++++ client/src/components/Share/ShareView.tsx | 9 +- client/src/data-provider/SSE/queries.ts | 4 +- client/src/hooks/SSE/useEventHandlers.ts | 7 +- client/src/locales/en/translation.json | 1 + client/src/store/settings.ts | 2 + .../src/utils/__tests__/documentTitle.test.ts | 79 ++++++++++ client/src/utils/documentTitle.ts | 36 +++++ client/src/utils/index.ts | 1 + 13 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/General/ChatTitleInTab.tsx create mode 100644 client/src/components/Nav/SettingsTabs/General/__tests__/ChatTitleInTab.spec.tsx create mode 100644 client/src/utils/__tests__/documentTitle.test.ts create mode 100644 client/src/utils/documentTitle.ts diff --git a/client/src/components/Chat/Messages/SearchButtons.tsx b/client/src/components/Chat/Messages/SearchButtons.tsx index 03574946c7..6991cf9f64 100644 --- a/client/src/components/Chat/Messages/SearchButtons.tsx +++ b/client/src/components/Chat/Messages/SearchButtons.tsx @@ -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 ?? ({ diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 074cec5d62..37c996e379 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -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, diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index c2406a804a..333960fcc6 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -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', diff --git a/client/src/components/Nav/SettingsTabs/General/ChatTitleInTab.tsx b/client/src/components/Nav/SettingsTabs/General/ChatTitleInTab.tsx new file mode 100644 index 0000000000..875621b05e --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ChatTitleInTab.tsx @@ -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([ + 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 ( + + ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/__tests__/ChatTitleInTab.spec.tsx b/client/src/components/Nav/SettingsTabs/General/__tests__/ChatTitleInTab.spec.tsx new file mode 100644 index 0000000000..09f0d10fa4 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/__tests__/ChatTitleInTab.spec.tsx @@ -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( + + + { + if (recoilConversation) { + set(store.conversationByIndex(0), recoilConversation); + } + }} + > + + + + , + ); +} + +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'); + }); +}); diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 675f2b6322..59c84a798c 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -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; diff --git a/client/src/data-provider/SSE/queries.ts b/client/src/data-provider/SSE/queries.ts index e701e1f248..16089bf72c 100644 --- a/client/src/data-provider/SSE/queries.ts +++ b/client/src/data-provider/SSE/queries.ts @@ -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)); diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index d8a001d7d1..791b61ed7a 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -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) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index ac9863bea0..83d705302f 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 9f7cd94479..0716c86b46 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -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), diff --git a/client/src/utils/__tests__/documentTitle.test.ts b/client/src/utils/__tests__/documentTitle.test.ts new file mode 100644 index 0000000000..3cc0eacc72 --- /dev/null +++ b/client/src/utils/__tests__/documentTitle.test.ts @@ -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); + }); +}); diff --git a/client/src/utils/documentTitle.ts b/client/src/utils/documentTitle.ts new file mode 100644 index 0000000000..322ade24bc --- /dev/null +++ b/client/src/utils/documentTitle.ts @@ -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(); +}; diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index b7877117a5..91f47f129a 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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';