diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx
index 8de9161ab4..393ed16724 100644
--- a/client/src/components/Chat/Input/InFlightSteers.tsx
+++ b/client/src/components/Chat/Input/InFlightSteers.tsx
@@ -7,7 +7,12 @@ import type { TFile, TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
import type { MenuEntry } from './SteerMenu';
-import { RowMenu, useDefaultToggleEntry, useInterruptToggleEntry } from './SteerMenu';
+import {
+ RowMenu,
+ EscalateNowButton,
+ useDefaultToggleEntry,
+ useInterruptToggleEntry,
+} from './SteerMenu';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
@@ -144,6 +149,53 @@ const InFlightSteer = memo(function InFlightSteer({
const { mutateAsync: armSteer } = useArmSteerMutation();
const setEscalating = useSetAtom(escalatingSteerFamily(conversationId));
+ /**
+ * Escalate this waiting steer to an interrupt: ONE atomic server op flips
+ * `preempt` on the EXISTING queued item, so its FIFO position, id, and
+ * timestamp survive and there is no reclaim window to race. Every "too
+ * late" interleaving (drained, cancelled, run ended or replaced) is the
+ * same honest `armed: false`, and the chip is only relabelled on a
+ * confirmed durable arm. The escalating flag flips synchronously, before
+ * the request: the chip-derived gate cannot see this arm until the
+ * response lands, and the other escalation controls advertise "one
+ * interrupt at a time".
+ */
+ const escalate = useCallback(() => {
+ setEscalating(true);
+ void armSteer({ conversationId, steerId: steer.steerId })
+ .then(
+ (response) => {
+ if (response.armed === true) {
+ markSteerPreempt(steer.steerId);
+ return;
+ }
+ /* `armed: false` is deliberately ambiguous — injected, cancelled,
+ * re-homed, or run over — so the message only says the escalation
+ * lost, and the chip defers to the events for what happened. */
+ showToast({
+ message: localize(
+ response.code === 'PREEMPT_UNSUPPORTED'
+ ? 'com_ui_steer_preempt_unsupported'
+ : 'com_ui_steer_arm_lost_race',
+ ),
+ status: 'info',
+ });
+ },
+ () => {
+ showToast({ message: localize('com_ui_steer_arm_failed'), status: 'error' });
+ },
+ )
+ .finally(() => setEscalating(false));
+ }, [
+ armSteer,
+ conversationId,
+ steer.steerId,
+ setEscalating,
+ markSteerPreempt,
+ showToast,
+ localize,
+ ]);
+
/**
* Takes the steer back off the server queue so its words can be re-homed.
* The chip is left alone until the answer is known: only `reclaimed` proves
@@ -198,82 +250,6 @@ const InFlightSteer = memo(function InFlightSteer({
});
},
},
- {
- key: 'queue',
- label: localize('com_ui_convert_to_queue'),
- icon: ,
- onClick: () => {
- void reclaim().then((reclaimed) => {
- if (reclaimed) {
- steering.queueReclaimedSteer(steer);
- }
- });
- },
- },
- /* Escalate a waiting steer to interrupt at the next safe token boundary.
- * Reclaim first, same race rules as Edit: only `reclaimed` proves the
- * words never entered the run, then `retrySteer` swaps this chip for a
- * new preempting one. A run that ended mid-reclaim already queued the
- * words — there is nothing left to interrupt. The escalation lock covers
- * the reclaim window (no preempt chip exists yet to disable the other
- * controls), and the fresh recheck before resubmitting catches an
- * interrupt armed elsewhere meanwhile — those words re-home to the queue
- * rather than breaking the one-interrupt invariant. Offered only while
- * this steer is not already preempting. */
- ...(preempting
- ? []
- : [
- {
- key: 'interrupt',
- label: localize('com_ui_interrupt_steer_now'),
- icon: ,
- /* UX gate only — correctness lives in the server's atomic arm,
- * which is fenced to this generation and refuses once the item
- * left the queue. `!duringRunActive` covers answer mode, where
- * `pausedOnApproval` stays false. */
- disabled: interruptPending || steering.pausedOnApproval || !steering.duringRunActive,
- /* One atomic server op: `preempt` flips on the EXISTING queued
- * item, so its FIFO position, id, and timestamp survive and there
- * is no reclaim window to race. Every "too late" interleaving
- * (drained, cancelled, run ended or replaced) is the same honest
- * `armed: false`, and the chip is only relabelled on a confirmed
- * durable arm. */
- onClick: () => {
- /* Synchronously, before the request: the chip-derived gate
- * cannot see this arm until the response lands, and the other
- * escalation controls advertise "one interrupt at a time". */
- setEscalating(true);
- void armSteer({ conversationId, steerId: steer.steerId })
- .then(
- (response) => {
- if (response.armed === true) {
- markSteerPreempt(steer.steerId);
- return;
- }
- /* `armed: false` is deliberately ambiguous — injected,
- * cancelled, re-homed, or run over — so the message only
- * says the escalation lost, and the chip defers to the
- * events for whatever actually happened. */
- showToast({
- message: localize(
- response.code === 'PREEMPT_UNSUPPORTED'
- ? 'com_ui_steer_preempt_unsupported'
- : 'com_ui_steer_arm_lost_race',
- ),
- status: 'info',
- });
- },
- () => {
- showToast({
- message: localize('com_ui_steer_arm_failed'),
- status: 'error',
- });
- },
- )
- .finally(() => setEscalating(false));
- },
- } satisfies MenuEntry,
- ]),
{
/* Non-destructive, but only when it is safe: cancel reliably first (the
* optimistic hook removes the chip and restores it if the server would
@@ -306,9 +282,20 @@ const InFlightSteer = memo(function InFlightSteer({
});
},
},
- toggleEntry,
- interruptToggle,
+ {
+ key: 'queue',
+ label: localize('com_ui_convert_to_queue'),
+ icon: ,
+ onClick: () => {
+ void reclaim().then((reclaimed) => {
+ if (reclaimed) {
+ steering.queueReclaimedSteer(steer);
+ }
+ });
+ },
+ },
];
+ const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
-
+
+ {!preempting && (
+
+ )}
+
)}
diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx
index 057423264a..0ca4dd3d10 100644
--- a/client/src/components/Chat/Input/PendingSteerChips.tsx
+++ b/client/src/components/Chat/Input/PendingSteerChips.tsx
@@ -1,8 +1,7 @@
import { memo, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
-import * as Ariakit from '@ariakit/react';
-import { X, Zap, Send, Clock, Pencil, Trash2, ZapOff, Paperclip, RotateCcw } from 'lucide-react';
+import { X, Zap, Send, Clock, Pencil, Trash2, Paperclip, RotateCcw } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { PendingSteer, QueuedMessage } from '~/store/families';
@@ -12,8 +11,8 @@ import {
RowMenu,
ICON_BTN_CLASS,
PRIMARY_BTN_CLASS,
+ EscalateNowButton,
useDefaultToggleEntry,
- useInterruptChordHint,
useInterruptToggleEntry,
} from './SteerMenu';
import { escalatingSteerFamily } from '~/store/steer';
@@ -37,40 +36,6 @@ function AttachmentCount({ count, label }: { count: number; label: string }) {
);
}
-/**
- * Escalate one message past the queue: interrupt & steer it now, at the next
- * safe token boundary instead of the next tool step. Icon-only ZapOff, the
- * interrupt glyph everywhere in this UI; the tooltip teaches the composer
- * chord that does this in one step. Disabled while another interrupt is
- * unresolved or the run is paused on approval.
- */
-function InterruptNowButton({ disabled, onClick }: { disabled: boolean; onClick: () => void }) {
- const localize = useLocalize();
- const chordHint = useInterruptChordHint();
- const label = localize('com_ui_interrupt_steer_now');
- return (
-
-
-
-
- }
- />
-
- {chordHint == null ? label : `${label} · ${chordHint}`}
-
-
- );
-}
-
function QueuedRow({
message,
steering,
@@ -114,9 +79,8 @@ function QueuedRow({
});
},
},
- toggleEntry,
- interruptToggle,
];
+ const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
@@ -148,7 +112,8 @@ function QueuedRow({
)}
{showEscalate && (
- steering.sendQueuedNow(message, { preempt: true })}
/>
@@ -172,7 +137,11 @@ function QueuedRow({
>
-
+
);
}
@@ -217,9 +186,8 @@ function FailedSteerRow({
manualSkills: steer.manualSkills,
}),
},
- toggleEntry,
- interruptToggle,
];
+ const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
-
+
);
}
diff --git a/client/src/components/Chat/Input/SteerMenu.tsx b/client/src/components/Chat/Input/SteerMenu.tsx
index fd20e4904c..205ead3d2e 100644
--- a/client/src/components/Chat/Input/SteerMenu.tsx
+++ b/client/src/components/Chat/Input/SteerMenu.tsx
@@ -1,11 +1,12 @@
import { useMemo } from 'react';
+import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
-import { useRecoilState, useRecoilValue } from 'recoil';
-import { Zap, ZapOff, Clock, MoreHorizontal } from 'lucide-react';
+import { InfoHoverCard, ESide } from '@librechat/client';
+import { Zap, ZapOff, Clock, ArrowUp, MoreHorizontal } from 'lucide-react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
-import { isMacPlatform, resolveComposerKeyDown, bindingDisplayString } from '~/utils/shortcuts';
-import useComposerBindings from '~/hooks/Input/useComposerBindings';
+import { useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
import store from '~/store';
/** Shared row/bubble affordances for the during-run surfaces: the in-flight
@@ -26,37 +27,115 @@ export type MenuEntry = {
icon: React.ReactNode;
onClick: () => void;
disabled?: boolean;
+ /** Localized description shown as the standard info hovercard. */
+ info?: string;
};
-/** Per-row "…" overflow menu (edit / mode toggle / conversions). */
-export function RowMenu({ label, entries }: { label: string; entries: MenuEntry[] }) {
+/**
+ * Per-row "…" overflow menu: message actions first, then a visually separated
+ * "Preferences" section for the sticky mode toggles, so one-off actions and
+ * persistent behavior changes never read as the same kind of choice.
+ */
+export function RowMenu({
+ label,
+ entries,
+ preferences,
+}: {
+ label: string;
+ entries: MenuEntry[];
+ preferences?: MenuEntry[];
+}) {
+ const localize = useLocalize();
const menu = Ariakit.useMenuStore({ placement: 'top-end' });
+ const renderEntry = (entry: MenuEntry) => (
+ {
+ entry.onClick();
+ menu.hide();
+ }}
+ >
+ {entry.icon}
+ {entry.label}
+ {entry.info != null && (
+ event.stopPropagation()}>
+
+
+ )}
+
+ );
return (
<>
- {entries.map((entry) => (
- {
- entry.onClick();
- menu.hide();
- }}
- >
- {entry.icon}
- {entry.label}
-
- ))}
+ {entries.map(renderEntry)}
+ {preferences != null && preferences.length > 0 && (
+ <>
+
+
+ {localize('com_ui_preferences')}
+
+ {preferences.map(renderEntry)}
+ >
+ )}
>
);
}
+/**
+ * The always-visible escalation control on a waiting message: interrupt &
+ * steer it now, at the next safe token boundary. The tooltip teaches this
+ * action's OWN shortcut (registry-aware, so a rebinding shows correctly);
+ * the shortcut handler clicks whichever of these buttons is newest, so the
+ * two can never diverge.
+ */
+export function EscalateNowButton({
+ surface,
+ disabled,
+ onClick,
+}: {
+ surface: 'bubble' | 'queued';
+ disabled: boolean;
+ onClick: () => void;
+}) {
+ const localize = useLocalize();
+ const chord = useShortcutDisplay('escalateSteer');
+ const label = localize('com_ui_interrupt_steer_now');
+ return (
+
+
+
+
+ }
+ />
+
+ {chord ? `${label} · ${chord}` : label}
+
+
+ );
+}
+
/**
* The overflow item that flips the Enter-during-run default. Shown as the
* OPPOSITE of the current default (the action you would switch to), matching
@@ -78,6 +157,7 @@ export function useDefaultToggleEntry(steering: SteeringControls): MenuEntry {
) : (
),
+ info: localize('com_nav_info_during_run_action'),
onClick: () => steering.setDefaultAction(next),
};
}, [steering, localize]);
@@ -102,43 +182,9 @@ export function useInterruptToggleEntry(): MenuEntry {
) : (
),
+ info: localize('com_ui_steer_interrupts_default_info'),
onClick: () => setInterrupts(!interrupts),
}),
[interrupts, setInterrupts, localize],
);
}
-
-/**
- * The interrupt chord for tooltips, present only while the chord still
- * preempts: a chord rebound to a global shortcut (or claimed by a rebound
- * submit) must not be advertised, and `resolveComposerKeyDown` is the one
- * source of that answer.
- */
-export function useInterruptChordHint(): string | undefined {
- const enterToSend = useRecoilValue(store.enterToSend);
- const { submitOverride, yieldedChords } = useComposerBindings();
- return useMemo(() => {
- const chord = {
- meta: isMacPlatform,
- ctrl: !isMacPlatform,
- alt: false,
- shift: true,
- key: 'Enter',
- };
- const verdict = resolveComposerKeyDown(
- { key: 'Enter', altKey: false, ctrlKey: chord.ctrl, metaKey: chord.meta, shiftKey: true },
- {
- isComposing: false,
- isSubmitting: true,
- allowSubmitWhileGenerating: true,
- hasDuringRunModifier: true,
- enterToSend,
- submitOverride,
- yieldedChords,
- },
- );
- return verdict === 'preempt'
- ? (bindingDisplayString(chord, isMacPlatform) ?? undefined)
- : undefined;
- }, [enterToSend, submitOverride, yieldedChords]);
-}
diff --git a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
index 011c79c4c9..811e9dce70 100644
--- a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
+++ b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
@@ -25,6 +25,8 @@ jest.mock('~/hooks', () => ({
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: mockShowToast }),
+ InfoHoverCard: () => null,
+ ESide: { Top: 'top', Bottom: 'bottom' },
}));
jest.mock('~/data-provider', () => ({
@@ -610,7 +612,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
it("arms the interrupt in place, keeping the steer's id and position", async () => {
renderSteers([{ steerId: 's1', text: 'hold on', status: 'pending', createdAt: 1 }]);
- await clickMenuItem('com_ui_interrupt_steer_now');
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('steer-escalate-now'));
+ });
expect(mockArmMutateAsync).toHaveBeenCalledWith({
conversationId: CONVO_ID,
@@ -622,32 +626,31 @@ describe('InFlightSteers — interrupt-now escalation', () => {
expect(screen.getByText('hold on')).toBeInTheDocument();
/** The chip relabelled in place: an interrupting steer offers no further
- * escalation, so the entry is gone on reopen. */
- fireEvent.click(screen.getByLabelText('com_ui_more_options'));
- expect(await screen.findByText('com_ui_steer_cancel')).toBeInTheDocument();
- expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
+ * escalation, so its arrow control is gone. */
+ expect(screen.queryByTestId('steer-escalate-now')).toBeNull();
});
it('does not offer escalation on a steer that is already interrupting', async () => {
renderSteers([
{ steerId: 's1', text: 'already sealing', status: 'pending', createdAt: 1, preempt: true },
]);
+ expect(screen.queryByTestId('steer-escalate-now')).toBeNull();
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
expect(await screen.findByText('com_ui_steer_cancel')).toBeInTheDocument();
- expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
});
it('keeps the chip an ordinary steer when the deployment cannot seal', async () => {
mockArmMutateAsync.mockResolvedValue({ armed: false, code: 'PREEMPT_UNSUPPORTED' });
renderSteers([{ steerId: 's1', text: 'no seal here', status: 'pending', createdAt: 1 }]);
- await clickMenuItem('com_ui_interrupt_steer_now');
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('steer-escalate-now'));
+ });
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_preempt_unsupported' }),
);
/** Still an ordinary steer: escalation stays offered. */
- fireEvent.click(screen.getByLabelText('com_ui_more_options'));
- expect(await screen.findByText('com_ui_interrupt_steer_now')).toBeInTheDocument();
+ expect(screen.getByTestId('steer-escalate-now')).toBeEnabled();
});
it('stays neutral when the arm loses its race, whatever the reason', async () => {
@@ -655,7 +658,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
* alike, so the toast must not claim one specific outcome. */
mockArmMutateAsync.mockResolvedValue({ armed: false });
renderSteers([{ steerId: 's1', text: 'too late', status: 'pending', createdAt: 1 }]);
- await clickMenuItem('com_ui_interrupt_steer_now');
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('steer-escalate-now'));
+ });
expect(mockRetrySteer).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
@@ -666,7 +671,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
it('reports an arm failure without touching the steer', async () => {
mockArmMutateAsync.mockRejectedValue(new Error('network'));
renderSteers([{ steerId: 's1', text: 'still queued', status: 'pending', createdAt: 1 }]);
- await clickMenuItem('com_ui_interrupt_steer_now');
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('steer-escalate-now'));
+ });
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_arm_failed', status: 'error' }),
@@ -689,19 +696,12 @@ describe('InFlightSteers — interrupt-now escalation', () => {
{ steerId: 's1', text: 'first', status: 'pending', createdAt: 1 },
{ steerId: 's2', text: 'second', status: 'pending', createdAt: 2 },
]);
- /** Every bubble's menu content mounts (hidden) up front, so item lookups
- * must scope to the menu the clicked button controls. */
- const menuFor = (button: HTMLElement) =>
- document.getElementById(button.getAttribute('aria-controls') ?? '') as HTMLElement;
- const menus = screen.getAllByLabelText('com_ui_more_options');
- fireEvent.click(menus[0]);
- fireEvent.click(await within(menuFor(menus[0])).findByText('com_ui_interrupt_steer_now'));
+ const buttons = screen.getAllByTestId('steer-escalate-now');
+ fireEvent.click(buttons[0]);
- fireEvent.click(menus[1]);
- const secondItem = await within(menuFor(menus[1])).findByText('com_ui_interrupt_steer_now');
- expect(secondItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
+ expect(buttons[1]).toBeDisabled();
await act(async () => {
- fireEvent.click(secondItem);
+ fireEvent.click(buttons[1]);
});
await act(async () => {
@@ -716,12 +716,11 @@ describe('InFlightSteers — interrupt-now escalation', () => {
{ steerId: 's1', text: 'plain steer', status: 'pending', createdAt: 1 },
{ steerId: 's2', text: 'sealing now', status: 'pending', createdAt: 2, preempt: true },
]);
- fireEvent.click(screen.getAllByLabelText('com_ui_more_options')[0]);
- const item = await screen.findByText('com_ui_interrupt_steer_now');
- expect(item.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
+ const button = screen.getByTestId('steer-escalate-now');
+ expect(button).toBeDisabled();
await act(async () => {
- fireEvent.click(item);
+ fireEvent.click(button);
});
expect(mockArmMutateAsync).not.toHaveBeenCalled();
});
@@ -743,13 +742,42 @@ describe('InFlightSteers — escalation while the run cannot accept a steer', ()
renderSteers([{ steerId: 's1', text: 'waiting', status: 'pending', createdAt: 1 }], {
duringRunActive: false,
});
- fireEvent.click(screen.getByLabelText('com_ui_more_options'));
- const item = await screen.findByText('com_ui_interrupt_steer_now');
- expect(item.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
+ const button = screen.getByTestId('steer-escalate-now');
+ expect(button).toBeDisabled();
await act(async () => {
- fireEvent.click(item);
+ fireEvent.click(button);
});
expect(mockArmMutateAsync).not.toHaveBeenCalled();
});
});
+
+/**
+ * One-off message actions and sticky behavior changes must never read as the
+ * same kind of choice: actions first (edit, cancel, queue), then a separated
+ * "Preferences" section holding the two mode toggles. Escalation is not a
+ * menu entry at all — it has its own always-visible arrow control.
+ */
+describe('InFlightSteers — menu structure', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCancelMutateAsync.mockResolvedValue({ removed: true });
+ });
+
+ it('orders actions above a separated Preferences section, with no escalation entry', async () => {
+ renderSteers([{ steerId: 's1', text: 'structured', status: 'pending', createdAt: 1 }]);
+ fireEvent.click(screen.getByLabelText('com_ui_more_options'));
+ await screen.findByText('com_ui_edit_message');
+
+ const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
+ expect(items).toEqual([
+ 'com_ui_edit_message',
+ 'com_ui_steer_cancel',
+ 'com_ui_convert_to_queue',
+ 'com_ui_turn_on_queueing',
+ 'com_ui_always_interrupt',
+ ]);
+ expect(screen.getByText('com_ui_preferences')).toBeInTheDocument();
+ expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
+ });
+});
diff --git a/client/src/hooks/useKeyboardShortcuts.ts b/client/src/hooks/useKeyboardShortcuts.ts
index 2b97f92702..2beffebc19 100644
--- a/client/src/hooks/useKeyboardShortcuts.ts
+++ b/client/src/hooks/useKeyboardShortcuts.ts
@@ -115,6 +115,14 @@ export const shortcutDefinitions = {
ariaMac: 'Meta+Shift+X',
ariaOther: 'Control+Shift+X',
},
+ escalateSteer: {
+ labelKey: 'com_ui_interrupt_steer_now',
+ groupKey: 'com_shortcut_group_chat',
+ displayMac: '⌘ ⇧ .',
+ displayOther: 'Ctrl+Shift+.',
+ ariaMac: 'Meta+Shift+.',
+ ariaOther: 'Control+Shift+.',
+ },
regenerateResponse: {
labelKey: 'com_shortcut_regenerate_response',
groupKey: 'com_shortcut_group_chat',
@@ -289,6 +297,7 @@ export const EDITING_ALLOWED_SHORTCUTS: ReadonlySet = new Set(
'focusSearch',
'showShortcuts',
'submitMessage',
+ 'escalateSteer',
]);
export type ShortcutAction = ShortcutDefinition & {
@@ -551,6 +560,25 @@ export function useShortcutActions(): ShortcutAction[] {
[],
);
+ /** Escalate the newest waiting message to an interrupt by pressing its own
+ * visible arrow control, so the shortcut can never diverge from the
+ * button's semantics. A waiting steer bubble beats a queued follow-up (it
+ * is closer to the run); newest-last matches how both stacks append. */
+ const handleEscalateSteer = useCallback(() => {
+ const pick = (surface: string) => {
+ const list = document.querySelectorAll(
+ `[data-escalate-steer="${surface}"]`,
+ );
+ for (let i = list.length - 1; i >= 0; i--) {
+ if (!isUnavailableElement(list[i])) {
+ return list[i];
+ }
+ }
+ return null;
+ };
+ return clickTarget(pick('bubble') ?? pick('queued'));
+ }, []);
+
const handleEditLastMessage = useCallback(() => {
const userTurns = document.querySelectorAll('.user-turn');
if (userTurns.length === 0) {
@@ -750,6 +778,7 @@ export function useShortcutActions(): ShortcutAction[] {
focusSearch: handleFocusSearch,
openSettings: handleOpenSettings,
stopGenerating: handleStopGenerating,
+ escalateSteer: handleEscalateSteer,
regenerateResponse: handleRegenerateResponse,
editLastMessage: handleEditLastMessage,
copyLastCode: handleCopyLastCode,
@@ -779,6 +808,7 @@ export function useShortcutActions(): ShortcutAction[] {
handleUploadFile,
handleToggleSidebar,
handleOpenModelSelector,
+ handleEscalateSteer,
handleFocusSearch,
handleOpenSettings,
handleStopGenerating,
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index bd8f29355d..ef1b48257b 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1527,6 +1527,7 @@
"com_ui_pin_error": "Failed to pin conversation",
"com_ui_pinned": "Pinned",
"com_ui_plus_n_more": "+{{0}} more",
+ "com_ui_preferences": "Preferences",
"com_ui_preferences_updated": "Preferences updated successfully",
"com_ui_prev": "Prev",
"com_ui_prev_result": "Previous result",
diff --git a/e2e/specs/mock/steering-escalation.spec.ts b/e2e/specs/mock/steering-escalation.spec.ts
index 7223f58cc3..52188de32b 100644
--- a/e2e/specs/mock/steering-escalation.spec.ts
+++ b/e2e/specs/mock/steering-escalation.spec.ts
@@ -145,24 +145,19 @@ test.describe('escalating waiting messages to an interrupt', () => {
const bubble = inFlightSteers(page).filter({ hasText: steerText });
await expect(bubble).toBeVisible({ timeout: 10000 });
- // Escalate from the bubble's overflow menu: ONE atomic in-place arm.
- await bubble.getByRole('button', { name: 'More options' }).click();
+ // Escalate via the bubble's always-visible arrow control: ONE atomic
+ // in-place arm.
const [armResponse] = await Promise.all([
page.waitForResponse(isArmRequest, { timeout: 15000 }),
- page.getByRole('menuitem', { name: 'Interrupt & steer now' }).click(),
+ bubble.getByTestId('steer-escalate-now').click(),
]);
expect(armResponse.status()).toBe(200);
expect(((await armResponse.json()) as { armed?: boolean }).armed).toBe(true);
// Relabelled IN PLACE: still exactly one bubble with the same text, and
- // an interrupting steer no longer offers escalation on reopen.
+ // an interrupting steer no longer offers its escalation control.
await expect(inFlightSteers(page)).toHaveCount(1);
- await bubble.getByRole('button', { name: 'More options' }).click();
- await expect(page.getByRole('menuitem', { name: 'Cancel steering message' })).toBeVisible({
- timeout: 5000,
- });
- await expect(page.getByRole('menuitem', { name: 'Interrupt & steer now' })).toHaveCount(0);
- await page.keyboard.press('Escape');
+ await expect(bubble.getByTestId('steer-escalate-now')).toHaveCount(0);
// The armed steer seals mid-stream and injects with no tool boundary.
await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, {
@@ -196,7 +191,9 @@ test.describe('escalating waiting messages to an interrupt', () => {
const row = queuedRows(page).filter({ hasText: queueText });
await expect(row).toBeVisible({ timeout: 10000 });
+ // The toggle lives in the row menu's separated Preferences section.
await row.getByRole('button', { name: 'More options' }).click();
+ await expect(page.getByText('Preferences', { exact: true })).toBeVisible({ timeout: 5000 });
await page.getByRole('menuitem', { name: 'Always interrupt instead' }).click();
// The toggle is live for the SAME run: plain Enter now routes the default
@@ -222,4 +219,46 @@ test.describe('escalating waiting messages to an interrupt', () => {
timeout: 5000,
});
});
+
+ test('the dedicated shortcut escalates the newest waiting steer from the keyboard', async ({
+ page,
+ }) => {
+ test.setTimeout(150000);
+ const label = uniqueLabel('shortcut');
+ const steerText = `Shortcut-armed steer ${label}`;
+
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ await establishConversation(page, `shortcut-setup-${label}`);
+
+ const run = await sendMessage(page, `E2E_SLOW_REPLY:${label}`);
+ expect(run.ok()).toBeTruthy();
+ await expect(messagesView(page).getByText('chunk-010')).toBeVisible({ timeout: 15000 });
+
+ await typeDuringRun(page, steerText);
+ const [steerResponse] = await Promise.all([
+ page.waitForResponse(isSteerRequest, { timeout: 15000 }),
+ messageInput(page).press('Enter'),
+ ]);
+ expect(steerResponse.status()).toBe(202);
+ await expect(inFlightSteers(page).filter({ hasText: steerText })).toBeVisible({
+ timeout: 10000,
+ });
+
+ // The dedicated command works from the composer (it is editing-allowed),
+ // pressing the newest waiting bubble's own arrow control.
+ await messageInput(page).click();
+ const [armResponse] = await Promise.all([
+ page.waitForResponse(isArmRequest, { timeout: 15000 }),
+ page.keyboard.press('ControlOrMeta+Shift+.'),
+ ]);
+ expect(armResponse.status()).toBe(200);
+ expect(((await armResponse.json()) as { armed?: boolean }).armed).toBe(true);
+
+ // And the armed steer seals mid-stream, same proof as the button path.
+ await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, {
+ timeout: 90000,
+ });
+ await expect(messagesView(page).getByText(SLOW_REPLY_LAST_CHUNK)).toHaveCount(0);
+ });
});