fix: composer review findings across dictation, uploads and steers

Dictation only ever submitted through the speech engines' auto-send
callback, which they never fire with the default setting: stop-and-send
left the transcript sitting in the composer, and a plain stop stopped
honouring Auto Send Text at all. The send is now armed on the stop and
spent once the take settles, with a per-take guard so the setting and
the button cannot both spend it. Cancel also keeps the draft on its ref
until a take is really spent, so an external transcription that was
already in flight can no longer wipe the draft it just restored.

Assistants get their unfiltered picker back — the provider check does
not recognise them, so the palette had scoped them to images and dropped
PDF support. Memory honours the user's personalization opt-out. Quote
chips are gated on the same flag as the quote button, so an endpoint
that cannot transmit an excerpt no longer shows one as staged.

The upload-file shortcut targets the palette disclosure that replaced
the attach menu, and Open Files opens the file manager dialog now that
the side panel link is gone.

Retried steers resolve "is the run over" from the conversation's
submitting state instead of the block's unmount: navigating away from a
live run unmounts it too, and queueing there sent the same words twice.

Composer hints follow the Enter-to-send setting rather than always
naming Enter as the send key.
This commit is contained in:
Marco Beretta 2026-07-28 15:57:10 +02:00
parent 2aefa1f070
commit d62ab93c7d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
18 changed files with 458 additions and 71 deletions

View file

@ -418,7 +418,7 @@ const ChatForm = memo(function ChatForm({
const isMoreThanThreeRows = visualRowCount > 3;
const composerItems = useComposerItems(conversationId);
const composerItems = useComposerItems(conversationId, quotesEnabled);
const attachTarget = useAttachTarget(conversation, disableInputs);
const dictation = useDictation({ ask: submitMessage, methods, isSubmitting });
const uploadingCount = useMemo(() => {

View file

@ -22,8 +22,9 @@ export const COMPOSER_HINT_ID = 'composer-hint';
* here would re-announce on every keystroke as the hint flips between idle and
* typing, so the description channel carries it instead.
*/
function Hints(state: ComposerHintState) {
const hint = useComposerHint(state);
function Hints(state: Omit<ComposerHintState, 'enterToSend'>) {
const enterToSend = useRecoilValue(store.enterToSend);
const hint = useComposerHint({ ...state, enterToSend });
const showTips = useRecoilValue(store.showComposerTips);
const visible = showTips || hint.kind === 'state';

View file

@ -896,6 +896,9 @@ function Palette({
disclosure's own click handler and the popover never opened. */}
<Ariakit.PopoverDisclosure
ref={disclosureRef}
/* The upload-file shortcut resolves its target by id, and this is
the control that replaced the attach menu it used to click. */
id="attach-file-menu-button"
disabled={disabled}
aria-label={dictating ? localize('com_ui_cancel') : localize('com_ui_composer_palette')}
/* Kept as the disclosure while dictating rather than swapped for a

View file

@ -1,6 +1,6 @@
import { useState, memo, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import * as Menu from '@ariakit/react/menu';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import {
Archive,
@ -100,7 +100,7 @@ function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
});
const [showSettings, setShowSettings] = useState(false);
const [showFiles, setShowFiles] = useState(false);
const [showFiles, setShowFiles] = useRecoilState(store.showFilesDialog);
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const [showArchived, setShowArchived] = useState(false);
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);

View file

@ -1,6 +1,6 @@
import React from 'react';
import { act, render, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil';
import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil';
import useSteerRecovery from '../useSteerRecovery';
import store from '~/store';
@ -16,9 +16,25 @@ const flush = () => act(async () => undefined);
const CONVO_ID = 'convo-steer-recovery';
/** The hook resolves "is the run over" from the conversation's own submitting
* state, not from its own unmount, so every case has to place the
* conversation in the store the way `ChatView` does. */
const seedRun = (snapshot: MutableSnapshot, submitting: boolean) => {
snapshot.set(store.conversationKeysAtom, [0]);
snapshot.set(store.conversationByIndex(0), { conversationId: CONVO_ID } as never);
snapshot.set(store.isSubmittingFamily(0), submitting);
};
function setup(initialize?: (snapshot: MutableSnapshot) => void) {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={initialize}>{children}</RecoilRoot>
<RecoilRoot
initializeState={(snapshot) => {
seedRun(snapshot, true);
initialize?.(snapshot);
}}
>
{children}
</RecoilRoot>
);
return renderHook(
() => ({
@ -125,17 +141,15 @@ describe('useSteerRecovery', () => {
expect(result.current.queue).toEqual([]);
});
/* The block this hook lives in unmounts the moment the run ends, which is
exactly when a retry's ack tends to land. It has to survive that: the
words go to the queue rather than leaving the chip saying `sending`. */
/* The block this hook lives in unmounts the moment the run ends, which is
exactly when a retry's ack tends to land. It has to survive that: the
words go to the queue rather than leaving the chip saying `sending`. */
it('queues a retry whose ack lands after the run ended', async () => {
/* A retry's ack tends to land right as the run ends, which is also when
the block this hook lives in unmounts. Whether the words go to the queue
turns on the run, not on the unmount. */
const lateAck = async (endRun: boolean) => {
let settle: (value: unknown) => void = () => undefined;
mockMutateAsync.mockReturnValue(new Promise((resolve) => (settle = resolve)));
let recovery: ReturnType<typeof useSteerRecovery> | undefined;
let setSubmitting: ((value: boolean) => void) | undefined;
let chips: unknown[] = [];
let queue: unknown[] = [];
const Recovery = () => {
@ -147,12 +161,14 @@ describe('useSteerRecovery', () => {
const Observer = () => {
chips = useRecoilValue(store.pendingSteersByConvoId(CONVO_ID));
queue = useRecoilValue(store.queuedMessagesByConvoId(CONVO_ID));
setSubmitting = useSetRecoilState(store.isSubmittingFamily(0));
return null;
};
const Tree = ({ live }: { live: boolean }) => (
<RecoilRoot
initializeState={({ set }) => {
set(store.pendingSteersByConvoId(CONVO_ID), [
initializeState={(snapshot) => {
seedRun(snapshot, true);
snapshot.set(store.pendingSteersByConvoId(CONVO_ID), [
{ steerId: 'local-late', text: 'landed too late', status: 'failed', createdAt: 3 },
]);
}}
@ -168,15 +184,32 @@ describe('useSteerRecovery', () => {
});
expect(chips).toEqual([expect.objectContaining({ status: 'sending' })]);
if (endRun) {
act(() => setSubmitting?.(false));
}
rerender(<Tree live={false} />);
await act(async () => {
settle({ steerId: 'srv-late', status: 'queued', position: 1, conversationId: CONVO_ID });
});
return { chips, queue };
};
it('queues a retry whose ack lands after the run ended', async () => {
const { chips, queue } = await lateAck(true);
expect(chips).toEqual([]);
expect(queue).toEqual([expect.objectContaining({ id: 'srv-late', text: 'landed too late' })]);
});
/* Navigating away unmounts the same block while the resumable run carries
on, and the server will still inject the accepted steer. Queueing it as
a follow-up here is what sent the same words twice. */
it('leaves the ack pending when the block unmounts on a still-live run', async () => {
const { chips, queue } = await lateAck(false);
expect(chips).toEqual([expect.objectContaining({ steerId: 'srv-late', status: 'pending' })]);
expect(queue).toEqual([]);
});
/* The picks the message was written with have to survive the retry, or the
words are re-sent without the quotes and skills they referred to. */
it('carries the quotes and skills the steer was written with', async () => {

View file

@ -1,5 +1,6 @@
import { useRef, useEffect, useCallback } from 'react';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import type { Snapshot } from 'recoil';
import type { PendingSteer } from '~/store/families';
import { getSteerErrorCode, resolveAcknowledgedSteer } from '~/hooks/Chat/useSteering';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
@ -16,16 +17,24 @@ export default function useSteerRecovery(conversationId: string) {
const { mutateAsync: steerMessage } = useSteerMessageMutation();
const convertSteersToQueued = useSteerConvert();
/** `PendingSteers` only renders while `isLast && isSubmitting` is true, so
* its unmount IS the run ending. A retry's ack can resolve afterward;
* mirrors `composerMountedRef` in `ChatForm`, which guards the equivalent
* race for a reclaimed steer whose restore resolves post-unmount. */
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
/** Whether the run this steer belongs to has finished, read live at ack time.
* Unmounting is not the same signal: `PendingSteers` also unmounts when the
* user navigates away from a run that is still going, and treating that as
* the end queued the accepted steer as a follow-up on top of the injection
* the server was already making. */
const isRunOver = useCallback(
(snapshot: Snapshot) => {
const keys = snapshot.getLoadable(store.conversationKeysAtom).getValue();
for (const key of keys) {
const convo = snapshot.getLoadable(store.conversationByIndex(key)).getValue();
if (convo?.conversationId !== conversationId) {
continue;
}
return snapshot.getLoadable(store.isSubmittingFamily(key)).getValue() !== true;
}
return true;
},
[],
[conversationId],
);
const markStatus = useRecoilCallback(
@ -39,10 +48,16 @@ export default function useSteerRecovery(conversationId: string) {
);
const acknowledgeRetry = useRecoilCallback(
(cbInterface) => (localId: string, steer: PendingSteer, runOver: boolean) => {
resolveAcknowledgedSteer(cbInterface, conversationId, localId, steer, runOver);
(cbInterface) => (localId: string, steer: PendingSteer) => {
resolveAcknowledgedSteer(
cbInterface,
conversationId,
localId,
steer,
isRunOver(cbInterface.snapshot),
);
},
[conversationId],
[conversationId, isRunOver],
);
/** Routes a steer straight into the queue: reused for a retry that degrades
@ -84,11 +99,7 @@ export default function useSteerRecovery(conversationId: string) {
rest of the conversation with the words neither sent nor queued. */
steerMessage({ conversationId, text: steer.text, files: steer.files })
.then((response) => {
acknowledgeRetry(
steerId,
{ ...steer, steerId: response.steerId, status: 'pending' },
!mountedRef.current,
);
acknowledgeRetry(steerId, { ...steer, steerId: response.steerId, status: 'pending' });
})
.catch((error: unknown) => {
const code = getSteerErrorCode(error);

View file

@ -186,6 +186,22 @@ describe('useAttachItems', () => {
).toContain('local:image');
});
/* Assistants carry their own file configuration and the old menu handed
them an unfiltered picker; scoping them to the image capability, which
the provider check would, silently dropped PDF support. */
it.each([EModelEndpoint.assistants, EModelEndpoint.azureAssistants])(
'offers one unfiltered upload for %s and no tool destinations',
(endpoint) => {
const ids = renderEntries({
endpoint,
contextEnabled: true,
fileSearchEnabled: true,
codeEnabled: true,
});
expect(ids).toEqual(['local:assistants']);
},
);
it('reads a provider whatever its casing, which OpenRouter arrives in', () => {
expect(renderEntries({ provider: 'OpenRouter' })).toContain('local:provider');
});

View file

@ -12,6 +12,7 @@ const baseState: ComposerHintState = {
duringRunAction: 'queue' as const,
answerModeActive: false,
uploadingCount: 0,
enterToSend: true,
};
const hint = (overrides: Partial<ComposerHintState>, isMac = true) =>
@ -68,6 +69,29 @@ describe('composeHint', () => {
});
});
describe('with Enter bound to a newline', () => {
it('names the chord as the send key while typing', () => {
const result = hint({ hasText: true, enterToSend: false });
expect(result).toContain('⌘⏎');
expect(result).toContain('com_ui_composer_hint_send');
expect(result).toContain('com_ui_composer_hint_newline');
expect(result).not.toContain('com_ui_composer_hint_typing');
});
it('drops the alternate during-run action, which the chord no longer reaches', () => {
const result = hint({
duringRunActive: true,
hasText: true,
isSubmitting: true,
duringRunAction: 'steer',
enterToSend: false,
});
expect(result).toContain('⌘⏎ com_ui_composer_hint_steer_verb');
expect(result).toContain('com_ui_composer_hint_interrupt');
expect(result).not.toContain('com_ui_composer_hint_queue');
});
});
describe('kind', () => {
it('marks the ambient copy as a tip, so a conversation can drop it', () => {
expect(kindOf({})).toBe('tip');

View file

@ -0,0 +1,180 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { act, renderHook } from '@testing-library/react';
import type { TAskFunction } from '~/common';
import useDictation from '../useDictation';
import store from '~/store';
/**
* The engines only report a completed transcription when Auto Send Text is
* configured, so the composer's own stop-and-send cannot be built on that
* callback alone. These cases pin the three ways a take can be spent sent on
* request, sent by the setting, or thrown away against both engines.
*/
let mockSpeechEndpoint: 'browser' | 'external';
let mockSetTextCallback: (text: string) => void;
let mockOnTranscriptionComplete: (text: string) => void;
const mockStart = jest.fn();
const mockStop = jest.fn();
const mockAbort = jest.fn();
let mockIsListening: boolean;
let mockIsLoading: boolean;
jest.mock('../useSpeechToText', () => ({
__esModule: true,
default: (setText: (text: string) => void, complete: (text: string) => void) => {
mockSetTextCallback = setText;
mockOnTranscriptionComplete = complete;
return {
isListening: mockIsListening,
isLoading: mockIsLoading,
startRecording: mockStart,
stopRecording: mockStop,
abortRecording: mockAbort,
};
},
}));
jest.mock('../useGetAudioSettings', () => ({
__esModule: true,
default: () => ({ speechToTextEndpoint: mockSpeechEndpoint }),
}));
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock('../../useLocalize', () => ({
__esModule: true,
default: () => (key: string) => key,
}));
const ask = jest.fn(() => true) as unknown as jest.Mock & TAskFunction;
function setup({ autoSendText = -1, draft = '' }: { autoSendText?: number; draft?: string } = {}) {
let text = draft;
const methods = {
setValue: jest.fn((_name: string, value: string) => {
text = value;
}),
reset: jest.fn((values: { text: string }) => {
text = values.text;
}),
getValues: jest.fn(() => text),
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={({ set }) => set(store.autoSendText, autoSendText)}>
{children}
</RecoilRoot>
);
const view = renderHook(
() =>
useDictation({
ask: ask as unknown as TAskFunction,
methods: methods as never,
isSubmitting: false,
}),
{ wrapper },
);
return { ...view, methods, currentText: () => text };
}
/** Ends the take the way an engine does: listening clears, then the settle
* backstop expires. */
const settle = async (rerender: () => void) => {
mockIsListening = false;
act(() => {
rerender();
});
await act(async () => {
jest.advanceTimersByTime(600);
});
};
describe('useDictation', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockSpeechEndpoint = 'browser';
mockIsListening = false;
mockIsLoading = false;
});
afterEach(() => {
jest.useRealTimers();
});
it('sends on stop-and-send even with Auto Send Text off, which reports no transcription', async () => {
const { result, rerender, currentText } = setup();
act(() => result.current.start());
mockIsListening = true;
act(() => rerender());
act(() => mockSetTextCallback('take me somewhere'));
act(() => result.current.stopAndSend());
await settle(rerender);
expect(ask).toHaveBeenCalledWith({ text: 'take me somewhere' });
expect(currentText()).toBe('');
});
it('leaves a plain stop in the composer', async () => {
const { result, rerender, currentText } = setup();
act(() => result.current.start());
mockIsListening = true;
act(() => rerender());
act(() => mockSetTextCallback('just a draft'));
act(() => result.current.stopToComposer());
await settle(rerender);
expect(ask).not.toHaveBeenCalled();
expect(currentText()).toBe('just a draft');
});
it('still honours Auto Send Text on a plain stop', async () => {
const { result, rerender } = setup({ autoSendText: 0 });
act(() => result.current.start());
mockIsListening = true;
act(() => rerender());
act(() => result.current.stopToComposer());
await settle(rerender);
act(() => mockOnTranscriptionComplete('send this for me'));
expect(ask).toHaveBeenCalledWith({ text: 'send this for me' });
});
it('spends a take once when the setting and the send button both reach it', async () => {
const { result, rerender } = setup({ autoSendText: 0 });
act(() => result.current.start());
mockIsListening = true;
act(() => rerender());
act(() => mockSetTextCallback('only once'));
act(() => result.current.stopAndSend());
await settle(rerender);
act(() => mockOnTranscriptionComplete('only once'));
expect(ask).toHaveBeenCalledTimes(1);
});
it('keeps the draft when a cancelled external transcription lands anyway', async () => {
mockSpeechEndpoint = 'external';
const { result, rerender, currentText } = setup({ draft: 'my unsent draft' });
act(() => result.current.start());
mockIsListening = true;
act(() => rerender());
act(() => result.current.cancel());
expect(currentText()).toBe('my unsent draft');
/* The mutation was already in flight, so aborting the recorder cannot stop
its success callback from arriving. */
act(() => mockOnTranscriptionComplete('words nobody asked for'));
expect(ask).not.toHaveBeenCalled();
expect(currentText()).toBe('my unsent draft');
});
});

View file

@ -18,11 +18,13 @@ let mockContext: Record<string, unknown> | null;
let mockSkills: Array<Record<string, unknown>>;
let mockAgentsMap: Record<string, Record<string, unknown>>;
let mockSkillsActive: boolean;
let mockUser: { personalization?: { memories?: boolean } } | undefined;
jest.mock('~/hooks', () => ({
useHasAccess: ({ permissionType }: { permissionType: string }) =>
mockPermissions[permissionType] ?? false,
useHasMemoryAccess: () => mockMemoryAccess,
useAuthContext: () => ({ user: mockUser }),
useAgentCapabilities: () => mockCapabilities,
/* The real popover filter is used here, and it drops anything the user
cannot invoke or that is not active for them. */
@ -125,6 +127,7 @@ describe('usePaletteEntries', () => {
beforeEach(() => {
mockPermissions = { ...allPermissions };
mockMemoryAccess = true;
mockUser = { personalization: { memories: true } };
mockCapabilities = { ...allCapabilities };
mockContext = fullContext();
mockSkills = [];
@ -178,6 +181,11 @@ describe('usePaletteEntries', () => {
mockMemoryAccess = false;
expect(keysOf(entries().result)).not.toContain('builtin:memory');
});
it('withholds memory from a user who opted out in personalization', () => {
mockUser = { personalization: { memories: false } };
expect(keysOf(entries().result)).not.toContain('builtin:memory');
});
});
describe('on state', () => {

View file

@ -14,6 +14,7 @@ import {
EModelEndpoint,
getConfiguredMimeAccept,
bedrockDocumentMimeTypes,
isAssistantsEndpoint,
defaultAgentCapabilities,
bedrockDocumentExtensions,
isDocumentSupportedProvider,
@ -201,6 +202,25 @@ export default function useAttachItems({
const build = (onAction: (fileType?: FileUploadType) => void, prefix: string) => {
const items: AttachEntry[] = [];
/* Assistants own their own file handling: whatever the assistant is
configured to accept goes up unfiltered, and none of the tool
destinations below apply. Scoping this to the image capability, as the
provider check would, silently dropped PDF support. */
if (isAssistantsEndpoint(endpoint)) {
items.push({
id: `${prefix}:assistants`,
label: localize('com_sidepanel_attach_files'),
primary: true,
icon: <FileImageIcon className="icon-md" aria-hidden="true" />,
onSelect: () => {
setToolResource(undefined);
onAction();
},
});
return items;
}
let currentProvider = provider || endpoint;
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive

View file

@ -13,6 +13,9 @@ export interface ComposerHintState {
/** The composer is the answer box for a paused `ask_user_question`. */
answerModeActive: boolean;
uploadingCount: number;
/** Plain Enter submits. When off, Enter inserts a newline and the modifier
* chord is what submits, which inverts every shortcut named below. */
enterToSend: boolean;
}
/**
@ -62,16 +65,20 @@ export function composeHint(
if (state.duringRunActive && state.hasText) {
const mod = isMac ? '⌘⏎' : 'Ctrl+⏎';
const alt = isMac ? '⌥⏎' : 'Alt+⏎';
const parts =
state.duringRunAction === 'steer'
? [
localize('com_ui_composer_hint_steer'),
`${mod} ${localize('com_ui_composer_hint_queue')}`,
]
: [
localize('com_ui_composer_hint_queue_default'),
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
];
const isSteer = state.duringRunAction === 'steer';
/* With plain Enter bound to a newline, the chord IS the default action and
there is no second chord left to reach the alternate one, so the hint
names only what the composer will actually do. */
const defaultParts = isSteer
? [localize('com_ui_composer_hint_steer'), `${mod} ${localize('com_ui_composer_hint_queue')}`]
: [
localize('com_ui_composer_hint_queue_default'),
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
];
const chordVerb = isSteer
? 'com_ui_composer_hint_steer_verb'
: 'com_ui_composer_hint_queue_verb';
const parts = state.enterToSend ? defaultParts : [`${mod} ${localize(chordVerb)}`];
return {
text: [...parts, `${alt} ${localize('com_ui_composer_hint_interrupt')}`].join(SEPARATOR),
kind: 'state',
@ -83,6 +90,16 @@ export function composeHint(
}
if (state.hasText) {
if (!state.enterToSend) {
const mod = isMac ? '⌘⏎' : 'Ctrl+⏎';
return {
text: [
`${mod} ${localize('com_ui_composer_hint_send')}`,
`${localize('com_ui_composer_hint_newline')}`,
].join(SEPARATOR),
kind: 'tip',
};
}
return { text: localize('com_ui_composer_hint_typing'), kind: 'tip' };
}

View file

@ -23,8 +23,15 @@ export interface ComposerItem {
/**
* Merges the staged-text sources into a single ordered list in one pass each,
* so the tray renders one homogeneous list instead of a row per source.
*
* `includeQuotes` is false on the endpoints whose client path cannot transmit
* an excerpt: showing a quote as staged there promises something the send
* would silently drop.
*/
export default function useComposerItems(conversationId: string): ComposerItem[] {
export default function useComposerItems(
conversationId: string,
includeQuotes = true,
): ComposerItem[] {
const quotes = useRecoilValue(store.pendingQuotesByConvoId(conversationId));
const skills = useRecoilValue(store.pendingManualSkillsByConvoId(conversationId));
const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId));
@ -46,7 +53,7 @@ export default function useComposerItems(conversationId: string): ComposerItem[]
identical excerpts: an index in every id rewrote the ids of everything
after a removal, remounting those chips and dropping focus. */
const seen = new Map<string, number>();
for (let i = 0; i < quotes.length; i++) {
for (let i = 0; includeQuotes && i < quotes.length; i++) {
const text = quotes[i];
const repeat = seen.get(text) ?? 0;
seen.set(text, repeat + 1);
@ -70,5 +77,5 @@ export default function useComposerItems(conversationId: string): ComposerItem[]
}
return items;
}, [quotes, skills, removeQuoteAt, removeSkill]);
}, [quotes, skills, includeQuotes, removeQuoteAt, removeSkill]);
}

View file

@ -1,4 +1,5 @@
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import type { TAskFunction } from '~/common';
import useGetAudioSettings from './useGetAudioSettings';
@ -6,6 +7,7 @@ import { useChatFormContext } from '~/Providers';
import useSpeechToText from './useSpeechToText';
import { globalAudioId } from '~/common';
import useLocalize from '../useLocalize';
import store from '~/store';
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
@ -51,6 +53,10 @@ export default function useDictation({
const localize = useLocalize();
const { showToast } = useToastContext();
const { speechToTextEndpoint } = useGetAudioSettings();
const autoSendText = useRecoilValue(store.autoSendText);
/** The Auto Send Text setting, which submits a plain recording once its
* transcript settles. A stop that was never asked to send still honours it. */
const autoSendEnabled = autoSendText > -1;
const existingTextRef = useRef<string>('');
const isSubmittingRef = useRef(isSubmitting);
@ -58,15 +64,49 @@ export default function useDictation({
/** Read when the transcription lands, which is after the stop that set it.
* The speech hooks have no notion of cancelling or of deferring a send. */
const modeRef = useRef<StopMode>('compose');
/** One submission per take. Both the settle fallback below and a late
* auto-send callback can reach the same transcript, and whichever gets
* there first is the one that spends it. */
const spentRef = useRef(false);
const submit = useCallback(
(text: string) => {
if (spentRef.current) {
return;
}
if (isSubmittingRef.current) {
showToast({ message: localize('com_ui_speech_while_submitting'), status: 'error' });
return;
}
if (!text) {
return;
}
spentRef.current = true;
const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | null;
if (globalAudio) {
globalAudio.muted = false;
}
const submitted = ask({ text });
if (submitted === false) {
spentRef.current = false;
return;
}
reset({ text: '' });
existingTextRef.current = '';
},
[ask, reset, showToast, localize],
);
const onTranscriptionComplete = useCallback(
(text: string) => {
const mode = modeRef.current;
modeRef.current = 'compose';
if (mode === 'cancel') {
/* The draft stays on the ref until a take is actually spent: an
external transcription already in flight cannot be recalled, and
clearing it at cancel time left this reset wiping the very draft the
cancel had just put back. */
reset({ text: existingTextRef.current });
existingTextRef.current = '';
return;
}
@ -76,7 +116,7 @@ export default function useDictation({
? `${existingTextRef.current} ${text}`
: text;
if (mode === 'compose') {
if (mode === 'compose' && !autoSendEnabled) {
if (finalText) {
setValue('text', finalText, { shouldValidate: true });
}
@ -84,25 +124,9 @@ export default function useDictation({
return;
}
if (isSubmittingRef.current) {
showToast({ message: localize('com_ui_speech_while_submitting'), status: 'error' });
return;
}
if (!finalText) {
return;
}
const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | null;
if (globalAudio) {
globalAudio.muted = false;
}
const submitted = ask({ text: finalText });
if (submitted === false) {
return;
}
reset({ text: '' });
existingTextRef.current = '';
submit(finalText);
},
[ask, reset, setValue, showToast, localize, speechToTextEndpoint],
[reset, setValue, submit, autoSendEnabled, speechToTextEndpoint],
);
const setText = useCallback(
@ -155,8 +179,24 @@ export default function useDictation({
return () => clearInterval(timer);
}, [active]);
/* The engines only report a completed transcription when Auto Send Text is
configured, so an explicit stop-and-send cannot wait for that callback:
with the default setting it never arrives. Instead the send is armed here
and spent once the take has fully settled, reading whatever the transcript
put in the composer. */
const [pendingSend, setPendingSend] = useState(false);
useEffect(() => {
if (!pendingSend || active || isLoading === true || settling) {
return;
}
setPendingSend(false);
submit(getValues('text') || '');
}, [pendingSend, active, isLoading, settling, submit, getValues]);
const start = useCallback(() => {
modeRef.current = 'compose';
spentRef.current = false;
setPendingSend(false);
existingTextRef.current = getValues('text') || '';
startRecording();
}, [getValues, startRecording]);
@ -165,6 +205,9 @@ export default function useDictation({
(mode: StopMode) => {
modeRef.current = mode;
setSettling(true);
if (mode === 'send') {
setPendingSend(true);
}
stopRecording();
},
[stopRecording],
@ -176,10 +219,11 @@ export default function useDictation({
that was already in flight lands anyway. */
const cancel = useCallback(() => {
modeRef.current = 'cancel';
spentRef.current = true;
setSettling(false);
setPendingSend(false);
abortRecording();
reset({ text: existingTextRef.current });
existingTextRef.current = '';
}, [abortRecording, reset]);
const stopToComposer = useCallback(() => stopWith('compose'), [stopWith]);

View file

@ -12,6 +12,7 @@ import {
import type { TSkillSummary, TToolFavoriteType } from 'librechat-data-provider';
import {
useHasAccess,
useAuthContext,
useHasMemoryAccess,
useAgentCapabilities,
useSkillActiveState,
@ -160,7 +161,11 @@ export default function usePaletteEntries({
permissionType: PermissionTypes.SKILLS,
permission: Permissions.USE,
});
const canUseMemory = useHasMemoryAccess();
const { user } = useAuthContext();
/* Personalization is the user's own opt-out and the backend refuses to
register the memory tools once it is off, so role access and the global
capability are not enough to offer the toggle. */
const canUseMemory = useHasMemoryAccess() && user?.personalization?.memories !== false;
const skillsListable = enabled && canUseSkills && skillsEnabled;
const allSkills = useAllSkills(skillsListable);

View file

@ -504,6 +504,7 @@ export function useShortcutActions(): ShortcutAction[] {
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const [sidebarExpanded, setSidebarExpanded] = useRecoilState(store.sidebarExpanded);
const setShowShortcutsDialog = useSetRecoilState(store.showShortcutsDialog);
const setShowFilesDialog = useSetRecoilState(store.showFilesDialog);
const setIsTemporary = useSetRecoilState(store.isTemporary);
const setDeleteTarget = useSetRecoilState(store.keyboardDeleteTarget);
const hasAccessToTemporaryChat = useHasAccess({
@ -815,7 +816,12 @@ export function useShortcutActions(): ShortcutAction[] {
const handleOpenPrompts = useCallback(() => handleOpenPanel('prompts'), [handleOpenPanel]);
const handleOpenMemories = useCallback(() => handleOpenPanel('memories'), [handleOpenPanel]);
const handleOpenParameters = useCallback(() => handleOpenPanel('parameters'), [handleOpenPanel]);
const handleOpenFiles = useCallback(() => handleOpenPanel('files'), [handleOpenPanel]);
/* The file manager moved out of the side panel and into a dialog, so there is
no `nav-panel-files` button left for `handleOpenPanel` to find. */
const handleOpenFiles = useCallback(() => {
setShowFilesDialog(true);
return true;
}, [setShowFilesDialog]);
const handleOpenBookmarks = useCallback(() => handleOpenPanel('bookmarks'), [handleOpenPanel]);
const handleOpenMCP = useCallback(() => handleOpenPanel('mcp-builder'), [handleOpenPanel]);

View file

@ -1002,10 +1002,14 @@
"com_ui_composer_hint_answer": "Answering. Esc to dismiss",
"com_ui_composer_hint_idle": "/ for prompts · @ for models · + to attach",
"com_ui_composer_hint_interrupt": "interrupt & send",
"com_ui_composer_hint_newline": "for newline",
"com_ui_composer_hint_queue": "queue",
"com_ui_composer_hint_queue_default": "Enter queues",
"com_ui_composer_hint_queue_verb": "queues",
"com_ui_composer_hint_send": "to send",
"com_ui_composer_hint_send_now": "send now",
"com_ui_composer_hint_steer": "Enter steers",
"com_ui_composer_hint_steer_verb": "steers",
"com_ui_composer_hint_stop": "Esc to stop",
"com_ui_composer_hint_typing": "Enter to send · Shift+Enter for newline",
"com_ui_composer_hint_uploading": "Uploading {{count}} files…",

View file

@ -62,6 +62,13 @@ const showShortcutsDialog = atom<boolean>({
default: false,
});
/** The file manager is a dialog reached from the account menu rather than a
* side panel, so the shortcut that opens it needs a way in from anywhere. */
const showFilesDialog = atom<boolean>({
key: 'showFilesDialog',
default: false,
});
export type KeyboardDeleteTarget = {
conversationId: string;
title: string;
@ -96,6 +103,7 @@ export default {
queriesEnabled,
isEditingBadges,
showShortcutsDialog,
showFilesDialog,
keyboardDeleteTarget,
customShortcuts,
chatBadges,