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
32 changes: 32 additions & 0 deletions client/src/Providers/ChatSettingsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createContext, useContext } from 'react';

/** Composer action while a run is in flight: fold the text into the run, or queue a new turn. */
export type DuringRunAction = 'steer' | 'queue';

/**
* App-global preferences the chat reads but does not own. The host supplies them, so the chat
* hooks never reach into the app's state store for shell settings. A preference belongs here only
* once every chat reader of it takes it from here: a reader left on the store would act on a
* different value than a host that supplies its own.
*/
export type ChatSettings = {
/** Default composer action while a run is in flight. */
duringRunDefaultAction: DuringRunAction;
setDuringRunDefaultAction: (action: DuringRunAction) => void;
/** Whether a steer interrupts the running step instead of waiting for the next one. */
steerInterruptsByDefault: boolean;
/** Closes the artifacts panel, called when the active conversation changes. */
resetVisibleArtifacts: () => void;
};

/** Stock values, used when no host supplies settings (isolated renders and tests). */
export const defaultChatSettings: ChatSettings = {
duringRunDefaultAction: 'steer',
setDuringRunDefaultAction: () => undefined,
steerInterruptsByDefault: false,
resetVisibleArtifacts: () => undefined,
};

export const ChatSettingsContext = createContext<ChatSettings>(defaultChatSettings);

export const useChatSettings = () => useContext(ChatSettingsContext);
4 changes: 1 addition & 3 deletions client/src/components/Chat/Input/DuringRunSendButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { forwardRef, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useWatch } from 'react-hook-form';
import { Zap, Clock, OctagonPause, ZapOff } from 'lucide-react';
import { composerSubmitClasses, SendActions, SendIcon } from '@librechat/client';
Expand All @@ -10,7 +9,6 @@ import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { isMacPlatform, resolveComposerKeyDown } from '~/utils/shortcuts';
import useComposerBindings from '~/hooks/Input/useComposerBindings';
import { useLocalize } from '~/hooks';
import store from '~/store';

/** The rows, the popover and the chord chips are shared with every other chat
* surface that can submit more than one way — see `SendActions`. */
Expand Down Expand Up @@ -40,9 +38,9 @@ type DuringRunSendButtonProps = {
const DuringRunSendButton = React.memo(
forwardRef((props: DuringRunSendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize();
const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault);
const { shortcutsEnabled, submitOverride, yieldedChords } = useComposerBindings();
const { steering, enterToSend } = props;
const { steerInterruptsByDefault } = steering;
const data = useWatch({ control: props.control });
const content = data?.text?.trim();
const primary = steering.effectiveAction;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ const steeringStub = ({
pausedOnApproval = false,
canSteer = true,
canControlGeneration = true,
}: StubOptions) =>
steerInterruptsByDefault = false,
}: StubOptions & { steerInterruptsByDefault?: boolean }) =>
({
effectiveAction: canSteer ? 'steer' : 'queue',
canSteer,
canControlGeneration,
pausedOnApproval,
steerInterruptsByDefault,
interruptSteer: mockInterruptSteer,
steerFromComposer: mockSteerFromComposer,
queueFromComposer: mockQueueFromComposer,
Expand Down Expand Up @@ -83,12 +85,14 @@ function openMenu(options: MenuOptions = {}) {
render(
<RecoilRoot
initializeState={({ set }) => {
set(store.steerInterruptsByDefault, enterInterrupts);
set(store.shortcutsEnabled, shortcutsEnabled);
set(store.customShortcuts, customShortcuts);
}}
>
<Harness steering={steeringStub(stub)} enterToSend={enterToSend} />
<Harness
steering={steeringStub({ ...stub, steerInterruptsByDefault: enterInterrupts })}
enterToSend={enterToSend}
/>
</RecoilRoot>,
);
expect(screen.getByText('com_ui_interrupt_steer')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { useAtomValue, useSetAtom } from 'jotai';
import type { TFile, TMessage } from 'librechat-data-provider';
import SteerReceipt, { type SteerReceiptState } from '~/components/Chat/Steering/Receipt';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
import { liveAppliedSteerFamily, liveAppliedSteerIdsAtom } from '~/store/steer';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import MessageQuotes from '~/components/Chat/Messages/Content/MessageQuotes';
import { cn, hydrateFileDeliveryMetadata, usesImagePreview } from '~/utils';
Expand Down Expand Up @@ -95,8 +97,8 @@ const SteerPart = memo(function SteerPart({
* identity consumes its id whether it animated or not, so nothing lingers.
* The membership selector scopes the subscription to THIS id — stamping or
* consuming one steer never re-renders the other mounted parts. */
const isLiveApplied = useRecoilValue(store.liveAppliedSteerFamily(steerId ?? ''));
const setLiveAppliedIds = useSetRecoilState(store.liveAppliedSteerIds);
const isLiveApplied = useAtomValue(liveAppliedSteerFamily(steerId ?? ''));
const setLiveAppliedIds = useSetAtom(liveAppliedSteerIdsAtom);
const [captured, setCaptured] = useState<{ id: string | undefined; animate: boolean }>({
id: steerId,
animate: isLiveApplied,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { RecoilRoot } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { Provider, createStore, useAtomValue } from 'jotai';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { TMessage } from 'librechat-data-provider';
import { STEER_ICON } from '~/components/Chat/Steering/identity';
import { liveAppliedSteerIdsAtom } from '~/store/steer';
import SteerPart from '../SteerPart';
import store from '~/store';

Expand Down Expand Up @@ -243,21 +245,24 @@ describe('SteerPart presentation', () => {

describe('SteerPart live receipt draw-in', () => {
function LiveIdsProbe() {
const ids = useRecoilValue(store.liveAppliedSteerIds);
const ids = useAtomValue(liveAppliedSteerIdsAtom);
return <div data-testid="live-ids">{ids.join(',')}</div>;
}

function seededStore(liveIds: string[]) {
const jotaiStore = createStore();
jotaiStore.set(liveAppliedSteerIdsAtom, liveIds);
return jotaiStore;
}

function renderLive(liveIds: string[]) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.user, SEEDED_USER as never);
set(store.liveAppliedSteerIds, liveIds);
}}
>
<SteerPart steer="steered words" steerId="s1" createdAt={1} />
<LiveIdsProbe />
</RecoilRoot>,
<Provider store={seededStore(liveIds)}>
<RecoilRoot initializeState={({ set }) => set(store.user, SEEDED_USER as never)}>
<SteerPart steer="steered words" steerId="s1" createdAt={1} />
<LiveIdsProbe />
</RecoilRoot>
</Provider>,
);
}

Expand Down Expand Up @@ -286,28 +291,23 @@ describe('SteerPart live receipt draw-in', () => {
<LiveIdsProbe />
</>
);
const jotaiStore = seededStore(['s2']);
const { rerender } = render(
<RecoilRoot
initializeState={({ set }) => {
set(store.user, SEEDED_USER as never);
set(store.liveAppliedSteerIds, ['s2']);
}}
>
{partFor('s1')}
</RecoilRoot>,
<Provider store={jotaiStore}>
<RecoilRoot initializeState={({ set }) => set(store.user, SEEDED_USER as never)}>
{partFor('s1')}
</RecoilRoot>
</Provider>,
);
expect(appliedChecks()).not.toHaveClass('animate-in');
expect(screen.getByTestId('live-ids')).toHaveTextContent('s2');

rerender(
<RecoilRoot
initializeState={({ set }) => {
set(store.user, SEEDED_USER as never);
set(store.liveAppliedSteerIds, ['s2']);
}}
>
{partFor('s2')}
</RecoilRoot>,
<Provider store={jotaiStore}>
<RecoilRoot initializeState={({ set }) => set(store.user, SEEDED_USER as never)}>
{partFor('s2')}
</RecoilRoot>
</Provider>,
);
expect(appliedChecks()).toHaveClass('animate-in');
expect(screen.getByTestId('live-ids').textContent).toBe('');
Expand Down
Loading
Loading