diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx
index 15ef0b63d5..4a0c93998e 100644
--- a/client/src/components/Chat/Input/ChatForm.tsx
+++ b/client/src/components/Chat/Input/ChatForm.tsx
@@ -434,7 +434,9 @@ 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 = (() => {
+ /* Memoized for `memo(Bar)`: an inline element is a new identity every render,
+ and this component re-renders on every keystroke. */
+ const duringRunSlot = useMemo(() => {
if (steering.duringRunActive && (textValue?.trim() ?? '') !== '') {
return (
;
}
return null;
- })();
+ }, [
+ steering,
+ textValue,
+ methods,
+ submitButtonRef,
+ filesLoading,
+ showStopButton,
+ 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 && (
+
+ ),
+ [
+ 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. */
@@ -665,22 +707,7 @@ const ChatForm = memo(function ChatForm({
showSpeech={SpeechToText}
speechDisabled={disableInputs || isNotAppendable}
dictation={dictation}
- actionSlot={
- isSubmitting && showStopButton && !answerMode.active
- ? duringRunSlot
- : endpoint && (
-
- )
- }
+ actionSlot={actionSlot}
/>
diff --git a/client/src/components/Chat/Input/Composer/Bar.tsx b/client/src/components/Chat/Input/Composer/Bar.tsx
index 408da5b370..6fe4292404 100644
--- a/client/src/components/Chat/Input/Composer/Bar.tsx
+++ b/client/src/components/Chat/Input/Composer/Bar.tsx
@@ -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(
+export function chipsFitInline(
entries: T[],
widths: Record,
capacity: number,
@@ -59,23 +59,25 @@ function chipsFitInline(
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 (
mode.active);
diff --git a/client/src/components/Chat/Input/Composer/Palette.tsx b/client/src/components/Chat/Input/Composer/Palette.tsx
index 418ea129de..e90b6b05b8 100644
--- a/client/src/components/Chat/Input/Composer/Palette.tsx
+++ b/client/src/components/Chat/Input/Composer/Palette.tsx
@@ -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(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({
{description}
)}
- {/* 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) => (
-
+
))}
{isEntry && (
-
+
)}
);
@@ -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)
diff --git a/client/src/components/Chat/Input/Composer/Queue.tsx b/client/src/components/Chat/Input/Composer/Queue.tsx
index f7bda91862..802147d670 100644
--- a/client/src/components/Chat/Input/Composer/Queue.tsx
+++ b/client/src/components/Chat/Input/Composer/Queue.tsx
@@ -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;
}
diff --git a/client/src/components/Chat/Input/Composer/Thinking.tsx b/client/src/components/Chat/Input/Composer/Thinking.tsx
index 37b317ef84..06871434cf 100644
--- a/client/src/components/Chat/Input/Composer/Thinking.tsx
+++ b/client/src/components/Chat/Input/Composer/Thinking.tsx
@@ -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:
diff --git a/client/src/components/Chat/Input/Composer/Tray.tsx b/client/src/components/Chat/Input/Composer/Tray.tsx
index 6abca91429..363bbfe512 100644
--- a/client/src/components/Chat/Input/Composer/Tray.tsx
+++ b/client/src/components/Chat/Input/Composer/Tray.tsx
@@ -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 (
diff --git a/client/src/components/Chat/Input/Composer/__tests__/Bar.spec.ts b/client/src/components/Chat/Input/Composer/__tests__/Bar.spec.ts
new file mode 100644
index 0000000000..892be12f67
--- /dev/null
+++ b/client/src/components/Chat/Input/Composer/__tests__/Bar.spec.ts
@@ -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);
+ });
+});
diff --git a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
index 791574282a..2067f33593 100644
--- a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
+++ b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
@@ -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 =>
+ ({
+ 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 =>
({
@@ -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(
+
+ set(store.queuedMessagesByConvoId(CONVO_ID), [queued({ id: 'q1' }), queued({ id: 'q2' })])
+ }
+ >
+
+
+
+
+ ,
+ );
+
+ 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');
diff --git a/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx b/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx
index 0083889ab0..3e22f86837 100644
--- a/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx
@@ -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);
diff --git a/client/src/components/SidePanel/Files/PanelTable.tsx b/client/src/components/SidePanel/Files/PanelTable.tsx
index 74bdc1a3c1..b7cd4aa5f6 100644
--- a/client/src/components/SidePanel/Files/PanelTable.tsx
+++ b/client/src/components/SidePanel/Files/PanelTable.tsx
@@ -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 {
@@ -74,7 +75,8 @@ export default function DataTable({ 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;
diff --git a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
index 6a34d3c899..532d9e1b4c 100644
--- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
@@ -92,6 +92,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;
+ setQueue: (items: QueuedMessage[]) => void;
+ },
+ item: QueuedMessage,
+) {
+ current.setQueue([item]);
+ current.steering.sendQueuedNow(item);
+}
+
describe('useSteering', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -1939,10 +1952,26 @@ describe('useSteering', () => {
expect(setFiles).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(),
@@ -1965,7 +1994,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(),
@@ -2085,6 +2114,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)),
@@ -2158,7 +2188,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(),
@@ -2194,7 +2224,7 @@ describe('useSteering', () => {
});
const { result } = setupWithContext();
act(() => {
- result.current.steering.sendQueuedNow({
+ sendFromQueue(result.current, {
id: 'q-degraded',
text: 'carried context',
createdAt: Date.now(),
diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts
index 414c4f9d86..85e1b2ea3c 100644
--- a/client/src/hooks/Chat/useSteering.ts
+++ b/client/src/hooks/Chat/useSteering.ts
@@ -1377,9 +1377,13 @@ export default function useSteering({
if (isSubmitting && (!duringRunActive || !canSteer || item.recoverySteerId != null)) {
return;
}
- /** UI callers always find the item; a stale/direct caller has no original
- * neighbours, so restoration falls back to the queue's priority split. */
- const origin = takeQueued(item.id) ?? { item, beforeIds: [], afterIds: [] };
+ /* 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 origin = takeQueued(item.id);
+ if (origin == null) {
+ return;
+ }
const taken = origin.item;
if (duringRunActive && canSteer) {
const consumed = submitSteer(
diff --git a/client/src/hooks/Files/__tests__/useAttachExisting.spec.tsx b/client/src/hooks/Files/__tests__/useAttachExisting.spec.tsx
index 3874286acf..63dbf8a715 100644
--- a/client/src/hooks/Files/__tests__/useAttachExisting.spec.tsx
+++ b/client/src/hooks/Files/__tests__/useAttachExisting.spec.tsx
@@ -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 =>
({ 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);
};
diff --git a/client/src/hooks/Files/useAttachExisting.ts b/client/src/hooks/Files/useAttachExisting.ts
index a29a46c99c..e992f4a16a 100644
--- a/client/src/hooks/Files/useAttachExisting.ts
+++ b/client/src/hooks/Files/useAttachExisting.ts
@@ -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;
+ 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),
});
diff --git a/client/src/hooks/Input/__tests__/useComposerItems.spec.tsx b/client/src/hooks/Input/__tests__/useComposerItems.spec.tsx
index 09db9a7ab1..2744ca5a96 100644
--- a/client/src/hooks/Input/__tests__/useComposerItems.spec.tsx
+++ b/client/src/hooks/Input/__tests__/useComposerItems.spec.tsx
@@ -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]);
diff --git a/client/src/hooks/Input/__tests__/useRecentFiles.spec.tsx b/client/src/hooks/Input/__tests__/useRecentFiles.spec.tsx
new file mode 100644
index 0000000000..cd598cb8a1
--- /dev/null
+++ b/client/src/hooks/Input/__tests__/useRecentFiles.spec.tsx
@@ -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 => ({ 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']);
+ });
+});
diff --git a/client/src/hooks/Input/__tests__/useSpeechToTextBrowser.spec.tsx b/client/src/hooks/Input/__tests__/useSpeechToTextBrowser.spec.tsx
new file mode 100644
index 0000000000..07b9f381f3
--- /dev/null
+++ b/client/src/hooks/Input/__tests__/useSpeechToTextBrowser.spec.tsx
@@ -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 }) => (
+ set(store.autoSendText, AUTO_SEND_SECONDS)}>
+ {children}
+
+ );
+ 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;
+ 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;
+ });
+});
diff --git a/client/src/hooks/Input/__tests__/useThinkingSetting.spec.ts b/client/src/hooks/Input/__tests__/useThinkingSetting.spec.ts
new file mode 100644
index 0000000000..08d2b21923
--- /dev/null
+++ b/client/src/hooks/Input/__tests__/useThinkingSetting.spec.ts
@@ -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;
+
+jest.mock('~/data-provider', () => ({
+ useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
+}));
+
+const setting = (conversation: Partial) =>
+ 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');
+ });
+});
diff --git a/client/src/hooks/Input/useAttachItems.tsx b/client/src/hooks/Input/useAttachItems.tsx
index 360c9db170..f29a527547 100644
--- a/client/src/hooks/Input/useAttachItems.tsx
+++ b/client/src/hooks/Input/useAttachItems.tsx
@@ -116,7 +116,12 @@ export default function useAttachItems({
}: UseAttachItemsParams): UseAttachItems {
const localize = useLocalize();
const inputRef = useRef(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();
+ const [sharePointResource, setSharePointResource] = useState();
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(() => {
const setToolResource = (value: EToolResources | undefined) => {
toolResourceRef.current = value;
+ setSharePointResource(value);
};
const build = (onAction: (fileType?: FileUploadType) => void, prefix: string) => {
diff --git a/client/src/hooks/Input/useChipPacking.ts b/client/src/hooks/Input/useChipPacking.ts
index 9564206bfa..790b65bf89 100644
--- a/client/src/hooks/Input/useChipPacking.ts
+++ b/client/src/hooks/Input/useChipPacking.ts
@@ -29,58 +29,55 @@ export default function useChipPacking(
widths: Record;
} {
const rootRef = useRef(null);
- const widthsRef = useRef>({});
- 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>({});
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 = {};
/* Queried by role rather than walked as children: the caller may split the
chips across rows. Document order still matches `ordered`. */
root.querySelectorAll('[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 = {};
+ 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 };
}
diff --git a/client/src/hooks/Input/useComposerItems.ts b/client/src/hooks/Input/useComposerItems.ts
index 33e5f60ea3..e3840f790b 100644
--- a/client/src/hooks/Input/useComposerItems.ts
+++ b/client/src/hooks/Input/useComposerItems.ts
@@ -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();
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,
diff --git a/client/src/hooks/Input/useDictation.ts b/client/src/hooks/Input/useDictation.ts
index 54fc01ce7c..9bbaa06c43 100644
--- a/client/src/hooks/Input/useDictation.ts
+++ b/client/src/hooks/Input/useDictation.ts
@@ -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],
+ );
}
diff --git a/client/src/hooks/Input/useRecentFiles.ts b/client/src/hooks/Input/useRecentFiles.ts
index 35c0f61053..fa40d3603b 100644
--- a/client/src/hooks/Input/useRecentFiles.ts
+++ b/client/src/hooks/Input/useRecentFiles.ts
@@ -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({ enabled });
- const attach = useAttachExisting();
+ const attach = useAttachExisting(context);
const files = useMemo(() => {
if (!data?.length) {
diff --git a/client/src/hooks/Input/useThinkingSetting.ts b/client/src/hooks/Input/useThinkingSetting.ts
index e210796086..5a02bbdddc 100644
--- a/client/src/hooks/Input/useThinkingSetting.ts
+++ b/client/src/hooks/Input/useThinkingSetting.ts
@@ -55,7 +55,11 @@ export default function useThinkingSetting(
const byKey = new Map();
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);
}
diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
index 53a6de973b..68fcbb82e2 100644
--- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
+++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
@@ -259,7 +259,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';
@@ -3940,4 +3943,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',
+ ]);
+ });
+ });
});
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 2e4acb0ad8..40b7befb60 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -615,6 +615,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
@@ -3128,7 +3133,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 },
);
});
@@ -3901,7 +3906,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);
+ });
/** The Set object itself is never reassigned, so this alias reads the
* LIVE frame ids at cleanup time (satisfies react-hooks/exhaustive-deps
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 37059f8b55..7db906739c 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
@@ -1616,10 +1616,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",