diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index e599c6cefc..02ce42ca2b 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -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({
+ {hasAccessToTemporaryChat === true && } {!isNewChat && }
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 ( + + ); +} 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