diff --git a/client/src/components/Chat/Input/Composer/Bar.tsx b/client/src/components/Chat/Input/Composer/Bar.tsx
index 6fe4292404..c6bc5244c0 100644
--- a/client/src/components/Chat/Input/Composer/Bar.tsx
+++ b/client/src/components/Chat/Input/Composer/Bar.tsx
@@ -428,7 +428,12 @@ function Bar({
{dictating ? (
@@ -442,6 +447,7 @@ function Bar({
primary
label={localize('com_nav_send_message')}
onClick={dictation.stopAndSend}
+ disabled={dictation.transcribing}
>
diff --git a/client/src/components/Chat/Input/Composer/Queue.tsx b/client/src/components/Chat/Input/Composer/Queue.tsx
index 802147d670..9e84e99115 100644
--- a/client/src/components/Chat/Input/Composer/Queue.tsx
+++ b/client/src/components/Chat/Input/Composer/Queue.tsx
@@ -1,8 +1,8 @@
import { memo, useRef, useMemo, useState, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useDrag, useDrop } from 'react-dnd';
-import { useMediaQuery } from '@librechat/client';
import { X, Pencil, GripVertical } from 'lucide-react';
+import { useMediaQuery, useToastContext } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
@@ -71,6 +71,7 @@ function QueueRow({
onAnnounce,
}: QueueRowProps) {
const localize = useLocalize();
+ const { showToast } = useToastContext();
const rowRef = useRef(null);
const gripRef = useRef(null);
const { reorderQueued, restoreQueuedOrder } = steering;
@@ -241,7 +242,15 @@ function QueueRow({
);
if (restored) {
steering.removeQueued(message.id);
+ return;
}
+ /* Refusing silently reads as a dead button: the row stays, nothing
+ moves, and the reason (a draft in the box, another chat on screen)
+ is somewhere the click was not. */
+ showToast({
+ message: localize('com_ui_queue_remove_blocked'),
+ status: 'warning',
+ });
}}
className={ICON_BTN}
>
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 2067f33593..fb49d41014 100644
--- a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
+++ b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx
@@ -18,6 +18,12 @@ jest.mock('~/hooks', () => ({
},
}));
+const mockShowToast = jest.fn();
+jest.mock('@librechat/client', () => ({
+ ...jest.requireActual('@librechat/client'),
+ useToastContext: () => ({ showToast: mockShowToast }),
+}));
+
const CONVO_ID = 'convo-1';
const mockSendQueuedNow = jest.fn();
const mockRemoveQueued = jest.fn();
@@ -184,6 +190,11 @@ describe('Queue', () => {
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(onRestore).toHaveBeenCalled();
expect(mockRemoveQueued).not.toHaveBeenCalled();
+ /* Keeping the words is right; saying nothing about it is not. Without this
+ the row simply does not react and the button reads as broken. */
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'com_ui_queue_remove_blocked' }),
+ );
});
it('hands the whole message to the composer to edit', () => {
diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts
index 85e1b2ea3c..0de2c640ab 100644
--- a/client/src/hooks/Chat/useSteering.ts
+++ b/client/src/hooks/Chat/useSteering.ts
@@ -381,12 +381,9 @@ export default function useSteering({
visibleConversationRef.current = conversationId;
/**
- * How each conversation's last run ended, kept because `useQueueDrain`
- * CONSUMES the one-shot signal — by the time a reclaim resolves it is already
- * gone. Every subscriber renders before the drain's effect nulls it, so the
- * outcome is captured first. Both carriers are watched: the index signal, and
- * the copy parked under the conversation when the run ended while the user
- * was looking elsewhere.
+ * The most recent run end seen for a conversation: either the live one for
+ * this pane's index, or the copy parked under the conversation when the run
+ * ended while the user was looking elsewhere.
*
* Keyed by conversation because this hook is REUSED across chats: a single
* slot would answer for whichever chat is on screen when the reclaim lands,
diff --git a/client/src/hooks/Input/__tests__/useDictation.spec.tsx b/client/src/hooks/Input/__tests__/useDictation.spec.tsx
index b191b1446f..c611e7ce27 100644
--- a/client/src/hooks/Input/__tests__/useDictation.spec.tsx
+++ b/client/src/hooks/Input/__tests__/useDictation.spec.tsx
@@ -160,6 +160,29 @@ describe('useDictation', () => {
expect(ask).toHaveBeenCalledTimes(1);
});
+ /* The bar disables stop and send once a take is being transcribed, and the
+ hook refuses them too: a second stop would rewrite how a take that was
+ already committed gets spent. */
+ it('ignores a stop that arrives after the take has ended', async () => {
+ const { result, rerender, currentText } = setup();
+ act(() => result.current.start());
+ mockIsListening = true;
+ act(() => rerender());
+
+ act(() => mockSetTextCallback('already committed'));
+ act(() => result.current.stopToComposer());
+ mockIsListening = false;
+ act(() => rerender());
+
+ mockStop.mockClear();
+ act(() => result.current.stopAndSend());
+ await settle(rerender);
+
+ expect(mockStop).not.toHaveBeenCalled();
+ expect(ask).not.toHaveBeenCalled();
+ expect(currentText()).toBe('already committed');
+ });
+
it('keeps the draft when a cancelled external transcription lands anyway', async () => {
mockSpeechEndpoint = 'external';
const { result, rerender, currentText } = setup({ draft: 'my unsent draft' });
diff --git a/client/src/hooks/Input/useComposerHint.ts b/client/src/hooks/Input/useComposerHint.ts
index a87e546a2d..37a7033741 100644
--- a/client/src/hooks/Input/useComposerHint.ts
+++ b/client/src/hooks/Input/useComposerHint.ts
@@ -1,4 +1,3 @@
-import type { TranslationKeys } from '~/hooks/useLocalize';
import type { LocalizeFunction } from '~/common';
import { isMacPlatform } from '~/utils/shortcuts';
import useLocalize from '~/hooks/useLocalize';
diff --git a/client/src/hooks/Input/useDictation.ts b/client/src/hooks/Input/useDictation.ts
index ab5e0d9083..5f39dc65af 100644
--- a/client/src/hooks/Input/useDictation.ts
+++ b/client/src/hooks/Input/useDictation.ts
@@ -201,8 +201,17 @@ export default function useDictation({
startRecording();
}, [getValues, startRecording]);
+ /* Only a running take can be stopped. The bar disables these controls once a
+ transcription is in flight, and this is the same rule stated where the mode
+ is actually written: a second stop would otherwise rewrite how a take that
+ was already committed gets spent. */
+ const activeRef = useRef(active);
+ activeRef.current = active;
const stopWith = useCallback(
(mode: StopMode) => {
+ if (!activeRef.current) {
+ return;
+ }
modeRef.current = mode;
setSettling(true);
if (mode === 'send') {
diff --git a/client/src/hooks/Plugins/useToolToggle.ts b/client/src/hooks/Plugins/useToolToggle.ts
index 72f3674908..2b90675513 100644
--- a/client/src/hooks/Plugins/useToolToggle.ts
+++ b/client/src/hooks/Plugins/useToolToggle.ts
@@ -162,17 +162,37 @@ export function useToolToggle({
[handleChange],
);
- return {
- toggleState: toolValue, // Return the actual value from ephemeralAgent
- handleChange,
- isToolEnabled,
- toolValue,
- setToggleState: (value: ToolValue) => handleChange({ value }), // Adapter for direct setting
- ephemeralAgent,
- debouncedChange,
- setEphemeralAgent,
- authData: authQuery?.data,
- isPinned,
- setIsPinned,
- };
+ const setToggleState = useCallback((value: ToolValue) => handleChange({ value }), [handleChange]);
+
+ /* Memoized because `BadgeRowContext` puts six of these in a context value that
+ the composer palette derives its whole catalog from: a fresh object here on
+ every render made that value change on every keystroke, whatever the
+ provider did about it. */
+ return useMemo(
+ () => ({
+ toggleState: toolValue, // Return the actual value from ephemeralAgent
+ handleChange,
+ isToolEnabled,
+ toolValue,
+ setToggleState, // Adapter for direct setting
+ ephemeralAgent,
+ debouncedChange,
+ setEphemeralAgent,
+ authData: authQuery?.data,
+ isPinned,
+ setIsPinned,
+ }),
+ [
+ toolValue,
+ handleChange,
+ isToolEnabled,
+ setToggleState,
+ ephemeralAgent,
+ debouncedChange,
+ setEphemeralAgent,
+ authQuery?.data,
+ isPinned,
+ setIsPinned,
+ ],
+ );
}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 2729d14c2b..fdb61cae27 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1621,6 +1621,7 @@
"com_ui_question_unanswered": "No answer was given",
"com_ui_queue": "Queue",
"com_ui_queue_moved": "Moved to {{0}} of {{1}}",
+ "com_ui_queue_remove_blocked": "Clear the message box in this chat to take this message back.",
"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",