Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/Providers/ChatSettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const defaultChatSettings: ChatSettings = {
setDuringRunDefaultAction: () => undefined,
steerInterruptsByDefault: false,
resetVisibleArtifacts: () => undefined,
saveDrafts: true,
isTemporary: false,
setIsTemporary: () => undefined,
};

export const ChatSettingsContext = createContext<ChatSettings>(defaultChatSettings);
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
PendingToolApprovalPanel,
} from '~/components/Chat/approval/Review';
import useComposerRestore from '~/hooks/Input/useComposerRestore';
import { useChatSettings } from '~/Providers/ChatSettingsContext';
import usePastedTextEdit from '~/hooks/Files/usePastedTextEdit';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskUserQuestionPopover from './AskUserQuestionPopover';
Expand Down Expand Up @@ -199,7 +200,7 @@ const ChatForm = memo(function ChatForm({
const chatDirection = useRecoilValue(store.chatDirection);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const isTemporary = useRecoilValue(store.isTemporary);
const { isTemporary } = useChatSettings();

const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const plusPopoverAtom = useMemo(() => store.showPlusPopoverFamily(index), [index]);
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/Chat/Input/Composer/Queue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import {
} from '@librechat/client';
import type { RestoreToComposer } from '~/Providers/ComposerRestoreContext';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
import type { QueuedMessage } from '~/hooks/Chat/queue';
import { claimQueuedIntent, releaseQueuedIntent, hasQueuedIntent } from '~/utils/queueIntent';
import { useQueuedTurnPortal } from '~/components/Chat/Steering/QueuedTurnPortal';
import { escalatingSteerFamily, revealedQueuedTurnFamily } from '~/store/steer';
import EscalateNowButton from '~/components/Chat/Input/EscalateNowButton';
import { queuedMessagesByConvoId } from '~/hooks/Chat/queue';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
Expand Down Expand Up @@ -615,7 +616,7 @@ function Queue({
shared constant id would duplicate the element and point every handle's
`aria-describedby` at whichever copy the document happened to keep. */
const reorderHintId = useId();
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
const queued = useAtomValue(queuedMessagesByConvoId(steering.queueKey));
const pendingSteers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const escalating = useAtomValue(escalatingSteerFamily(conversationId));
/* Only one interrupt can be unresolved at a time: a second arm would seal the
Expand Down
64 changes: 36 additions & 28 deletions client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { getDefaultStore } from 'jotai';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { getDefaultStore, useSetAtom } from 'jotai';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ReasoningEffort } from 'librechat-data-provider';
import { act, render, screen, within, fireEvent } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
import type { QueuedMessage } from '~/hooks/Chat/queue';
import {
QueuedTurnPortalProvider,
useQueuedTurnPortal,
} from '~/components/Chat/Steering/QueuedTurnPortal';
import { queuedMessagesByConvoId, resetQueueFamilies } from '~/hooks/Chat/queue';
import { hasQueuedIntent, releaseQueuedIntent } from '~/utils/queueIntent';
import { revealedQueuedTurnFamily } from '~/store/steer';
import Queue from '../Queue';
import store from '~/store';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, options?: Record<string, string | number>) => {
if (!options) {
Expand Down Expand Up @@ -140,7 +140,9 @@ function renderQueue(
} = {},
) {
return render(
<RecoilRoot initializeState={({ set }) => set(store.queuedMessagesByConvoId(CONVO_ID), items)}>
<RecoilRoot
initializeState={() => getDefaultStore().set(queuedMessagesByConvoId(CONVO_ID), items)}
>
{/* Mirrors `App`, which mounts the provider around the whole tree. */}
<DndProvider backend={HTML5Backend}>
<QueuedTurnPortalProvider>
Expand All @@ -161,6 +163,8 @@ function renderQueue(
);
}

beforeEach(() => resetQueueFamilies());

describe('Queue', () => {
beforeEach(() => jest.clearAllMocks());

Expand Down Expand Up @@ -617,34 +621,30 @@ describe('Queue', () => {
/* Split view mounts two composers at once. A module-global id duplicated the
hint element and pointed every handle at whichever copy won. */
it('scopes the reorder hint to its own rail', () => {
/** Split view: both panes share the app's store and differ by conversation. */
const store = getDefaultStore();
store.set(queuedMessagesByConvoId('left-convo'), [
queued({ id: 'q1', text: 'left first' }),
queued({ id: 'q2', text: 'left second' }),
]);
store.set(queuedMessagesByConvoId('right-convo'), [
queued({ id: 'q3', text: 'right first' }),
queued({ id: 'q4', text: 'right second' }),
]);
render(
<DndProvider backend={HTML5Backend}>
<RecoilRoot
initializeState={({ set }) =>
set(store.queuedMessagesByConvoId(CONVO_ID), [
queued({ id: 'q1' }),
queued({ id: 'q2' }),
])
}
>
<RecoilRoot>
<Queue
steering={steering}
conversationId={CONVO_ID}
steering={{ ...steering, queueKey: 'left-convo' }}
conversationId="left-convo"
onRestoreToComposer={jest.fn()}
canRestoreToComposer={() => true}
/>
</RecoilRoot>
<RecoilRoot
initializeState={({ set }) =>
set(store.queuedMessagesByConvoId(CONVO_ID), [
queued({ id: 'q3' }),
queued({ id: 'q4' }),
])
}
>
<RecoilRoot>
<Queue
steering={steering}
conversationId={CONVO_ID}
steering={{ ...steering, queueKey: 'right-convo' }}
conversationId="right-convo"
onRestoreToComposer={jest.fn()}
canRestoreToComposer={() => true}
/>
Expand All @@ -657,6 +657,11 @@ describe('Queue', () => {
expect(hints[0].id).not.toBe(hints[1].id);

const rails = screen.getAllByTestId('composer-queue');
expect(within(rails[0]).getAllByTestId('queued-message-grip')).toHaveLength(2);
expect(within(rails[1]).getAllByTestId('queued-message-grip')).toHaveLength(2);
expect(rails[0]).toHaveTextContent('left first');
expect(rails[0]).not.toHaveTextContent('right first');
expect(rails[1]).toHaveTextContent('right first');
for (const [railIndex, rail] of rails.entries()) {
for (const grip of within(rail).getAllByTestId('queued-message-grip')) {
expect(grip).toHaveAttribute('aria-describedby', hints[railIndex].id);
Expand All @@ -669,13 +674,16 @@ describe('Queue', () => {
it('forgets its last announcement once the queue empties', () => {
let setQueue: (items: QueuedMessage[]) => void = () => undefined;
const Driver = () => {
setQueue = useSetRecoilState(store.queuedMessagesByConvoId(CONVO_ID));
setQueue = useSetAtom(queuedMessagesByConvoId(CONVO_ID));
return null;
};
render(
<RecoilRoot
initializeState={({ set }) =>
set(store.queuedMessagesByConvoId(CONVO_ID), [queued({ id: 'q1' }), queued({ id: 'q2' })])
initializeState={() =>
getDefaultStore().set(queuedMessagesByConvoId(CONVO_ID), [
queued({ id: 'q1' }),
queued({ id: 'q2' }),
])
}
>
<Driver />
Expand Down
17 changes: 8 additions & 9 deletions client/src/components/Chat/Landing.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { HatGlasses } from 'lucide-react';
import { easings } from '@react-spring/web';
import { EModelEndpoint } from 'librechat-data-provider';
Expand All @@ -9,9 +8,9 @@ import Description, { isHtmlDescription } from '~/components/ui/Description';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import { getIconEndpoint, getEntity, getModelSpec } from '~/utils';
import { useLocalize, useAuthContext, useGreeting } from '~/hooks';
import { useChatSettings } from '~/Providers/ChatSettingsContext';
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 ';
Expand Down Expand Up @@ -45,7 +44,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
const { data: endpointsConfig } = useGetEndpointsQuery();
const { user } = useAuthContext();
const localize = useLocalize();
const isTemporary = useRecoilValue(temporaryStore.isTemporary);
const { isTemporary } = useChatSettings();

const [textHasMultipleLines, setTextHasMultipleLines] = useState(false);
const [lineCount, setLineCount] = useState(1);
Expand Down Expand Up @@ -145,7 +144,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
{isTemporary ? (
<div className={containerClassName}>
<HatGlasses className="h-2/3 w-2/3 text-text-primary" aria-hidden="true" />
<HatGlasses className="text-text-primary h-2/3 w-2/3" aria-hidden="true" />
</div>
) : (
<ConvoIcon
Expand All @@ -155,13 +154,13 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3 text-text-primary"
className="text-text-primary h-2/3 w-2/3"
size={41}
/>
)}
{startupConfig?.showBirthdayIcon && (
<TooltipAnchor
className="absolute bottom-[27px] right-2"
className="absolute right-2 bottom-[27px]"
description={localize('com_ui_happy_birthday')}
aria-label={localize('com_ui_happy_birthday')}
>
Expand All @@ -174,7 +173,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${name}`}
text={name}
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
className={`${getTextSizeClass(name)} text-text-primary font-medium`}
delay={50}
textAlign="center"
animationFrom={greetingAnimationFrom}
Expand All @@ -189,7 +188,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<SplitText
key={`split-text-${greetingText}${user?.name ? '-user' : ''}`}
text={greetingText}
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
className={`${getTextSizeClass(greetingText)} text-text-primary font-medium`}
delay={50}
textAlign="center"
animationFrom={greetingAnimationFrom}
Expand All @@ -206,7 +205,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
description={description}
className={
descriptionIsHTML
? 'animate-fadeIn mt-4 flex max-w-md items-center justify-center gap-2 text-center text-sm font-normal text-text-primary [&_img]:inline-block [&_img]:h-4 [&_img]:w-4'
? 'animate-fadeIn text-text-primary mt-4 flex max-w-md items-center justify-center gap-2 text-center text-sm font-normal [&_img]:inline-block [&_img]:h-4 [&_img]:w-4'
: `animate-fadeIn mt-4 max-w-md text-center text-sm font-normal ${isTemporary ? 'text-text-secondary' : 'text-text-primary'}`
}
/>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Chat/Steering/recovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { atomFamily, atomWithStorage, createJSONStorage } from 'jotai/utils';
import type { QueuedMessage } from '~/store/families';
import type { QueuedMessage } from '~/hooks/Chat/queue';

export type RecoveryDisposition = 'blocked' | 'cancelling' | 'cancelled' | 'dismissed';
export type RecoveryDispositions = Partial<Record<string, RecoveryDisposition>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { ChatSettingsContext, defaultChatSettings } from '~/Providers/ChatSettingsContext';
import Landing from '../Landing';

let mockConversation: Record<string, unknown> | null = null;
Expand Down Expand Up @@ -88,8 +88,10 @@ jest.mock('~/components/Endpoints/ConvoIcon', () => () => <span data-testid="con

function renderLanding({ isTemporary = false }: { isTemporary?: boolean } = {}) {
return render(
<RecoilRoot initializeState={({ set }) => set(temporaryStore.isTemporary, isTemporary)}>
<Landing centerFormOnLanding={false} />
<RecoilRoot>
<ChatSettingsContext.Provider value={{ ...defaultChatSettings, isTemporary }}>
<Landing centerFormOnLanding={false} />
</ChatSettingsContext.Provider>
</RecoilRoot>,
);
}
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/Chat/__tests__/TemporaryChat.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 ChatSettingsProvider from '~/routes/ChatSettings';
import store from '~/store';

jest.mock('@librechat/client', () => ({
Expand Down Expand Up @@ -44,7 +45,7 @@ function renderChat(
}
}}
>
{ui}
<ChatSettingsProvider>{ui}</ChatSettingsProvider>
</RecoilRoot>,
);
}
Expand Down
32 changes: 10 additions & 22 deletions client/src/components/Messages/Content/RunCode.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilCallback } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { SquareTerminal, Check, X } from 'lucide';
import { MorphIcon, Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import type { IconNode } from '@librechat/client';
import type { CodeBarProps } from '~/common';
import { useChatSettings } from '~/Providers/ChatSettingsContext';
import { useToolCallMutation } from '~/data-provider';
import { cn, normalizeLanguage } from '~/utils';
import { useMessageContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import store from '~/store';

type RunState = 'idle' | 'loading' | 'success' | 'error';

Expand All @@ -33,13 +32,11 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(

const { messageId, conversationId, partIndex } = useMessageContext();
const normalizedLang = useMemo(() => normalizeLanguage(lang), [lang]);
// Read at click time so retention context is current without re-rendering every code block.
const getIsTemporary = useRecoilCallback(
({ snapshot }) =>
() =>
snapshot.getPromise(store.isTemporary),
[],
);
const { isTemporary } = useChatSettings();
/** Read at execution time, so toggling temporary chat neither rebuilds the debounced run
* (cancelling one already clicked) nor sends the flag the click was made under. */
const isTemporaryRef = useRef(isTemporary);
isTemporaryRef.current = isTemporary;

const handleExecute = useCallback(async () => {
const codeString: string = codeRef.current?.textContent ?? '';
Expand All @@ -59,18 +56,9 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
conversationId: conversationId ?? '',
lang: normalizedLang,
code: codeString,
isTemporary: await getIsTemporary(),
isTemporary: isTemporaryRef.current,
});
}, [
codeRef,
execute,
partIndex,
messageId,
blockIndex,
conversationId,
normalizedLang,
getIsTemporary,
]);
}, [codeRef, execute, partIndex, messageId, blockIndex, conversationId, normalizedLang]);

const debouncedExecute = useMemo(
() => debounce(handleExecute, 1000, { leading: true }),
Expand Down Expand Up @@ -118,9 +106,9 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
aria-label={label}
aria-busy={isLoading || undefined}
className={cn(
'inline-flex select-none items-center justify-center text-text-secondary transition-all duration-200 ease-out',
'text-text-secondary inline-flex items-center justify-center transition-all duration-200 ease-out select-none',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-heavy',
'focus-visible:outline-border-heavy focus-visible:outline focus-visible:outline-2',
'disabled:pointer-events-none disabled:opacity-50',
isError && 'text-text-destructive hover:text-text-destructive',
iconOnly
Expand Down
Loading
Loading