From 463de567e58039959c7a73ace91e41b2e2bb30fa Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 29 Jul 2026 20:09:42 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20feat:=20Interrupt=20&=20Steer=20?= =?UTF-8?q?=E2=80=94=20client=20half=20(PR=203=20of=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes preemptive steering reachable. Consumes the server contract from PR 2 (POST /chat/steer `preempt`, echoed on the 202) and the SDK seam in @librechat/agents 3.3.5. Settings shape follows the agreed correction, NOT the earlier plan draft: `steerInterruptsByDefault` is a boolean ORTHOGONAL to `duringRunDefaultAction` — that enum still chooses steer-vs-queue, the new boolean chooses how soon a steer lands. This deliberately avoids widening the enum to three values, which would have silently broken two hard-coded binary TOGGLES (`DuringRunAction.tsx`'s setter and `SteerMenu`'s `useDefaultToggleEntry`, both `prev === 'steer' ? … : …`) where a third value collapses to the wrong branch and one click erases the setting. - useSteering: `submitSteer` takes an opts bag and threads `preempt` into the POST, the optimistic chip, and the failure chip. The ACK relabels from the SERVER's echo, so a deployment that cannot seal mid-stream downgrades the chip's wording instead of erroring — the entire UX surface of capability degradation. New `interruptSteer` reuses the whole chip lifecycle and degradation ladder, and falls back to `interruptAndSend` when `!canSteer`, because steering needs a server-side job and an always-visible button would otherwise be dead for the whole first turn. `steerFromComposer` honours the new preference. - Composer: always-visible `InterruptSteerButton` with one fixed meaning (stop now, keep what's written), disabled on a paused run to pre-empt the server's 409, `type="button"` so it never steals the form's Enter submit, RTL-correct margins. A fourth hovercard row on the during-run send button, and ⌘/Ctrl+Shift+Enter routed AHEAD of the bare ⌘/Ctrl+Enter branch that would otherwise swallow it. - Chips: an in-flight preempt chip reads "Interrupting" with a ZapOff glyph; `preempt` survives reconnect through `seedSteerChips`. - `RunEnd.interruptArmed`, `drainAfterAbortByIndex`, `useQueueDrain`, `stopGenerating` and `interruptAndSend` are untouched — the preempt path deliberately shares none of the abort machinery. Tests: 7 new specs (posts preempt, turn-1 fallback, empty-text refusal, default route with and without the preference, server-echo relabel, double-click). 66 useSteering specs green; tsc and lint clean. Round-1 review fixes folded in: - P1: interrupt & steer no longer hard-aborts a run paused on tool approval. `canSteer` is false there, so the fallback was routing the keyboard and hovercard paths into `interruptAndSend` — discarding the partial answer, the exact opposite of what the action promises. The fallback is now scoped to the missing-conversation case only, and a paused run refuses outright (the standalone button was already disabled; the guard now lives where all three paths reach it). - The preference no longer leaks into the explicit Steer action. `steerFromComposer` backs both the default Enter route AND the explicit hovercard row / Ctrl+Enter alternate; applying `steerInterruptsByDefault` inside it made ordinary Steer interrupt and the two rows indistinguishable. It now takes an explicit argument that only `submitDuringRun`'s default route sets. - Retry preserves preemption: a failed interrupt-steer chip keeps `preempt: true`, and `retrySteer` now forwards it rather than silently resubmitting as an ordinary tool-boundary steer. - ⌘/Ctrl+Shift+Enter defers to a rebound submit shortcut, mirroring the bare ⌘/Ctrl+Enter branch — a user who bound submit to that chord keeps getting submit. Round-2 fix: the preempt label now survives the page-reload resume path too. `seedSteerChips` (useResumableSSE) and `restoreSteerChips` (useResumeOnLoad) are two independent TPendingSteer→PendingSteer mappers with near-identical bodies; the first carried the flag and the second silently dropped it, so an armed interrupt reverted to plain "Steering" after a reload. Swept: those are the only two in production code. The reclaim/convert paths deliberately omit it — a queued follow-up starts its own turn, so there is nothing to interrupt. --- client/src/components/Chat/Input/ChatForm.tsx | 18 +- .../Chat/Input/DuringRunSendButton.tsx | 19 +- .../components/Chat/Input/InFlightSteers.tsx | 14 +- .../Chat/Input/InterruptSteerButton.tsx | 73 ++++++++ .../Chat/Input/PendingSteerChips.tsx | 11 +- .../src/components/Nav/Settings/registry.tsx | 13 ++ client/src/data-provider/SSE/mutations.ts | 12 +- .../hooks/Chat/__tests__/useSteering.spec.tsx | 163 ++++++++++++++++++ client/src/hooks/Chat/useSteering.ts | 87 +++++++++- client/src/hooks/Input/useTextarea.ts | 11 +- client/src/hooks/SSE/useResumableSSE.ts | 1 + client/src/hooks/SSE/useResumeOnLoad.ts | 1 + client/src/locales/en/translation.json | 6 + client/src/store/families.ts | 13 +- client/src/store/settings.ts | 8 + 15 files changed, 424 insertions(+), 26 deletions(-) create mode 100644 client/src/components/Chat/Input/InterruptSteerButton.tsx diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 12581ac6e8..784ec144e2 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -26,6 +26,7 @@ import PendingManualSkillsChips from './PendingManualSkillsChips'; import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode'; import AskUserQuestionPopover from './AskUserQuestionPopover'; import { cn, getModelSpec, removeFocusRings } from '~/utils'; +import InterruptSteerButton from './InterruptSteerButton'; import DuringRunSendButton from './DuringRunSendButton'; import { useGetStartupConfig } from '~/data-provider'; import { mainTextareaId, BadgeItem } from '~/common'; @@ -344,13 +345,16 @@ const ChatForm = memo(function ChatForm({ ); /** ⌘/Ctrl+Enter = the non-default during-run action, ⌥/Alt+Enter = - * interrupt & send — the counterpart of Enter's `submitDuringRun`. */ + * interrupt & send (discards the answer), ⌘/Ctrl+Shift+Enter = interrupt & + * steer (keeps it) — all counterparts of Enter's `submitDuringRun`. */ const handleDuringRunModifier = useCallback( - (kind: 'other' | 'interrupt') => { + (kind: 'other' | 'interrupt' | 'preempt') => { const text = methods.getValues('text'); let consumed = false; if (kind === 'interrupt') { consumed = steering.interruptAndSend(text); + } else if (kind === 'preempt') { + consumed = steering.interruptSteer(text); } else if (steering.effectiveAction === 'steer') { consumed = steering.queueFromComposer(text); } else { @@ -661,6 +665,16 @@ const ChatForm = memo(function ChatForm({ isSubmitting={isSubmitting} /> )} + {steering.duringRunActive && (textValue?.trim() ?? '') !== '' && ( +
+ methods.getValues('text')} + onConsumed={() => methods.reset()} + disabled={filesLoading} + /> +
+ )}
{isSubmitting && showStopButton && !answerMode.active ? duringRunSlot diff --git a/client/src/components/Chat/Input/DuringRunSendButton.tsx b/client/src/components/Chat/Input/DuringRunSendButton.tsx index 01f6d57fa1..b2e5eae86c 100644 --- a/client/src/components/Chat/Input/DuringRunSendButton.tsx +++ b/client/src/components/Chat/Input/DuringRunSendButton.tsx @@ -2,7 +2,7 @@ import React, { forwardRef } from 'react'; import * as Ariakit from '@ariakit/react'; import { useWatch } from 'react-hook-form'; import { SendIcon } from '@librechat/client'; -import { Zap, Clock, OctagonPause } from 'lucide-react'; +import { Zap, Clock, OctagonPause, ZapOff } from 'lucide-react'; import type { Control } from 'react-hook-form'; import type { SteeringControls } from '~/hooks/Chat/useSteering'; import { isMacPlatform } from '~/utils/shortcuts'; @@ -43,8 +43,10 @@ type DuringRunSendButtonProps = { * (and `submitButtonRef`, so Enter's synthetic click routes here) whenever the * composer holds text — submitting steers or queues per the effective action. * Hovering it reveals the full action list with its shortcuts: steer, queue - * (⌘/Ctrl+Enter routes to the non-default action), and interrupt & send - * (⌥/Alt+Enter). Clearing the composer restores the Stop button. + * (⌘/Ctrl+Enter routes to the non-default action), interrupt & steer + * (⌘/Ctrl+Shift+Enter — stops writing now but keeps what is written), and + * interrupt & send (⌥/Alt+Enter — discards the answer and starts over). + * Clearing the composer restores the Stop button. */ const DuringRunSendButton = React.memo( forwardRef((props: DuringRunSendButtonProps, ref: React.ForwardedRef) => { @@ -55,6 +57,7 @@ const DuringRunSendButton = React.memo( const primary = steering.effectiveAction; const modEnter = isMacPlatform ? '⌘⏎' : 'Ctrl ⏎'; const altEnter = isMacPlatform ? '⌥⏎' : 'Alt ⏎'; + const modShiftEnter = isMacPlatform ? '⌘⇧⏎' : 'Ctrl ⇧ ⏎'; const runAction = (action: (text: string) => boolean | void) => { const text = props.getText().trim(); @@ -83,6 +86,14 @@ const DuringRunSendButton = React.memo( icon: