🫥 feat: Add Temporary Chat Empty State and Active Indicator (#15086)

* feat: add temporary chat empty state and active indicator

Temporary Chat gave users a toggle but no page-level confirmation that
they had entered the mode or what it changes. The only cue was the
toggle's pressed state, which is easy to miss, and the toggle itself
retires once the conversation starts, leaving an active temporary chat
with no indication at all.

The landing now swaps its identity block for a temporary-chat empty
state: a dashed message icon, a "Temporary Chat" heading, and a line
explaining that the chat stays out of history and is deleted
automatically. It clears on its own once the first message is sent,
since the landing unmounts at that point.

useTemporaryChat gains isActive for the window where temporary mode is
locked in for a conversation in progress. TemporaryChatIndicator renders
exactly then, so the toggle and the read-only pill never overlap. It is
shown at every breakpoint, collapsing to the icon alone below md while
keeping its accessible name.

The copy matches actual behavior: buildRetentionVisibilityFilter keeps
isTemporary conversations out of the list query, and temporary chats are
stamped with expiredAt from temporaryChatRetention.

* fix: keep temporary conversations out of the sidebar and compose the status pill

The empty state told users a temporary chat would not appear in their
history, but the client seeded it into the conversation list caches
anyway, so the chat sat in the sidebar for the rest of the session until
a refetch or reload dropped it. The history query already excludes
temporary conversations server-side, so the copy described the intended
behavior while the UI contradicted it.

Temporary mode lives on the submission rather than on the draft
conversation, so the optimistic record never carried the flag and every
consumer of that cache entry read a new temporary chat as an ordinary
one. It is now stamped onto the optimistic conversation, only when true
so the legacy expiredAt inference is untouched, and the sync handler
gains the same isTemporary guard the title handler already had.
upsertConvoInAllQueries refuses temporary conversations outright, which
holds the invariant at one point rather than at each caller.

The header indicator now composes the shared Chip primitive instead of
hand-building a pill. Its theme size and shape tokens resolve to the
same 2.25rem height, 0.75rem radius and 0.375rem gap the local classes
hardcoded, so the appearance is unchanged while the indicator follows
future theme work. It also carries role="status" so the mode change
reaches assistive technology, which matters below md where the label is
visually hidden and only the icon remains.
This commit is contained in:
Marco Beretta 2026-08-21 17:35:12 +02:00 committed by GitHub
parent 6757c65a54
commit f5f462a1c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 302 additions and 32 deletions

View file

@ -8,12 +8,12 @@ import {
Permissions,
} from 'librechat-data-provider';
import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus';
import { TemporaryChat, TemporaryChatIndicator } from './TemporaryChat';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import SubagentThreadLink from './SubagentThreadLink';
import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
@ -99,6 +99,7 @@ function Header({
</div>
<div className={cn('flex flex-shrink-0 items-center gap-2', hiddenBehindNav)}>
{hasAccessToTemporaryChat === true && <TemporaryChatIndicator />}
{!isNewChat && <NewChat className="md:hidden" />}
<HeaderMenu startupConfig={startupConfig} className="md:hidden" />
<div className="hidden items-center gap-2 md:flex">

View file

@ -1,5 +1,7 @@
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { easings } from '@react-spring/web';
import { MessageCircleDashed } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import { BirthdayIcon, TooltipAnchor, SplitText } from '@librechat/client';
import {
@ -15,6 +17,7 @@ import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import { useLocalize, useAuthContext, useGreeting } from '~/hooks';
import AgentContact from '~/components/Agents/AgentContact';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import temporaryStore from '~/store/temporary';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-presentation text-text-primary dark:after:shadow-none ';
@ -48,6 +51,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
const { data: endpointsConfig } = useGetEndpointsQuery();
const { user } = useAuthContext();
const localize = useLocalize();
const isTemporary = useRecoilValue(temporaryStore.isTemporary);
const [textHasMultipleLines, setTextHasMultipleLines] = useState(false);
const [lineCount, setLineCount] = useState(1);
@ -81,9 +85,10 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
const brandedSpecLabel = modelSpec?.showOnLanding ? modelSpec.label : '';
const brandedSpecDescription = (modelSpec?.showOnLanding && modelSpec.description) || '';
const name = entity?.name ?? brandedSpecLabel;
const description =
(entity?.description || brandedSpecDescription || conversation?.greeting) ?? '';
const name = isTemporary ? '' : (entity?.name ?? brandedSpecLabel);
const description = isTemporary
? localize('com_ui_temporary_description')
: ((entity?.description || brandedSpecDescription || conversation?.greeting) ?? '');
const descriptionIsHTML = description.trim().startsWith('<');
const sanitizeDescription = useMemo(
@ -140,7 +145,9 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
? customWelcome.replace(/{{user.name}}/g, user.name)
: customWelcome;
const greetingText = resolvedWelcome ?? scheduledGreeting;
const greetingText = isTemporary
? localize('com_ui_temporary')
: (resolvedWelcome ?? scheduledGreeting);
return (
<div
@ -151,16 +158,22 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-2`}
>
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
<ConvoIcon
agentsMap={agentsMap}
assistantMap={assistantMap}
conversation={conversation}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3 text-text-primary"
size={41}
/>
{isTemporary ? (
<div className={containerClassName}>
<MessageCircleDashed className="h-2/3 w-2/3 text-text-primary" aria-hidden="true" />
</div>
) : (
<ConvoIcon
agentsMap={agentsMap}
assistantMap={assistantMap}
conversation={conversation}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3 text-text-primary"
size={41}
/>
)}
{startupConfig?.showBirthdayIcon && (
<TooltipAnchor
className="absolute bottom-[27px] right-2"
@ -210,11 +223,13 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
dangerouslySetInnerHTML={{ __html: sanitizeDescription(description) }}
/>
) : (
<div className="animate-fadeIn mt-4 max-w-md text-center text-sm font-normal text-text-primary">
<div
className={`animate-fadeIn mt-4 max-w-md text-center text-sm font-normal ${isTemporary ? 'text-text-secondary' : 'text-text-primary'}`}
>
{description}
</div>
))}
{selectedAgent && (
{selectedAgent && !isTemporary && (
<AgentContact
agent={selectedAgent}
className="animate-fadeIn mt-2 max-w-md justify-center text-center text-sm"

View file

@ -1,5 +1,5 @@
import { TooltipAnchor } from '@librechat/client';
import { MessageCircleDashed } from 'lucide-react';
import { Chip, TooltipAnchor } from '@librechat/client';
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
import useTemporaryChat from '~/hooks/Chat/useTemporaryChat';
import { useLocalize } from '~/hooks';
@ -39,3 +39,29 @@ export function TemporaryChat() {
</div>
);
}
/** Once the first message is sent the toggle retires, so the active mode still
* needs a persistent, read-only cue in the header. `role="status"` carries the
* mode change to assistive technology, which matters most below `md` where the
* label is visually hidden and only the icon remains. */
export function TemporaryChatIndicator() {
const localize = useLocalize();
const { isActive } = useTemporaryChat();
if (!isActive) {
return null;
}
return (
<Chip
role="status"
tone="neutral"
size="theme"
shape="theme"
className="flex-shrink-0"
leading={<MessageCircleDashed className="size-4 shrink-0" aria-hidden="true" />}
>
<span className="max-md:sr-only">{localize('com_ui_temporary')}</span>
</Chip>
);
}

View file

@ -1,6 +1,8 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import temporaryStore from '~/store/temporary';
import Landing from '../Landing';
let mockConversation: Record<string, unknown> | null = null;
@ -44,6 +46,9 @@ jest.mock('~/hooks', () => ({
const translations: Record<string, string> = {
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
com_ui_temporary: 'Temporary Chat',
com_ui_temporary_description:
"This chat won't appear in your history and will be deleted automatically.",
};
return translations[key] || key;
},
@ -81,6 +86,14 @@ jest.mock('~/utils', () => ({
jest.mock('~/components/Endpoints/ConvoIcon', () => () => <span data-testid="convo-icon" />);
function renderLanding({ isTemporary = false }: { isTemporary?: boolean } = {}) {
return render(
<RecoilRoot initializeState={({ set }) => set(temporaryStore.isTemporary, isTemporary)}>
<Landing centerFormOnLanding={false} />
</RecoilRoot>,
);
}
describe('Landing agent contact', () => {
beforeEach(() => {
mockConversation = null;
@ -102,7 +115,7 @@ describe('Landing agent contact', () => {
},
};
render(<Landing centerFormOnLanding={false} />);
renderLanding();
expect(screen.getByText('Portal Remote Agent')).toBeInTheDocument();
expect(screen.getByText('Remote Agent Showcase')).toBeInTheDocument();
@ -119,7 +132,7 @@ describe('Landing agent contact', () => {
};
mockAgentsMap = {};
render(<Landing centerFormOnLanding={false} />);
renderLanding();
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
expect(screen.queryByText('No contact available')).not.toBeInTheDocument();
@ -138,9 +151,58 @@ describe('Landing agent contact', () => {
},
};
render(<Landing centerFormOnLanding={false} />);
renderLanding();
expect(screen.getByText('Assistant')).toBeInTheDocument();
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
});
});
describe('Landing temporary chat empty state', () => {
beforeEach(() => {
mockConversation = null;
mockAgentsMap = undefined;
mockAssistantMap = undefined;
});
it('replaces the greeting with the temporary chat explanation', () => {
renderLanding({ isTemporary: true });
expect(screen.getByText('Temporary Chat')).toBeInTheDocument();
expect(
screen.getByText("This chat won't appear in your history and will be deleted automatically."),
).toBeInTheDocument();
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
expect(screen.queryByTestId('convo-icon')).not.toBeInTheDocument();
});
it('hides the agent identity and contact while temporary', () => {
mockConversation = {
endpoint: 'agents',
agent_id: 'agent-1',
};
mockAgentsMap = {
'agent-1': {
id: 'agent-1',
name: 'Portal Remote Agent',
description: 'Remote Agent Showcase',
owner_contact: { name: 'Owner User' },
},
};
renderLanding({ isTemporary: true });
expect(screen.getByText('Temporary Chat')).toBeInTheDocument();
expect(screen.queryByText('Portal Remote Agent')).not.toBeInTheDocument();
expect(screen.queryByText('Remote Agent Showcase')).not.toBeInTheDocument();
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
});
it('keeps the normal greeting when temporary chat is off', () => {
renderLanding();
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.queryByText('Temporary Chat')).not.toBeInTheDocument();
expect(screen.getByTestId('convo-icon')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,110 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { TConversation } from 'librechat-data-provider';
import { TemporaryChat, TemporaryChatIndicator } from '../TemporaryChat';
import store from '~/store';
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
TooltipAnchor: ({ render: renderProp }: { render: React.ReactNode }) => <>{renderProp}</>,
}));
jest.mock('~/hooks/useKeyboardShortcuts', () => ({
useShortcutHint: (_id: string, label: string) => label,
useShortcutAriaKey: () => undefined,
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => (key === 'com_ui_temporary' ? 'Temporary Chat' : key),
}));
function renderChat(
ui: React.ReactElement,
{ isTemporary, conversation }: { isTemporary: boolean; conversation?: Partial<TConversation> },
) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.isTemporary, isTemporary);
if (conversation) {
set(store.conversationByIndex(0), conversation as TConversation);
}
}}
>
{ui}
</RecoilRoot>,
);
}
describe('TemporaryChat', () => {
it('offers the toggle on a chat that has not started', () => {
renderChat(<TemporaryChat />, { isTemporary: false });
const toggle = screen.getByRole('button', { name: 'Temporary Chat' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
});
it('marks the toggle pressed while temporary mode is on', () => {
renderChat(<TemporaryChat />, { isTemporary: true });
expect(screen.getByRole('button', { name: 'Temporary Chat' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('retires the toggle once the conversation has started', () => {
renderChat(<TemporaryChat />, {
isTemporary: true,
conversation: { conversationId: 'convo-1' },
});
expect(screen.queryByRole('button', { name: 'Temporary Chat' })).not.toBeInTheDocument();
});
});
describe('TemporaryChatIndicator', () => {
it('stays hidden while the toggle is still offered', () => {
renderChat(<TemporaryChatIndicator />, { isTemporary: true });
expect(screen.queryByText('Temporary Chat')).not.toBeInTheDocument();
});
it('labels a started temporary conversation', () => {
renderChat(<TemporaryChatIndicator />, {
isTemporary: true,
conversation: { conversationId: 'convo-1' },
});
expect(screen.getByText('Temporary Chat')).toBeInTheDocument();
});
it('exposes the cue as a status so the mode change reaches assistive tech', () => {
renderChat(<TemporaryChatIndicator />, {
isTemporary: true,
conversation: { conversationId: 'convo-1' },
});
expect(screen.getByRole('status')).toHaveTextContent('Temporary Chat');
});
it('keeps the label in the accessible name when it is visually hidden', () => {
renderChat(<TemporaryChatIndicator />, {
isTemporary: true,
conversation: { conversationId: 'convo-1' },
});
expect(screen.getByText('Temporary Chat')).toHaveClass('max-md:sr-only');
});
it('stays hidden on a started conversation that is not temporary', () => {
renderChat(<TemporaryChatIndicator />, {
isTemporary: false,
conversation: { conversationId: 'convo-1' },
});
expect(screen.queryByText('Temporary Chat')).not.toBeInTheDocument();
});
});

View file

@ -5,9 +5,11 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import store from '~/store';
export type UseTemporaryChatResult = {
/** Only offered before a conversation has any history it cannot be toggled mid-thread. */
/** Only offered before a conversation has any history, it cannot be toggled mid-thread. */
show: boolean;
isTemporary: boolean;
/** Temporary mode is locked in for the conversation in progress, leaving only a read-only indicator. */
isActive: boolean;
toggle: () => void;
};
@ -24,9 +26,12 @@ export default function useTemporaryChat(): UseTemporaryChatResult {
const hasStarted = conversationId != null && conversationId !== Constants.NEW_CONVO;
const hasMessages = Array.isArray(conversation?.messages) && conversation.messages.length >= 1;
const show = !hasStarted && !hasMessages && !isSubmitting;
return {
show: !hasStarted && !hasMessages && !isSubmitting,
show,
isTemporary,
isActive: isTemporary && !show,
toggle,
};
}

View file

@ -481,7 +481,7 @@ export default function useEventHandlers({
const syncHandler = useCallback(
(data: TSyncData, submission: EventSubmission) => {
const { conversationId, thread_id, responseMessage, requestMessage } = data;
const { initialResponse, messages: _messages, userMessage } = submission;
const { initialResponse, messages: _messages, userMessage, isTemporary = false } = submission;
/** Swap the optimistic user row for the server-stamped one IN PLACE.
* Filtering it out and re-appending at the tail would order any of its
* already-present children (abandoned responses from preempted
@ -525,14 +525,16 @@ export default function useEventHandlers({
return update;
});
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
upsertConvoInAllQueries(queryClient, update);
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
if (update.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
if (!isTemporary) {
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
upsertConvoInAllQueries(queryClient, update);
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
if (update.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
}
}
} else if (setConversation) {
setConversation((prevState) => {

View file

@ -508,6 +508,11 @@ const buildOptimisticConversation = (
messages: messageIds.length > 0 ? messageIds : submission.conversation.messages,
createdAt: submission.conversation.createdAt ?? now,
updatedAt: now,
/** Temporary mode lives on the submission, not on the draft conversation, so
* the optimistic record has to carry it forward or every consumer of this
* cache entry reads a new temporary chat as an ordinary one. Only stamped
* when true, leaving the legacy `expiredAt` inference untouched otherwise. */
...(submission.isTemporary === true ? { isTemporary: true } : {}),
} as TConversation;
};

View file

@ -2200,6 +2200,7 @@
"com_ui_table_error_description": "The table failed to load. Refresh the page or try again.",
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
"com_ui_temporary_description": "This chat won't appear in your history and will be deleted automatically.",
"com_ui_terms_and_conditions": "Terms and Conditions",
"com_ui_terms_of_service": "Terms of service",
"com_ui_text_variables": "Text variables",

View file

@ -733,6 +733,40 @@ describe('Conversation Utilities', () => {
expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a', 'c']);
});
it('upsertConvoInAllQueries keeps temporary conversations out of the list', () => {
upsertConvoInAllQueries(queryClient, { ...convoB, isTemporary: true } as TConversation);
const data = queryClient.getQueryData<InfiniteData<{ conversations: TConversation[] }>>([
'allConversations',
]);
expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a']);
});
it('upsertConvoInAllQueries keeps legacy expiring conversations out of the list', () => {
upsertConvoInAllQueries(queryClient, {
...convoB,
expiredAt: '2099-01-01T00:00:00Z',
} as TConversation);
const data = queryClient.getQueryData<InfiniteData<{ conversations: TConversation[] }>>([
'allConversations',
]);
expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a']);
});
it('upsertConvoInAllQueries still admits retained conversations that carry an expiry', () => {
upsertConvoInAllQueries(queryClient, {
...convoB,
isTemporary: false,
expiredAt: '2099-01-01T00:00:00Z',
} as TConversation);
const data = queryClient.getQueryData<InfiniteData<{ conversations: TConversation[] }>>([
'allConversations',
]);
expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['b', 'a']);
});
it('updateConvoInAllQueries updates correct convo', () => {
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, model: 'gpt-4' }));
const data = queryClient.getQueryData<InfiniteData<any>>(['allConversations']);

View file

@ -12,6 +12,7 @@ import {
} from 'date-fns';
import type { TConversation, GroupedConversations } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import { isTemporaryConversation } from './conversation';
// Date group helpers
export const dateKeys = {
@ -474,6 +475,14 @@ export function upsertConvoInAllQueries(
return;
}
/* The history query excludes temporary conversations server-side, so seeding
one into the list caches would surface it in the sidebar until the next
refetch, contradicting what temporary mode promises. Enforced here rather
than at each caller so a future insert path cannot reintroduce the leak. */
if (isTemporaryConversation(nextConvo)) {
return;
}
/* Root-level SSE updates and resumable settlement go through upsert, not
update. Merge into any already-cached pin so that path cannot leave the
section at the old title or position. Do not insert: a new chat is not