mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🫥 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:
parent
6757c65a54
commit
f5f462a1c6
11 changed files with 302 additions and 32 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
110
client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
Normal file
110
client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue