mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🗂️ 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:
parent
db675209e8
commit
cf30661d20
13 changed files with 331 additions and 15 deletions
|
|
@ -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 ??
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
79
client/src/utils/__tests__/documentTitle.test.ts
Normal file
79
client/src/utils/__tests__/documentTitle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
client/src/utils/documentTitle.ts
Normal file
36
client/src/utils/documentTitle.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue