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
12 changes: 5 additions & 7 deletions api/server/controllers/AuthController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -856,17 +856,15 @@ describe('refreshController – OpenID path', () => {
expect(res.status).toHaveBeenCalledWith(403);
});

it('requires sign-in without publishing tokens when the persisted session disappeared', async () => {
it('refreshes with the cookie token when the persisted session expired', async () => {
req.session.reload = jest.fn((callback) => callback(new Error('failed to load session')));

await refreshController(req, res);

expect(clearOpenIDAuthTokens).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ code: 'OPENID_SESSION_MISSING' });
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
expect(storeOpenIDSession).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(clearOpenIDAuthTokens).not.toHaveBeenCalled();
expect(setOpenIDAuthTokens).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).not.toHaveBeenCalledWith({ code: 'OPENID_SESSION_MISSING' });
});

it('does not classify a session store outage as a missing session', async () => {
Expand Down
7 changes: 2 additions & 5 deletions client/src/components/Chat/Input/DuringRunSendButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import React, { forwardRef, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useWatch } from 'react-hook-form';
import { SendActions, SendIcon } from '@librechat/client';
import { Zap, Clock, OctagonPause, ZapOff } from 'lucide-react';
import { composerSubmitClasses, SendActions, SendIcon } from '@librechat/client';
import type { SendAction } from '@librechat/client';
import type { Control } from 'react-hook-form';
import type { ComposerKeyContext, KeyChordSource } from '~/utils/shortcuts';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { isMacPlatform, resolveComposerKeyDown } from '~/utils/shortcuts';
import useComposerBindings from '~/hooks/Input/useComposerBindings';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';

/** The rows, the popover and the chord chips are shared with every other chat
Expand Down Expand Up @@ -175,9 +174,7 @@ const DuringRunSendButton = React.memo(
aria-label={label}
id="during-run-send-button"
disabled={!content || props.disabled === true}
className={cn(
'size-theme-control rounded-theme-control-round bg-text-primary p-theme-compact text-text-primary outline-offset-4 transition-all duration-theme-normal disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
className={composerSubmitClasses()}
data-testid="during-run-send-button"
data-during-run-action={primary}
type="submit"
Expand Down
8 changes: 3 additions & 5 deletions client/src/components/Chat/Input/SendButton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { forwardRef } from 'react';
import { useWatch } from 'react-hook-form';
import { SendIcon, TooltipAnchor } from '@librechat/client';
import { composerSubmitClasses, SendIcon, TooltipAnchor } from '@librechat/client';
import type { Control } from 'react-hook-form';
import { cn, isSubmittableMessage } from '~/utils';
import { isSubmittableMessage } from '~/utils';
import { useLocalize } from '~/hooks';

type SendButtonProps = {
Expand All @@ -24,9 +24,7 @@ const SubmitButton = React.memo(
aria-label={localize('com_nav_send_message')}
id="send-button"
disabled={props.disabled}
className={cn(
'size-theme-control rounded-theme-control-round bg-text-primary p-theme-compact text-text-primary outline-offset-4 transition-all duration-theme-normal disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
className={composerSubmitClasses()}
data-testid="send-button"
type="submit"
>
Expand Down
7 changes: 2 additions & 5 deletions client/src/components/Chat/Input/StopButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { memo } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { composerSubmitClasses, TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';

export default memo(function StopButton({
stop,
Expand All @@ -19,9 +18,7 @@ export default memo(function StopButton({
<button
type="button"
data-testid="stop-generation-button"
className={cn(
'size-theme-control rounded-theme-control-round bg-text-primary p-theme-compact text-text-primary outline-offset-4 transition-all duration-theme-normal disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
className={composerSubmitClasses()}
aria-label={localize('com_nav_stop_generating')}
onClick={(e) => {
setShowStopButton(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ const inputRoot = join(__dirname, '..');
const source = (file: string): string => readFileSync(join(inputRoot, file), 'utf8');

const themedControls = [
['SendButton.tsx', ['size-theme-control', 'rounded-theme-control-round', 'p-theme-compact']],
['StopButton.tsx', ['size-theme-control', 'rounded-theme-control-round', 'p-theme-compact']],
[
'DuringRunSendButton.tsx',
['size-theme-control', 'rounded-theme-control-round', 'p-theme-compact'],
],
/** The submit slot's three faces share one recipe, which owns the coarse-pointer
* tap-target floor as well as the geometry; its tokens are asserted where it
* lives (`composer.spec.ts`). Copying the class string back into any of them
* would take that floor off a phone silently, so the recipe is what is checked. */
['SendButton.tsx', ['composerSubmitClasses()']],
['StopButton.tsx', ['composerSubmitClasses()']],
['DuringRunSendButton.tsx', ['composerSubmitClasses()']],
['InterruptSteerButton.tsx', ['size-theme-control', 'rounded-theme-control-round']],
['AudioRecorder.tsx', ['size="theme"', 'shape="theme"']],
/** Controls that draw their shape from `composerControlClasses()` prove it by
Expand Down
32 changes: 22 additions & 10 deletions client/src/hooks/Chat/__tests__/useNewChat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const mockNewConversation = jest.fn();
const mockClearMessagesCache = jest.fn();
const mockInvalidateQueries = jest.fn();
const mockClearAllDrafts = jest.fn();
const mockClearFilesDraft = jest.fn();
const mockDeleteFiles = jest.fn();
const mockScheduleRetainedRetry = jest.fn();

Expand Down Expand Up @@ -78,6 +79,7 @@ jest.mock('~/hooks/useNewConvo', () => ({
jest.mock('~/utils', () => ({
clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args),
clearAllDrafts: (...args: unknown[]) => mockClearAllDrafts(...args),
clearFilesDraft: (...args: unknown[]) => mockClearFilesDraft(...args),
getNewConversationDraftId: (index = 0) => (index === 0 ? 'new' : `new:${index}`),
getPendingDraftId: (index = 0) => (index === 0 ? 'pending' : `pending:${index}`),
getComposerDraftId: (index = 0, conversationId?: string | null) => {
Expand Down Expand Up @@ -260,26 +262,36 @@ describe('useNewChat', () => {
expect(mockNewConversation).toHaveBeenCalledTimes(1);
});

it('drops the unsaved-chat draft before the reset restores from it', () => {
it('drops the unsaved-chat attachments before the reset restores from them', () => {
const { result } = renderHook(() => useNewChat());

act(() => result.current.startNewChat());

expect(mockClearAllDrafts).toHaveBeenCalledWith('new');
expect(mockClearAllDrafts.mock.invocationCallOrder[0]).toBeLessThan(
expect(mockClearFilesDraft).toHaveBeenCalledWith('new');
expect(mockClearFilesDraft.mock.invocationCallOrder[0]).toBeLessThan(
mockNewConversation.mock.invocationCallOrder[0],
);
});

it('drops only its own pane unsaved-chat draft', () => {
const { result } = renderHook(() => useNewChat({ index: 1 }));
/** The reset is also how a user gets back to an unsaved chat they typed in and left, and the
* restore that puts an unwanted paste back is the same one that puts the message back. */
it('keeps what was typed in the unsaved chat', () => {
const { result } = renderHook(() => useNewChat());

act(() => result.current.startNewChat());

expect(mockClearAllDrafts).toHaveBeenCalledWith('new:1');
expect(mockClearAllDrafts).not.toHaveBeenCalledWith('new');
});

it('drops only its own pane unsaved-chat attachments', () => {
const { result } = renderHook(() => useNewChat({ index: 1 }));

act(() => result.current.startNewChat());

expect(mockClearFilesDraft).toHaveBeenCalledWith('new:1');
expect(mockClearFilesDraft).not.toHaveBeenCalledWith('new');
});

it('deletes the draft uploads a draft-saving user is discarding with the draft', () => {
mockState.saveDrafts = true;
mockState.filesDraft = {
Expand Down Expand Up @@ -315,7 +327,7 @@ describe('useNewChat', () => {
],
});
expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan(
mockClearAllDrafts.mock.invocationCallOrder[0],
mockClearFilesDraft.mock.invocationCallOrder[0],
);
});

Expand Down Expand Up @@ -490,7 +502,7 @@ describe('useNewChat', () => {
act(() => result.current.startNewChat());

expect(mockDeleteFiles).not.toHaveBeenCalled();
expect(mockClearAllDrafts).not.toHaveBeenCalledWith('new');
expect(mockClearFilesDraft).not.toHaveBeenCalledWith('new');
expect(mockClearAllDrafts).toHaveBeenCalledWith('pending');
});

Expand Down Expand Up @@ -895,7 +907,7 @@ describe('useNewChat', () => {
act(() => result.current.startNewChat());

expect(mockClearAllDrafts).toHaveBeenCalledWith('pending');
expect(mockClearAllDrafts).toHaveBeenCalledWith('new');
expect(mockClearFilesDraft).toHaveBeenCalledWith('new');
});

it('deletes pending-draft uploads before clearing them', () => {
Expand Down Expand Up @@ -944,7 +956,7 @@ describe('useNewChat', () => {

expect(mockDeleteFiles).not.toHaveBeenCalled();
expect(mockClearAllDrafts).not.toHaveBeenCalledWith('pending');
expect(mockClearAllDrafts).toHaveBeenCalledWith('new');
expect(mockClearFilesDraft).toHaveBeenCalledWith('new');
});

it('spares a reattached file from a retained deletion retry', async () => {
Expand Down
17 changes: 12 additions & 5 deletions client/src/hooks/Chat/useNewChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { FilesDraft } from '~/utils';
import {
beginRetainedDeletionPass,
clearAllDrafts,
clearFilesDraft,
clearMessagesCache,
clearRetainedFileDeletion,
failedFileIdsFrom,
Expand Down Expand Up @@ -299,10 +300,13 @@ export default function useNewChat({
const startNewChat = useCallback(() => {
clearMessagesCache(queryClient, conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
/** `newConversation` empties the composer, but the unsaved-chat draft key outlives it and
* `useAutoSave` restores from that key on the way in, so an unsent paste came back on every
* later new chat. Dropping the key first makes an explicit new chat an actual clean slate,
* for the text draft and its attachments alike. Per-conversation drafts are untouched.
/** The unsaved-chat draft key outlives the reset and `useAutoSave` restores from it on the way
* in, so an unsent paste came back on every later new chat. Its attachments are dropped here to
* make an explicit new chat a clean slate; its text is deliberately kept, because the restore
* is also what puts a draft back when the user simply leaves the unsaved chat and returns, and
* clearing it made that the one composer that forgot what had been typed in it. Nothing else is
* gained by clearing it: unlike an attachment there is no upload behind it to leave dangling.
* Per-conversation drafts are untouched either way.
*
* With draft saving on, `newConversation` deliberately leaves the draft's files alive
* because a draft normally keeps them restorable; discarding the draft removes the only
Expand Down Expand Up @@ -442,8 +446,11 @@ export default function useNewChat({
if (isFilesDraftOwnedByThisTab(pendingDraft)) {
clearAllDrafts(pendingId);
}
/** Attachments only: the uploads behind them have just been deleted, so restoring the chips
* would reference files that are gone, while the typed message is what the user expects to
* find again on the way back into the unsaved chat. */
if (isFilesDraftOwnedByThisTab(idleDraft)) {
clearAllDrafts(draftId);
clearFilesDraft(draftId);
}
const discardedFileIds = Array.from(
new Set([
Expand Down
Loading
Loading