mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: double send, vanishing thinking control, stuck lift
Send now fell back to the message the row was rendered with, so a click landing just after the drain sent the same words twice. An admin override replaced a reasoning parameter instead of merging into it, dropping the options the control needs to render at all. The landing screen released its lift only when the popup closed, not when the composer unmounted. Chip widths move into state, concurrent steer retries each keep their own frame handle, and starting a stream is wrapped so a throw cannot leave the composer stuck generating.
This commit is contained in:
parent
b639d547c0
commit
0ebb6740c1
27 changed files with 707 additions and 148 deletions
|
|
@ -427,19 +427,61 @@ const ChatForm = memo(function ChatForm({
|
|||
/** One button slot while a run is generating: with composer text the send
|
||||
* button takes over (Enter steers/queues; hover reveals all actions);
|
||||
* clearing the text restores Stop. */
|
||||
const duringRunSlot =
|
||||
steering.duringRunActive && (textValue?.trim() ?? '') !== '' ? (
|
||||
<DuringRunSendButton
|
||||
ref={submitButtonRef}
|
||||
control={methods.control}
|
||||
steering={steering}
|
||||
getText={() => methods.getValues('text')}
|
||||
onConsumed={() => methods.reset()}
|
||||
disabled={filesLoading}
|
||||
/>
|
||||
) : (
|
||||
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
||||
);
|
||||
const duringRunSlot = useMemo(
|
||||
() =>
|
||||
steering.duringRunActive && (textValue?.trim() ?? '') !== '' ? (
|
||||
<DuringRunSendButton
|
||||
ref={submitButtonRef}
|
||||
control={methods.control}
|
||||
steering={steering}
|
||||
getText={() => methods.getValues('text')}
|
||||
onConsumed={() => methods.reset()}
|
||||
disabled={filesLoading}
|
||||
/>
|
||||
) : (
|
||||
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
||||
),
|
||||
[
|
||||
steering,
|
||||
textValue,
|
||||
methods,
|
||||
submitButtonRef,
|
||||
filesLoading,
|
||||
handleStopGenerating,
|
||||
setShowStopButton,
|
||||
],
|
||||
);
|
||||
|
||||
/* Memoized for `memo(Bar)`: an inline element is a new identity every render,
|
||||
and this component re-renders on every keystroke. */
|
||||
const actionSlot = useMemo(
|
||||
() =>
|
||||
isSubmitting && showStopButton && !answerMode.active
|
||||
? duringRunSlot
|
||||
: endpoint && (
|
||||
<SendButton
|
||||
ref={submitButtonRef}
|
||||
control={methods.control}
|
||||
disabled={
|
||||
filesLoading ||
|
||||
disableInputs ||
|
||||
isNotAppendable ||
|
||||
(isSubmitting && !answerMode.active)
|
||||
}
|
||||
/>
|
||||
),
|
||||
[
|
||||
endpoint,
|
||||
duringRunSlot,
|
||||
filesLoading,
|
||||
disableInputs,
|
||||
isNotAppendable,
|
||||
isSubmitting,
|
||||
showStopButton,
|
||||
answerMode.active,
|
||||
methods.control,
|
||||
],
|
||||
);
|
||||
|
||||
/* The empty-conversation screen. Drives both how far the composer floats off
|
||||
the bottom and whether the ambient tips under it are worth their row. */
|
||||
|
|
@ -653,22 +695,7 @@ const ChatForm = memo(function ChatForm({
|
|||
showSpeech={SpeechToText}
|
||||
speechDisabled={disableInputs || isNotAppendable}
|
||||
dictation={dictation}
|
||||
actionSlot={
|
||||
isSubmitting && showStopButton && !answerMode.active
|
||||
? duringRunSlot
|
||||
: endpoint && (
|
||||
<SendButton
|
||||
ref={submitButtonRef}
|
||||
control={methods.control}
|
||||
disabled={
|
||||
filesLoading ||
|
||||
disableInputs ||
|
||||
isNotAppendable ||
|
||||
(isSubmitting && !answerMode.active)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
actionSlot={actionSlot}
|
||||
/>
|
||||
<ToolDialogs />
|
||||
</BadgeRowProvider>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const CHIP_GAP = 6;
|
|||
* the buttons keep a row of their own rather than being crowded by whichever
|
||||
* two or three chips happened to be left over.
|
||||
*/
|
||||
function chipsFitInline<T extends { key: string }>(
|
||||
export function chipsFitInline<T extends { key: string }>(
|
||||
entries: T[],
|
||||
widths: Record<string, number>,
|
||||
capacity: number,
|
||||
|
|
@ -59,23 +59,25 @@ function chipsFitInline<T extends { key: string }>(
|
|||
return true;
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
export function formatElapsed(seconds: number): string {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface RoundButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function RoundButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
primary = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
}: RoundButtonProps) {
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={label}
|
||||
|
|
@ -107,7 +109,11 @@ function RoundButton({
|
|||
* be changed without reopening the palette. Real menu items, unlike the
|
||||
* pointer-only pills inside the palette's `option` rows.
|
||||
*/
|
||||
function ChipModes({ modes }: { modes: PaletteMode[] }) {
|
||||
interface ChipModesProps {
|
||||
modes: PaletteMode[];
|
||||
}
|
||||
|
||||
function ChipModes({ modes }: ChipModesProps) {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const active = modes.find((mode) => mode.active);
|
||||
|
|
|
|||
|
|
@ -257,6 +257,11 @@ function Palette({
|
|||
[],
|
||||
);
|
||||
|
||||
/* The popup stays "mounted" through its leave transition, so a composer
|
||||
unmounted inside that window never reaches the reset below and leaves the
|
||||
landing screen holding a lift with no popup under it. */
|
||||
useEffect(() => () => setLift(0), [setLift]);
|
||||
|
||||
const baselineRef = useRef<number | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) {
|
||||
|
|
@ -280,7 +285,7 @@ function Palette({
|
|||
}, [mounted, popupHeight, setLift, anchorRef, follow]);
|
||||
|
||||
const favorites = useToolFavorites();
|
||||
const recent = useRecentFiles(mounted && canAttach);
|
||||
const recent = useRecentFiles(mounted && canAttach, { files, setFiles, conversation });
|
||||
const attach = useAttachItems({
|
||||
agentId,
|
||||
endpoint,
|
||||
|
|
@ -828,16 +833,16 @@ function Palette({
|
|||
<span className="truncate text-xs text-text-secondary opacity-80">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
{/* Modes ride on the parent row rather than a row of their own. Pointer
|
||||
only, like the star — an `option` must not own focusable children;
|
||||
the chip in the bar carries the keyboard-reachable equivalent. */}
|
||||
{/* Modes ride on the parent row rather than a row of their own.
|
||||
Pointer targets rather than buttons, like the star: an `option`
|
||||
must not own focusable children, and a hidden button is still
|
||||
click-focusable, which strands a reader inside a hidden subtree.
|
||||
The chip in the bar carries the keyboard-reachable equivalent. */}
|
||||
{modes != null &&
|
||||
modes.map((mode) => (
|
||||
<button
|
||||
<span
|
||||
key={mode.id}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mode.onSelect();
|
||||
|
|
@ -850,13 +855,11 @@ function Palette({
|
|||
)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{isEntry && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
<span
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
favorites.toggleFavorite(row.entry.itemType, row.entry.itemId);
|
||||
|
|
@ -873,7 +876,7 @@ function Palette({
|
|||
fill={favorited ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -976,7 +979,7 @@ function Palette({
|
|||
role="combobox"
|
||||
aria-expanded={rows.length > 0}
|
||||
autoComplete="off"
|
||||
aria-controls="composer-palette-list"
|
||||
aria-controls={rows.length > 0 ? 'composer-palette-list' : undefined}
|
||||
aria-activedescendant={
|
||||
activeRow != null && isSelectable(activeRow)
|
||||
? rowElementId(activeRow.key)
|
||||
|
|
|
|||
|
|
@ -276,6 +276,16 @@ function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer
|
|||
const [announcement, setAnnouncement] = useState('');
|
||||
const order = useMemo(() => queued.map((message) => message.id), [queued]);
|
||||
|
||||
/* Cleared when the rail empties or the conversation changes: the region is
|
||||
removed with the rail and re-inserted with its old text still in it, which
|
||||
readers announce on insertion — so an unrelated new message replayed the
|
||||
last move. */
|
||||
const [spokenFor, setSpokenFor] = useState(steering.queueKey);
|
||||
if (spokenFor !== steering.queueKey || (queued.length === 0 && announcement !== '')) {
|
||||
setSpokenFor(steering.queueKey);
|
||||
setAnnouncement('');
|
||||
}
|
||||
|
||||
if (queued.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,12 @@ import { cn } from '~/utils';
|
|||
const RESIZE_MS = 190;
|
||||
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
|
||||
function ThinkingControl({
|
||||
setting,
|
||||
conversation,
|
||||
}: {
|
||||
interface ThinkingControlProps {
|
||||
setting: SettingDefinition;
|
||||
conversation: TConversation | null;
|
||||
}) {
|
||||
}
|
||||
|
||||
function ThinkingControl({ setting, conversation }: ThinkingControlProps) {
|
||||
const localize = useLocalize();
|
||||
const reducedMotion = useReducedMotion();
|
||||
/* Ariakit owns the open state rather than a controlled `open`/`setOpen` pair:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ const KIND_REMOVE_KEY = {
|
|||
skill: 'com_ui_remove_skill',
|
||||
} as const;
|
||||
|
||||
function ItemChip({ item }: { item: ComposerItem }) {
|
||||
interface ItemChipProps {
|
||||
item: ComposerItem;
|
||||
}
|
||||
|
||||
function ItemChip({ item }: ItemChipProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { chipsFitInline, formatElapsed } from '../Bar';
|
||||
|
||||
/**
|
||||
* The two pure decisions behind the bar's layout: whether the chips share the
|
||||
* button row, and how long a recording has been running.
|
||||
*/
|
||||
|
||||
const chip = (key: string) => ({ key });
|
||||
|
||||
describe('chipsFitInline', () => {
|
||||
const widths = { a: 40, b: 60, c: 100 };
|
||||
|
||||
it('keeps an empty row inline, whatever the room', () => {
|
||||
expect(chipsFitInline([], widths, 0)).toBe(true);
|
||||
expect(chipsFitInline([], widths, 500)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives chips a row of their own once there is no room at all', () => {
|
||||
expect(chipsFitInline([chip('a')], widths, 0)).toBe(false);
|
||||
expect(chipsFitInline([chip('a')], widths, -10)).toBe(false);
|
||||
});
|
||||
|
||||
/* On the first pass nothing has been measured, and guessing would move the
|
||||
chips twice: once on the guess and again on the measurement. */
|
||||
it('keeps everything inline until anything has been measured', () => {
|
||||
expect(chipsFitInline([chip('a'), chip('b')], {}, 10)).toBe(true);
|
||||
});
|
||||
|
||||
/* Once the measured ones already overflow, the answer is known without the
|
||||
rest: the row cannot hold them whatever the unmeasured chip turns out to be. */
|
||||
it('wraps as soon as the measured chips alone do not fit', () => {
|
||||
expect(chipsFitInline([chip('c'), chip('unmeasured')], widths, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the gap between chips, not just the chips', () => {
|
||||
/* 40 + 60 alone would fit exactly; the gap between them is what does not. */
|
||||
expect(chipsFitInline([chip('a'), chip('b')], widths, 100)).toBe(false);
|
||||
expect(chipsFitInline([chip('a'), chip('b')], widths, 108)).toBe(true);
|
||||
});
|
||||
|
||||
it('takes a row that fills the space exactly', () => {
|
||||
expect(chipsFitInline([chip('c')], widths, 100)).toBe(true);
|
||||
expect(chipsFitInline([chip('c')], widths, 99)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatElapsed', () => {
|
||||
it.each([
|
||||
[0, '0:00'],
|
||||
[5, '0:05'],
|
||||
[59, '0:59'],
|
||||
[60, '1:00'],
|
||||
[61, '1:01'],
|
||||
[600, '10:00'],
|
||||
[3599, '59:59'],
|
||||
])('reads %i seconds as %s', (seconds, expected) => {
|
||||
expect(formatElapsed(seconds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { RecoilRoot, useSetRecoilState } from 'recoil';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react';
|
||||
import { act, render, screen, within, fireEvent } from '@testing-library/react';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
import type { QueuedMessage } from '~/store/families';
|
||||
import Queue from '../Queue';
|
||||
|
|
@ -24,17 +24,22 @@ const mockRemoveQueued = jest.fn();
|
|||
const mockReorderQueued = jest.fn();
|
||||
const mockRestoreQueuedOrder = jest.fn();
|
||||
|
||||
const steering = {
|
||||
queueKey: CONVO_ID,
|
||||
duringRunActive: true,
|
||||
canSteer: true,
|
||||
sendQueuedNow: mockSendQueuedNow,
|
||||
removeQueued: mockRemoveQueued,
|
||||
reorderQueued: mockReorderQueued,
|
||||
restoreQueuedOrder: mockRestoreQueuedOrder,
|
||||
} as unknown as SteeringControls;
|
||||
/** Only what the rail reads, filled out against the real type so a change to
|
||||
* the contract breaks compilation rather than passing quietly. */
|
||||
const steeringWith = (over: Partial<SteeringControls> = {}): SteeringControls =>
|
||||
({
|
||||
queueKey: CONVO_ID,
|
||||
duringRunActive: true,
|
||||
canSteer: true,
|
||||
sendQueuedNow: mockSendQueuedNow,
|
||||
removeQueued: mockRemoveQueued,
|
||||
reorderQueued: mockReorderQueued,
|
||||
restoreQueuedOrder: mockRestoreQueuedOrder,
|
||||
...over,
|
||||
}) as SteeringControls;
|
||||
|
||||
const pausedSteering = { ...steering, canSteer: false } as unknown as SteeringControls;
|
||||
const steering = steeringWith();
|
||||
const pausedSteering = steeringWith({ canSteer: false });
|
||||
|
||||
const queued = (over: Partial<QueuedMessage> = {}): QueuedMessage =>
|
||||
({
|
||||
|
|
@ -194,6 +199,40 @@ describe('Queue', () => {
|
|||
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
|
||||
});
|
||||
|
||||
/* The region is removed with the rail and re-inserted with its old text
|
||||
still in it, which readers announce on insertion. */
|
||||
it('forgets its last announcement once the queue empties', () => {
|
||||
let setQueue: (items: QueuedMessage[]) => void = () => undefined;
|
||||
const Driver = () => {
|
||||
setQueue = useSetRecoilState(store.queuedMessagesByConvoId(CONVO_ID));
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) =>
|
||||
set(store.queuedMessagesByConvoId(CONVO_ID), [queued({ id: 'q1' }), queued({ id: 'q2' })])
|
||||
}
|
||||
>
|
||||
<Driver />
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Queue
|
||||
steering={steering}
|
||||
conversationId={CONVO_ID}
|
||||
onEditToComposer={jest.fn()}
|
||||
onRestoreToComposer={jest.fn()}
|
||||
/>
|
||||
</DndProvider>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getAllByTestId('queued-message-grip')[0], { key: 'ArrowDown' });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('com_ui_queue_moved:2');
|
||||
|
||||
act(() => setQueue([]));
|
||||
act(() => setQueue([queued({ id: 'q3', text: 'a new message' })]));
|
||||
expect(screen.getByRole('status')).toHaveTextContent('');
|
||||
});
|
||||
|
||||
it('shows an attachment count when files ride along', () => {
|
||||
renderQueue([queued({ files: [{ file_id: 'f1' }, { file_id: 'f2' }] as never })]);
|
||||
const attachmentLabel = screen.getByText('com_ui_attachment_count:2');
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ const ACTION_CLASS =
|
|||
* `ContentTypes.STEER` part (`useResumableSSE` removes the pending entry), so
|
||||
* the row's whole job is to hold the position and admit it is provisional.
|
||||
*/
|
||||
function PendingSteers({ conversationId }: { conversationId: string }) {
|
||||
interface PendingSteersProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
function PendingSteers({ conversationId }: PendingSteersProps) {
|
||||
const localize = useLocalize();
|
||||
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
|
||||
const { retry, sendAsNew } = useSteerRecovery(conversationId);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import useAttachExisting from '~/hooks/Files/useAttachExisting';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
|
|
@ -74,7 +75,8 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
},
|
||||
});
|
||||
|
||||
const handleFileClick = useAttachExisting();
|
||||
const { files, setFiles, conversation } = useChatContext();
|
||||
const handleFileClick = useAttachExisting({ files, setFiles, conversation });
|
||||
|
||||
const filenameFilter = table.getColumn('filename')?.getFilterValue() as string;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||
import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil';
|
||||
import { Constants, ContentTypes, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { TConversation, TMessage } from 'librechat-data-provider';
|
||||
import type { QueuedMessage } from '~/store/families';
|
||||
import useSteering from '../useSteering';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -59,6 +60,19 @@ function useQueue(convoId: string) {
|
|||
return useRecoilValue(store.queuedMessagesByConvoId(convoId));
|
||||
}
|
||||
|
||||
/** Puts a message in the queue and then sends it, which is the only order the
|
||||
* rail can produce: Send now is offered for a message the queue is holding. */
|
||||
function sendFromQueue(
|
||||
current: {
|
||||
steering: ReturnType<typeof useSteering>;
|
||||
setQueue: (items: QueuedMessage[]) => void;
|
||||
},
|
||||
item: QueuedMessage,
|
||||
) {
|
||||
current.setQueue([item]);
|
||||
current.steering.sendQueuedNow(item);
|
||||
}
|
||||
|
||||
describe('useSteering', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -456,6 +470,7 @@ describe('useSteering', () => {
|
|||
...params,
|
||||
}),
|
||||
queue: useQueue(CONVO_ID),
|
||||
setQueue: useSetRecoilState(store.queuedMessagesByConvoId(CONVO_ID)),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
|
@ -499,10 +514,26 @@ describe('useSteering', () => {
|
|||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* The drain can take the head between the click dispatching and the row
|
||||
unmounting. Falling back to the captured item sent the same words twice. */
|
||||
it('refuses to send a message the queue no longer holds', () => {
|
||||
const { result, sendNow } = setupWithFiles({ isSubmitting: false });
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
id: 'already-drained',
|
||||
text: 'sent moments ago',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
expect(sendNow).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(result.current.queue).toEqual([]);
|
||||
});
|
||||
|
||||
it('steers a queued media item with its own files during a live run', () => {
|
||||
const { result, sendNow } = setupWithFiles();
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
sendFromQueue(result.current, {
|
||||
id: 'q-media',
|
||||
text: 'media message',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -520,7 +551,7 @@ describe('useSteering', () => {
|
|||
it('sends a media item as a normal turn with its own files when idle', () => {
|
||||
const { result, sendNow } = setupWithFiles({ isSubmitting: false });
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
sendFromQueue(result.current, {
|
||||
id: 'q-media',
|
||||
text: 'media message',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -584,7 +615,7 @@ describe('useSteering', () => {
|
|||
];
|
||||
const { result } = setupWithFiles();
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
sendFromQueue(result.current, {
|
||||
id: 'q-requeue',
|
||||
text: 'requeued media',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -621,6 +652,7 @@ describe('useSteering', () => {
|
|||
...params,
|
||||
}),
|
||||
queue: useQueue(CONVO_ID),
|
||||
setQueue: useSetRecoilState(store.queuedMessagesByConvoId(CONVO_ID)),
|
||||
chips: useRecoilValue(store.pendingSteersByConvoId(CONVO_ID)),
|
||||
pendingQuotes: useRecoilValue(store.pendingQuotesByConvoId(CONVO_ID)),
|
||||
pendingSkills: useRecoilValue(store.pendingManualSkillsByConvoId(CONVO_ID)),
|
||||
|
|
@ -694,7 +726,7 @@ describe('useSteering', () => {
|
|||
it('sendQueuedNow passes the carried context to sendNow when idle', () => {
|
||||
const { result, sendNow } = setupWithContext({ isSubmitting: false });
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
sendFromQueue(result.current, {
|
||||
id: 'q-ctx',
|
||||
text: 'context send',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -716,7 +748,7 @@ describe('useSteering', () => {
|
|||
});
|
||||
const { result } = setupWithContext();
|
||||
act(() => {
|
||||
result.current.steering.sendQueuedNow({
|
||||
sendFromQueue(result.current, {
|
||||
id: 'q-degraded',
|
||||
text: 'carried context',
|
||||
createdAt: Date.now(),
|
||||
|
|
|
|||
|
|
@ -748,7 +748,13 @@ export default function useSteering({
|
|||
* restore context so a degraded steer requeues/sends with them intact. */
|
||||
const sendQueuedNow = useCallback(
|
||||
(item: QueuedMessage) => {
|
||||
const taken = takeQueued(item.id) ?? item;
|
||||
/* No fallback to the captured item: the only way it is missing is that
|
||||
something else already took it — the run-end drain, moments before this
|
||||
click landed — and re-sending it would send the same words twice. */
|
||||
const taken = takeQueued(item.id);
|
||||
if (taken == null) {
|
||||
return;
|
||||
}
|
||||
if (duringRunActive && canSteer) {
|
||||
submitSteer(taken.text, taken.files, {
|
||||
quotes: taken.quotes,
|
||||
|
|
|
|||
|
|
@ -25,11 +25,6 @@ jest.mock('@librechat/client', () => ({
|
|||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useFileMapContext: () => mockFileMap,
|
||||
useChatContext: () => ({
|
||||
files: mockStaged,
|
||||
setFiles: jest.fn(),
|
||||
conversation: mockConversation,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
|
|
@ -66,7 +61,13 @@ const staged = (over: Partial<ExtendedFile> = {}): ExtendedFile =>
|
|||
({ file_id: 'other', size: MB, progress: 1, ...over }) as ExtendedFile;
|
||||
|
||||
const attach = (target: TFile = file()) => {
|
||||
const { result } = renderHook(() => useAttachExisting());
|
||||
const { result } = renderHook(() =>
|
||||
useAttachExisting({
|
||||
files: mockStaged,
|
||||
setFiles: jest.fn(),
|
||||
conversation: mockConversation as never,
|
||||
}),
|
||||
);
|
||||
result.current(target);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
getEndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import { useFileMapContext, useChatContext } from '~/Providers';
|
||||
import type { TFile, TConversation } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { useFileMapContext } from '~/Providers';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
|
|
@ -21,11 +22,22 @@ import useUpdateFiles from './useUpdateFiles';
|
|||
* file exists, but nothing guarantees the endpoint the user has since switched
|
||||
* to accepts its storage backend, type or size.
|
||||
*/
|
||||
export default function useAttachExisting(): (file: TFile) => void {
|
||||
export interface AttachExistingContext {
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
conversation: TConversation | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given rather than read from the chat context: the palette holds this hook and
|
||||
* is mounted for the whole conversation, so subscribing there re-rendered the
|
||||
* composer's whole tool catalog every time the context value changed.
|
||||
*/
|
||||
export default function useAttachExisting(context: AttachExistingContext): (file: TFile) => void {
|
||||
const localize = useLocalize();
|
||||
const fileMap = useFileMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { files, setFiles, conversation } = useChatContext();
|
||||
const { files, setFiles, conversation } = context;
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,16 @@ describe('useComposerItems', () => {
|
|||
expect(result.current.quotes).toEqual(['same words']);
|
||||
});
|
||||
|
||||
/* An index inside the id rewrote every id after a removal, which remounts
|
||||
those chips and drops focus off whichever one was being used. */
|
||||
it('leaves the ids of the surviving chips alone', () => {
|
||||
const { result } = withStaged(['first', 'second', 'third']);
|
||||
const before = result.current.items.map((item) => item.id);
|
||||
|
||||
act(() => result.current.items[0].remove());
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(before.slice(1));
|
||||
});
|
||||
|
||||
it('carries the full text for a chip that has to truncate it', () => {
|
||||
const long = 'a quote long enough that the chip will have to cut it short somewhere';
|
||||
const { result } = withStaged([long]);
|
||||
|
|
|
|||
80
client/src/hooks/Input/__tests__/useRecentFiles.spec.tsx
Normal file
80
client/src/hooks/Input/__tests__/useRecentFiles.spec.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import useRecentFiles from '../useRecentFiles';
|
||||
|
||||
/**
|
||||
* The palette's "your files" section: newest first, and fetched only while the
|
||||
* popup is open, since this is the user's whole file list.
|
||||
*/
|
||||
|
||||
let mockFiles: TFile[] | undefined;
|
||||
let mockEnabled: boolean | undefined;
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFiles: ({ enabled }: { enabled: boolean }) => {
|
||||
mockEnabled = enabled;
|
||||
return { data: mockFiles };
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useAttachExisting', () => ({
|
||||
__esModule: true,
|
||||
default: () => jest.fn(),
|
||||
}));
|
||||
|
||||
const file = (over: Partial<TFile>): TFile => ({ file_id: 'f', ...over }) as TFile;
|
||||
|
||||
const context = {
|
||||
files: new Map(),
|
||||
setFiles: jest.fn(),
|
||||
conversation: null,
|
||||
};
|
||||
|
||||
const recent = (enabled = true) =>
|
||||
renderHook(() => useRecentFiles(enabled, context)).result.current;
|
||||
|
||||
describe('useRecentFiles', () => {
|
||||
beforeEach(() => {
|
||||
mockFiles = undefined;
|
||||
mockEnabled = undefined;
|
||||
});
|
||||
|
||||
it('fetches only while the palette is open', () => {
|
||||
recent(false);
|
||||
expect(mockEnabled).toBe(false);
|
||||
recent(true);
|
||||
expect(mockEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lists nothing before the files have loaded', () => {
|
||||
expect(recent().files).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts the most recently touched file first', () => {
|
||||
mockFiles = [
|
||||
file({ file_id: 'older', createdAt: '2026-01-01T00:00:00Z' }),
|
||||
file({ file_id: 'newest', createdAt: '2026-07-01T00:00:00Z' }),
|
||||
file({ file_id: 'middle', createdAt: '2026-03-01T00:00:00Z' }),
|
||||
];
|
||||
expect(recent().files.map((item) => item.file_id)).toEqual(['newest', 'middle', 'older']);
|
||||
});
|
||||
|
||||
/* A file that was re-uploaded or renamed is newly touched, so its update
|
||||
time is what places it, not the day it first arrived. */
|
||||
it('prefers the update time over the creation time', () => {
|
||||
mockFiles = [
|
||||
file({
|
||||
file_id: 'edited',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
}),
|
||||
file({ file_id: 'newer-original', createdAt: '2026-07-01T00:00:00Z' }),
|
||||
];
|
||||
expect(recent().files.map((item) => item.file_id)).toEqual(['edited', 'newer-original']);
|
||||
});
|
||||
|
||||
it('leaves an undated file at the back rather than dropping it', () => {
|
||||
mockFiles = [file({ file_id: 'undated' }), file({ file_id: 'dated', createdAt: '2026-01-01' })];
|
||||
expect(recent().files.map((item) => item.file_id)).toEqual(['dated', 'undated']);
|
||||
});
|
||||
});
|
||||
127
client/src/hooks/Input/__tests__/useSpeechToTextBrowser.spec.tsx
Normal file
127
client/src/hooks/Input/__tests__/useSpeechToTextBrowser.spec.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import useSpeechToTextBrowser from '../useSpeechToTextBrowser';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* Dropping a take. The auto-send timer is the load-bearing part: a transcript
|
||||
* that already landed will fire it after the user has cancelled, sending words
|
||||
* they just discarded.
|
||||
*/
|
||||
|
||||
const mockAbortListening = jest.fn();
|
||||
const mockStopListening = jest.fn();
|
||||
const mockResetTranscript = jest.fn();
|
||||
let mockFinalTranscript = '';
|
||||
|
||||
jest.mock('react-speech-recognition', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
startListening: jest.fn(),
|
||||
stopListening: (...args: unknown[]) => mockStopListening(...args),
|
||||
abortListening: (...args: unknown[]) => mockAbortListening(...args),
|
||||
},
|
||||
useSpeechRecognition: () => ({
|
||||
listening: true,
|
||||
finalTranscript: mockFinalTranscript,
|
||||
interimTranscript: '',
|
||||
resetTranscript: mockResetTranscript,
|
||||
isMicrophoneAvailable: true,
|
||||
browserSupportsSpeechRecognition: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetCustomConfigSpeechQuery: () => ({ data: { sttExternal: false } }),
|
||||
}));
|
||||
|
||||
jest.mock('../useGetAudioSettings', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ speechToTextEndpoint: 'browser' }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const AUTO_SEND_SECONDS = 3;
|
||||
|
||||
function setup() {
|
||||
const setText = jest.fn();
|
||||
const onTranscriptionComplete = jest.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot initializeState={({ set }) => set(store.autoSendText, AUTO_SEND_SECONDS)}>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
);
|
||||
const rendered = renderHook(() => useSpeechToTextBrowser(setText, onTranscriptionComplete), {
|
||||
wrapper,
|
||||
});
|
||||
return { ...rendered, setText, onTranscriptionComplete };
|
||||
}
|
||||
|
||||
describe('useSpeechToTextBrowser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockFinalTranscript = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends a landed transcript once the auto-send delay passes', () => {
|
||||
mockFinalTranscript = 'the words that landed';
|
||||
const { onTranscriptionComplete } = setup();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(AUTO_SEND_SECONDS * 1000);
|
||||
});
|
||||
expect(onTranscriptionComplete).toHaveBeenCalledWith('the words that landed');
|
||||
});
|
||||
|
||||
/* The take is cancelled after the transcript arrived but before the delay
|
||||
elapsed, which is the window where the words are already staged. */
|
||||
it('does not send a transcript after the take has been dropped', () => {
|
||||
mockFinalTranscript = 'the words that landed';
|
||||
const { result, onTranscriptionComplete } = setup();
|
||||
|
||||
act(() => {
|
||||
result.current.abortRecording();
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(AUTO_SEND_SECONDS * 1000);
|
||||
});
|
||||
|
||||
expect(onTranscriptionComplete).not.toHaveBeenCalled();
|
||||
expect(mockAbortListening).toHaveBeenCalled();
|
||||
expect(mockResetTranscript).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* `abortListening` is optional on the recogniser module, and stopping still
|
||||
has to drop the take rather than leaving the microphone running. */
|
||||
it('falls back to stopping when the module cannot abort', () => {
|
||||
const speech = jest.requireMock('react-speech-recognition').default as Record<string, unknown>;
|
||||
const abort = speech.abortListening;
|
||||
delete speech.abortListening;
|
||||
|
||||
mockFinalTranscript = 'the words that landed';
|
||||
const { result, onTranscriptionComplete } = setup();
|
||||
act(() => {
|
||||
result.current.abortRecording();
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(AUTO_SEND_SECONDS * 1000);
|
||||
});
|
||||
|
||||
expect(mockStopListening).toHaveBeenCalled();
|
||||
expect(onTranscriptionComplete).not.toHaveBeenCalled();
|
||||
speech.abortListening = abort;
|
||||
});
|
||||
});
|
||||
68
client/src/hooks/Input/__tests__/useThinkingSetting.spec.ts
Normal file
68
client/src/hooks/Input/__tests__/useThinkingSetting.spec.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import useThinkingSetting from '../useThinkingSetting';
|
||||
|
||||
/**
|
||||
* Which parameter the composer's thinking control writes. Every provider spells
|
||||
* reasoning differently, and resolving the wrong key is silent: the slider
|
||||
* moves, the request carries a parameter the model ignores.
|
||||
*/
|
||||
|
||||
let mockEndpointsConfig: Record<string, unknown>;
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
|
||||
}));
|
||||
|
||||
const setting = (conversation: Partial<TConversation>) =>
|
||||
renderHook(() => useThinkingSetting(conversation as TConversation)).result.current;
|
||||
|
||||
describe('useThinkingSetting', () => {
|
||||
beforeEach(() => {
|
||||
mockEndpointsConfig = {};
|
||||
});
|
||||
|
||||
it('resolves nothing without a conversation or an endpoint', () => {
|
||||
expect(setting({})).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[EModelEndpoint.anthropic, 'claude-sonnet-4-5', 'effort'],
|
||||
[EModelEndpoint.openAI, 'gpt-5', 'reasoning_effort'],
|
||||
[EModelEndpoint.google, 'gemini-3-pro-preview', 'thinkingLevel'],
|
||||
])('reads %s as %s', (endpoint, model, expected) => {
|
||||
const resolved = setting({ endpoint, model });
|
||||
expect(resolved?.key).toBe(expected);
|
||||
});
|
||||
|
||||
/* Only a discrete set of levels renders as this slider; a bare numeric budget
|
||||
belongs in the parameters panel. */
|
||||
it('offers only settings that name their levels', () => {
|
||||
const resolved = setting({ endpoint: EModelEndpoint.anthropic, model: 'claude-sonnet-4-5' });
|
||||
expect((resolved?.options?.length ?? 0) > 0).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves nothing for an endpoint that defines no parameters', () => {
|
||||
expect(
|
||||
setting({ endpoint: 'SomeCustomEndpoint' as EModelEndpoint, model: 'a-model' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
/* An admin override refines the built-in definition. Replacing it outright
|
||||
dropped the levels, and a reasoning setting without levels is not rendered
|
||||
at all, so the control silently disappeared. */
|
||||
it('keeps the built-in levels when an override names only a default', () => {
|
||||
mockEndpointsConfig = {
|
||||
[EModelEndpoint.openAI]: {
|
||||
customParams: {
|
||||
paramDefinitions: [{ key: 'reasoning_effort', default: 'high' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolved = setting({ endpoint: EModelEndpoint.openAI, model: 'gpt-5' });
|
||||
expect(resolved?.key).toBe('reasoning_effort');
|
||||
expect((resolved?.options?.length ?? 0) > 0).toBe(true);
|
||||
expect(resolved?.default).toBe('high');
|
||||
});
|
||||
});
|
||||
|
|
@ -116,7 +116,12 @@ export default function useAttachItems({
|
|||
}: UseAttachItemsParams): UseAttachItems {
|
||||
const localize = useLocalize();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
/* The local picker reads this at event time, where a ref is the only thing
|
||||
fast enough: the change event can arrive before React has committed. The
|
||||
SharePoint dialog reads it during render, which a ref cannot serve, so the
|
||||
same choice is mirrored into state for it. */
|
||||
const toolResourceRef = useRef<EToolResources | undefined>();
|
||||
const [sharePointResource, setSharePointResource] = useState<EToolResources | undefined>();
|
||||
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
|
||||
const [, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(conversationId));
|
||||
|
||||
|
|
@ -128,7 +133,7 @@ export default function useAttachItems({
|
|||
});
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } =
|
||||
useSharePointFileHandlingNoChatContext(
|
||||
{ toolResource: toolResourceRef.current },
|
||||
{ toolResource: sharePointResource },
|
||||
{ files, setFiles, setFilesLoading, conversation },
|
||||
);
|
||||
|
||||
|
|
@ -191,6 +196,7 @@ export default function useAttachItems({
|
|||
const entries = useMemo<AttachEntry[]>(() => {
|
||||
const setToolResource = (value: EToolResources | undefined) => {
|
||||
toolResourceRef.current = value;
|
||||
setSharePointResource(value);
|
||||
};
|
||||
|
||||
const build = (onAction: (fileType?: FileUploadType) => void, prefix: string) => {
|
||||
|
|
|
|||
|
|
@ -29,58 +29,55 @@ export default function useChipPacking<T extends Keyed>(
|
|||
widths: Record<string, number>;
|
||||
} {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const widthsRef = useRef<Record<string, number>>({});
|
||||
const [version, setVersion] = useState(0);
|
||||
/* State, not a ref: the order below is render output, and deriving it from a
|
||||
value React does not track let two passes of the same render disagree. */
|
||||
const [widths, setWidths] = useState<Record<string, number>>({});
|
||||
|
||||
const ordered = useMemo(() => {
|
||||
const widths = widthsRef.current;
|
||||
/* Until every chip has been measured, leave the order alone: a partial sort
|
||||
would shuffle on each pass and never settle. `version` is the dependency
|
||||
that re-runs this once measuring completes. */
|
||||
would shuffle on each pass and never settle. */
|
||||
if (items.some((item) => widths[item.key] == null)) {
|
||||
return items;
|
||||
}
|
||||
return [...items].sort((a, b) => widths[b.key] - widths[a.key]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items, version]);
|
||||
}, [items, widths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const widths = widthsRef.current;
|
||||
let changed = false;
|
||||
const measured: Record<string, number> = {};
|
||||
/* Queried by role rather than walked as children: the caller may split the
|
||||
chips across rows. Document order still matches `ordered`. */
|
||||
root.querySelectorAll<HTMLElement>('[role="listitem"]').forEach((node, index) => {
|
||||
const key = ordered[index]?.key;
|
||||
const width = node.offsetWidth;
|
||||
if (key != null && width > 0 && widths[key] !== width) {
|
||||
widths[key] = width;
|
||||
changed = true;
|
||||
if (key != null && width > 0) {
|
||||
measured[key] = width;
|
||||
}
|
||||
});
|
||||
/* Drop stale entries so a chip that comes back later is re-measured. */
|
||||
const live = new Set(items.map((item) => item.key));
|
||||
for (const key of Object.keys(widths)) {
|
||||
if (!live.has(key)) {
|
||||
delete widths[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
setVersion((value) => value + 1);
|
||||
}
|
||||
}, [items, ordered]);
|
||||
|
||||
/* Snapshotted so a new measurement is a new identity, which is what lets the
|
||||
caller memoize on it. */
|
||||
const widths = useMemo(
|
||||
() => ({ ...widthsRef.current }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[version],
|
||||
);
|
||||
setWidths((prev) => {
|
||||
const next: Record<string, number> = {};
|
||||
let changed = false;
|
||||
/* Only live chips are carried over, so a width cannot outlive the chip it
|
||||
belongs to and place it wrongly when it comes back. */
|
||||
for (const item of items) {
|
||||
const width = measured[item.key] ?? prev[item.key];
|
||||
if (width != null) {
|
||||
next[item.key] = width;
|
||||
}
|
||||
if (next[item.key] !== prev[item.key]) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed && Object.keys(next).length === Object.keys(prev).length) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [items, ordered]);
|
||||
|
||||
return { ordered, rootRef, widths };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,16 @@ export default function useComposerItems(conversationId: string): ComposerItem[]
|
|||
return useMemo(() => {
|
||||
const items: ComposerItem[] = [];
|
||||
|
||||
/* Keyed by content, with the index only breaking ties between two
|
||||
identical excerpts: an index in every id rewrote the ids of everything
|
||||
after a removal, remounting those chips and dropping focus. */
|
||||
const seen = new Map<string, number>();
|
||||
for (let i = 0; i < quotes.length; i++) {
|
||||
const text = quotes[i];
|
||||
const repeat = seen.get(text) ?? 0;
|
||||
seen.set(text, repeat + 1);
|
||||
items.push({
|
||||
id: `quote:${i}:${text.slice(0, 24)}`,
|
||||
id: repeat === 0 ? `quote:${text}` : `quote:${text}#${repeat}`,
|
||||
kind: 'quote',
|
||||
label: text,
|
||||
title: text,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import type { TAskFunction } from '~/common';
|
||||
import useGetAudioSettings from './useGetAudioSettings';
|
||||
|
|
@ -182,13 +182,21 @@ export default function useDictation({
|
|||
existingTextRef.current = '';
|
||||
}, [abortRecording, reset]);
|
||||
|
||||
return {
|
||||
active,
|
||||
transcribing: isLoading === true || settling,
|
||||
elapsed,
|
||||
start,
|
||||
cancel,
|
||||
stopToComposer: useCallback(() => stopWith('compose'), [stopWith]),
|
||||
stopAndSend: useCallback(() => stopWith('send'), [stopWith]),
|
||||
};
|
||||
const stopToComposer = useCallback(() => stopWith('compose'), [stopWith]);
|
||||
const stopAndSend = useCallback(() => stopWith('send'), [stopWith]);
|
||||
|
||||
/* Memoized so `memo(Bar)` has something that can compare equal: a fresh
|
||||
object here re-rendered the whole bar on every keystroke in the composer. */
|
||||
return useMemo(
|
||||
() => ({
|
||||
active,
|
||||
transcribing: isLoading === true || settling,
|
||||
elapsed,
|
||||
start,
|
||||
cancel,
|
||||
stopToComposer,
|
||||
stopAndSend,
|
||||
}),
|
||||
[active, isLoading, settling, elapsed, start, cancel, stopToComposer, stopAndSend],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { AttachExistingContext } from '~/hooks/Files/useAttachExisting';
|
||||
import useAttachExisting from '~/hooks/Files/useAttachExisting';
|
||||
import { useGetFiles } from '~/data-provider';
|
||||
|
||||
|
|
@ -10,12 +11,15 @@ import { useGetFiles } from '~/data-provider';
|
|||
* Fetched only while the palette is open: this is the whole file list, and no
|
||||
* other part of the composer needs it.
|
||||
*/
|
||||
export default function useRecentFiles(enabled: boolean): {
|
||||
export default function useRecentFiles(
|
||||
enabled: boolean,
|
||||
context: AttachExistingContext,
|
||||
): {
|
||||
files: TFile[];
|
||||
attach: (file: TFile) => void;
|
||||
} {
|
||||
const { data } = useGetFiles<TFile[]>({ enabled });
|
||||
const attach = useAttachExisting();
|
||||
const attach = useAttachExisting(context);
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (!data?.length) {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@ export default function useThinkingSetting(
|
|||
|
||||
const byKey = new Map<string, SettingDefinition>();
|
||||
for (const param of modelAwareParams) {
|
||||
const resolved = (overriddenParamsMap[param.key] as SettingDefinition) ?? param;
|
||||
/* Merged, not replaced: an override that names only a default would
|
||||
otherwise drop the built-in `options`, and a reasoning setting without
|
||||
options is not rendered at all — the control would simply vanish. */
|
||||
const override = overriddenParamsMap[param.key];
|
||||
const resolved: SettingDefinition = override != null ? { ...param, ...override } : param;
|
||||
byKey.set(resolved.key, resolved);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@ jest.mock('librechat-data-provider', () => {
|
|||
};
|
||||
});
|
||||
|
||||
import useResumableSSE, { selectLocalSteersForQueue } from '~/hooks/SSE/useResumableSSE';
|
||||
import useResumableSSE, {
|
||||
selectLocalSteersForQueue,
|
||||
ABORT_SWEEP_STATUSES,
|
||||
} from '~/hooks/SSE/useResumableSSE';
|
||||
|
||||
const CONV_ID = 'conv-abc-123';
|
||||
|
||||
|
|
@ -2129,4 +2132,27 @@ describe('selectLocalSteersForQueue', () => {
|
|||
expect(withResult.files).toEqual(withFiles.files);
|
||||
expect(withoutResult.files).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('the abort sweep', () => {
|
||||
/* The abort path is the one terminal where the run may still be live on the
|
||||
server, so a chip it has already ACK'd must be left for it to inject. */
|
||||
it('sweeps only what never reached the server', () => {
|
||||
expect([...ABORT_SWEEP_STATUSES]).toEqual(['failed']);
|
||||
});
|
||||
|
||||
it("leaves an ACK'd chip alone where the default sweep would take it", () => {
|
||||
const chips = [
|
||||
chip({ steerId: 'acked', status: 'pending' }),
|
||||
chip({ steerId: 'never-sent', status: 'failed' }),
|
||||
];
|
||||
expect(selectLocalSteersForQueue(chips, ABORT_SWEEP_STATUSES).map((s) => s.steerId)).toEqual([
|
||||
'never-sent',
|
||||
]);
|
||||
/* Where the run has genuinely ended, both are swept. */
|
||||
expect(selectLocalSteersForQueue(chips).map((s) => s.steerId)).toEqual([
|
||||
'acked',
|
||||
'never-sent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -455,6 +455,11 @@ const mergeResumeMessages = (
|
|||
* conversation, not by run. */
|
||||
const RUN_ENDED_STATUSES: readonly PendingSteer['status'][] = ['pending', 'failed'];
|
||||
|
||||
/** Sweep for an intentional abort, where the run may still be live
|
||||
* server-side: a server-ACK'd `pending` chip is injected regardless, so
|
||||
* sweeping it here would send the same words a second time as a queued turn. */
|
||||
export const ABORT_SWEEP_STATUSES: readonly PendingSteer['status'][] = ['failed'];
|
||||
|
||||
/**
|
||||
* Local chips with no injection-boundary event left to resolve them.
|
||||
* `statuses` defaults to `RUN_ENDED_STATUSES` for terminals where the run is
|
||||
|
|
@ -581,7 +586,11 @@ export default function useResumableSSE(
|
|||
/** Steer event whose target response message hasn't rendered yet — same
|
||||
* bounded next-frame retry as pending actions, on its own handle so the
|
||||
* two retries can't cancel each other. */
|
||||
const steerRetryRef = useRef<number | null>(null);
|
||||
/* A set rather than one slot: two applied steers whose messages have not
|
||||
rendered yet start two retry chains, and a single slot let the second
|
||||
overwrite the first, so cleanup cancelled one and left the other running
|
||||
against a torn-down tree. */
|
||||
const steerRetryRef = useRef<Set<number>>(new Set());
|
||||
|
||||
/** Removes the pending chip once its steer is injected (the inline content
|
||||
* part becomes the durable record), and records the id so a 202 ACK that
|
||||
|
|
@ -784,9 +793,11 @@ export default function useResumableSSE(
|
|||
const applySteerToMessages = (event: TSteerAppliedEvent, attempt = 0) => {
|
||||
const retryNextFrame = () => {
|
||||
if (attempt < PENDING_ACTION_MAX_RETRY_FRAMES) {
|
||||
steerRetryRef.current = requestAnimationFrame(() =>
|
||||
applySteerToMessages(event, attempt + 1),
|
||||
);
|
||||
const handle = requestAnimationFrame(() => {
|
||||
steerRetryRef.current.delete(handle);
|
||||
applySteerToMessages(event, attempt + 1);
|
||||
});
|
||||
steerRetryRef.current.add(handle);
|
||||
}
|
||||
};
|
||||
/** Same boundary as pending actions: land queued deltas before the
|
||||
|
|
@ -1532,7 +1543,7 @@ export default function useResumableSSE(
|
|||
// words as a duplicate turn once `useQueueDrain` fires at run end.
|
||||
convertLocalSteersToQueued(
|
||||
currentSubmission.conversation?.conversationId ?? currentStreamId,
|
||||
{ statuses: ['failed'] },
|
||||
{ statuses: ABORT_SWEEP_STATUSES },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1787,7 +1798,15 @@ export default function useResumableSSE(
|
|||
}
|
||||
};
|
||||
|
||||
initStream();
|
||||
/* Fire-and-forget, but not silent: this sets the submitting flags before
|
||||
it does any work, so a throw would leave the composer generating with no
|
||||
stream, no final event and no way back but a reload. */
|
||||
initStream().catch((error: unknown) => {
|
||||
logger.error('[useResumableSSE] Failed to start the stream', error);
|
||||
setIsSubmitting(false);
|
||||
setShowStopButton(false);
|
||||
setSubmission(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
logger.log('ResumableSSE', 'Cleanup - closing SSE, resetting UI state');
|
||||
|
|
@ -1803,10 +1822,10 @@ export default function useResumableSSE(
|
|||
cancelAnimationFrame(pendingActionRetryRef.current);
|
||||
pendingActionRetryRef.current = null;
|
||||
}
|
||||
if (steerRetryRef.current != null) {
|
||||
cancelAnimationFrame(steerRetryRef.current);
|
||||
steerRetryRef.current = null;
|
||||
for (const handle of steerRetryRef.current) {
|
||||
cancelAnimationFrame(handle);
|
||||
}
|
||||
steerRetryRef.current.clear();
|
||||
// Reset reconnect counter before closing (so abort handler doesn't think we're reconnecting)
|
||||
reconnectAttemptRef.current = 0;
|
||||
if (sseRef.current) {
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"com_nav_balance_weeks": "weeks",
|
||||
"com_nav_browser": "Browser",
|
||||
"com_nav_center_chat_input": "Center Chat Input on Welcome Screen",
|
||||
"com_nav_composer_tips": "Show composer tips",
|
||||
"com_nav_change_picture": "Change picture",
|
||||
"com_nav_chat_direction": "Chat direction",
|
||||
"com_nav_chat_direction_selected": "Chat direction: {{direction}}",
|
||||
|
|
@ -479,6 +478,7 @@
|
|||
"com_nav_clear_conversation": "Clear conversations",
|
||||
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
|
||||
"com_nav_close_sidebar": "Close sidebar",
|
||||
"com_nav_composer_tips": "Show composer tips",
|
||||
"com_nav_confirm_clear": "Confirm Clear",
|
||||
"com_nav_control_panel": "Control Panel",
|
||||
"com_nav_conversation_mode": "Conversation Mode",
|
||||
|
|
@ -1602,10 +1602,10 @@
|
|||
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
|
||||
"com_ui_question_unanswered": "No answer was given",
|
||||
"com_ui_queue": "Queue",
|
||||
"com_ui_queue_send": "Queue message for after the response",
|
||||
"com_ui_queue_moved": "Moved to {{0}} of {{1}}",
|
||||
"com_ui_queue_reorder": "Reorder message, {{0}} of {{1}}",
|
||||
"com_ui_queue_reorder_hint": "Use the up and down arrow keys to move this message in the queue.",
|
||||
"com_ui_queue_send": "Queue message for after the response",
|
||||
"com_ui_queued_attachment_count": "{{0}} attachments queued with this message",
|
||||
"com_ui_queued_messages": "Queued messages",
|
||||
"com_ui_quote_selections": "{{0}} selections",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue