diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 30183332cf..17b78dff12 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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() ?? '') !== '' ? ( - methods.getValues('text')} - onConsumed={() => methods.reset()} - disabled={filesLoading} - /> - ) : ( - - ); + const duringRunSlot = useMemo( + () => + steering.duringRunActive && (textValue?.trim() ?? '') !== '' ? ( + methods.getValues('text')} + onConsumed={() => methods.reset()} + disabled={filesLoading} + /> + ) : ( + + ), + [ + 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 && ( + + ), + [ + 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 && ( - - ) - } + 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 3dc91b969c..9e4380a917 100644 --- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx @@ -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; + 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(), diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts index 662db6ece8..db33b828df 100644 --- a/client/src/hooks/Chat/useSteering.ts +++ b/client/src/hooks/Chat/useSteering.ts @@ -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, 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 b37daa3d3f..7cd73f5796 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -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', + ]); + }); + }); }); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index c7e8e99a40..6b7a0a3338 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -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(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>(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) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 46b9a71853..5e4b0d2e54 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", @@ -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",