From 9a0a8d1c151133d1bcc866448ebe7b59827f4068 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 30 Jul 2026 18:44:32 -0400 Subject: [PATCH] feat: dedicated escalation arrow + shortcut, menu split into actions and preferences The escalation was still half-hidden: the bubble only offered it inside the overflow menu, and the tooltip taught the composer chord, which does a different thing (interrupts with typed text, not this chip). Three changes make it a first-class command: - A shared EscalateNowButton (circular arrow, ghost-bordered like the composer's interrupt control) is always visible on BOTH surfaces: beside each queued row's Steer primary and on every waiting steer bubble next to its menu. It disappears once a steer is interrupting. - A dedicated registry shortcut, escalateSteer (Cmd/Ctrl+Shift+.), editing-allowed and rebindable like every other action. Deliberately NOT an Enter chord: the composer owns every Enter chord, and the yield design rests on no default binding using Enter besides submit. Its handler clicks the newest enabled arrow control (bubbles beat queued rows), so the shortcut can never diverge from the button, and the arrow's tooltip teaches THIS command via the registry display. - The overflow menus separate one-off actions from sticky behavior changes: Edit, Cancel, Queue, then a smaller "Preferences" section holding the queueing and always-interrupt toggles, each with the standard InfoHoverCard reusing the Settings panel's descriptions. "Interrupt & steer now" leaves the menu entirely. 386 client tests green, including a menu-structure test locking the order and the absence of the escalation entry; bubble escalation tests drive the visible arrow. The e2e spec's bubble test now clicks the arrow, and a fourth test drives the dedicated shortcut end to end through a real mid-stream seal. --- .../components/Chat/Input/InFlightSteers.tsx | 165 +++++++++--------- .../Chat/Input/PendingSteerChips.tsx | 60 ++----- .../src/components/Chat/Input/SteerMenu.tsx | 158 +++++++++++------ .../Input/__tests__/InFlightSteers.test.tsx | 88 ++++++---- client/src/hooks/useKeyboardShortcuts.ts | 30 ++++ client/src/locales/en/translation.json | 1 + e2e/specs/mock/steering-escalation.spec.ts | 59 +++++-- 7 files changed, 340 insertions(+), 221 deletions(-) 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: