diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx
index b2620b91e0..8df1f1e995 100644
--- a/client/src/components/Chat/Landing.tsx
+++ b/client/src/components/Chat/Landing.tsx
@@ -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 (
-
+ {isTemporary ? (
+
+
+
+ ) : (
+
+ )}
{startupConfig?.showBirthdayIcon && (
) : (
-
+
{description}
))}
- {selectedAgent && (
+ {selectedAgent && !isTemporary && (
);
}
+
+/** 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 (
+ }
+ >
+ {localize('com_ui_temporary')}
+
+ );
+}
diff --git a/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx
index 4ad1ec91a9..a2e42799de 100644
--- a/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx
+++ b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx
@@ -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 | null = null;
@@ -44,6 +46,9 @@ jest.mock('~/hooks', () => ({
const translations: Record = {
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', () => () => );
+function renderLanding({ isTemporary = false }: { isTemporary?: boolean } = {}) {
+ return render(
+ set(temporaryStore.isTemporary, isTemporary)}>
+
+ ,
+ );
+}
+
describe('Landing agent contact', () => {
beforeEach(() => {
mockConversation = null;
@@ -102,7 +115,7 @@ describe('Landing agent contact', () => {
},
};
- render();
+ 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();
+ renderLanding();
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
expect(screen.queryByText('No contact available')).not.toBeInTheDocument();
@@ -138,9 +151,58 @@ describe('Landing agent contact', () => {
},
};
- render();
+ 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();
+ });
+});
diff --git a/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx b/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
new file mode 100644
index 0000000000..ff8a5ad0ba
--- /dev/null
+++ b/client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
@@ -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 },
+) {
+ return render(
+ {
+ set(store.isTemporary, isTemporary);
+ if (conversation) {
+ set(store.conversationByIndex(0), conversation as TConversation);
+ }
+ }}
+ >
+ {ui}
+ ,
+ );
+}
+
+describe('TemporaryChat', () => {
+ it('offers the toggle on a chat that has not started', () => {
+ renderChat(, { 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(, { isTemporary: true });
+
+ expect(screen.getByRole('button', { name: 'Temporary Chat' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+ });
+
+ it('retires the toggle once the conversation has started', () => {
+ renderChat(, {
+ 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(, { isTemporary: true });
+
+ expect(screen.queryByText('Temporary Chat')).not.toBeInTheDocument();
+ });
+
+ it('labels a started temporary conversation', () => {
+ renderChat(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ isTemporary: false,
+ conversation: { conversationId: 'convo-1' },
+ });
+
+ expect(screen.queryByText('Temporary Chat')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/hooks/Chat/useTemporaryChat.ts b/client/src/hooks/Chat/useTemporaryChat.ts
index b5ff73ad15..21162305d6 100644
--- a/client/src/hooks/Chat/useTemporaryChat.ts
+++ b/client/src/hooks/Chat/useTemporaryChat.ts
@@ -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,
};
}
diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts
index 354be925e8..4c5b6b0e5f 100644
--- a/client/src/hooks/SSE/useEventHandlers.ts
+++ b/client/src/hooks/SSE/useEventHandlers.ts
@@ -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) => {
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 88d2ed6b00..67ed895718 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -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;
};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index b271a313e5..49253657f7 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts
index b91d7b298f..7236e4cbbc 100644
--- a/client/src/utils/convos.spec.ts
+++ b/client/src/utils/convos.spec.ts
@@ -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>([
+ '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>([
+ '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>([
+ '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>(['allConversations']);
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index f9648b619c..4708222ebf 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -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