feat: Interrupt & Steer — client half (PR 3 of 3)

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.
This commit is contained in:
Danny Avila 2026-07-29 20:09:42 -04:00
parent b40aed7cff
commit 463de567e5
15 changed files with 424 additions and 26 deletions

View file

@ -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() ?? '') !== '' && (
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
<InterruptSteerButton
steering={steering}
getText={() => methods.getValues('text')}
onConsumed={() => methods.reset()}
disabled={filesLoading}
/>
</div>
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{isSubmitting && showStopButton && !answerMode.active
? duringRunSlot

View file

@ -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<HTMLButtonElement>) => {
@ -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: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
onClick: () => runAction((text) => steering.queueFromComposer(text)),
};
/** Keeps the half-written answer, unlike interrupt & send below it. */
const interruptSteerRow: ActionRow = {
key: 'interrupt-steer',
label: localize('com_ui_interrupt_steer'),
kbd: modShiftEnter,
icon: <ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />,
onClick: () => runAction((text) => steering.interruptSteer(text)),
};
const interruptRow: ActionRow = {
key: 'interrupt',
label: localize('com_ui_interrupt_send'),
@ -91,7 +102,7 @@ const DuringRunSendButton = React.memo(
onClick: () => runAction((text) => steering.interruptAndSend(text)),
};
const rows = primary === 'steer' ? [steerRow, queueRow] : [queueRow, steerRow];
rows.push(interruptRow);
rows.push(interruptSteerRow, interruptRow);
const label =
primary === 'steer' ? localize('com_ui_steer_send') : localize('com_ui_queue_send');

View file

@ -2,7 +2,7 @@ import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from '
import { useSetAtom } from 'jotai';
import { useToastContext } from '@librechat/client';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import { X, Zap, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
import type { TFile, TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
@ -86,6 +86,7 @@ const InFlightSteer = memo(function InFlightSteer({
const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]);
const sending = steer.status === 'sending';
const preempting = steer.preempt === true;
/** Long steers (several paragraphs) collapse to a preview so the stack stays
* scannable; the toggle is offered only once the content actually overflows
@ -232,6 +233,7 @@ const InFlightSteer = memo(function InFlightSteer({
role="listitem"
data-testid="in-flight-steer"
data-steer-status={steer.status}
data-steer-preempt={preempting ? 'true' : undefined}
/* pointer-events-auto: the overlay container disables events so wheeling
* over the gaps reaches the messages behind; each bubble re-enables them
* for its own controls and internal scroll. */
@ -270,8 +272,14 @@ const InFlightSteer = memo(function InFlightSteer({
sending && 'opacity-70',
)}
>
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_steer_in_flight')}</span>
{preempting ? (
<ZapOff className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
) : (
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
)}
<span className="sr-only">
{localize(preempting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_in_flight')}
</span>
<div className="flex min-w-0 flex-col items-start gap-1">
<div
ref={contentRef}

View file

@ -0,0 +1,73 @@
import React from 'react';
import { ZapOff } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type InterruptSteerButtonProps = {
steering: SteeringControls;
getText: () => string;
onConsumed: () => void;
/** External hold (e.g. uploads in flight), mirroring the send button. */
disabled?: boolean;
};
/**
* Always-visible composer control with one fixed meaning: stop writing now,
* keep what is written, and steer from here. Distinct from the send button's
* hovercard, whose primary action follows the user's during-run preference
* this one never changes what it does.
*
* `type="button"`: the composer footer sits inside the chat form, and only
* `DuringRunSendButton` may receive Enter's synthetic submit.
*/
const InterruptSteerButton = React.memo((props: InterruptSteerButtonProps) => {
const localize = useLocalize();
const { steering } = props;
const label = localize('com_ui_interrupt_steer_button');
/** Pre-empts the server's 409: a paused run cannot accept a steer. */
const disabled = props.disabled === true || steering.pausedOnApproval;
const onClick = () => {
const text = props.getText().trim();
if (text.length === 0) {
return;
}
if (steering.interruptSteer(text) !== false) {
props.onConsumed();
}
};
return (
<Ariakit.TooltipProvider placement="top" timeout={300}>
<Ariakit.TooltipAnchor
render={
<button
type="button"
aria-label={label}
data-testid="interrupt-steer-button"
disabled={disabled}
onClick={onClick}
className={cn(
'flex size-9 items-center justify-center rounded-full border border-border-light',
'text-text-secondary transition-colors duration-200',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent',
)}
>
<ZapOff className="size-4" aria-hidden="true" />
</button>
}
/>
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
{localize('com_ui_interrupt_steer_desc')}
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
);
});
InterruptSteerButton.displayName = 'InterruptSteerButton';
export default InterruptSteerButton;

View file

@ -176,10 +176,13 @@ function FailedSteerRow({
type="button"
className={PRIMARY_BTN_CLASS}
onClick={() =>
steering.retrySteer(steer.steerId, steer.text, steer.files, {
quotes: steer.quotes,
manualSkills: steer.manualSkills,
})
steering.retrySteer(
steer.steerId,
steer.text,
steer.files,
{ quotes: steer.quotes, manualSkills: steer.manualSkills },
{ preempt: steer.preempt === true },
)
}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />

View file

@ -148,6 +148,19 @@ export const registry: SettingEntry[] = [
keywords: ['steer', 'queue', 'interrupt', 'generating'],
Component: DuringRunAction,
},
{
id: 'steerInterruptsByDefault',
tab: CHAT,
section: 'sending',
labelKey: 'com_ui_steer_interrupts_default',
keywords: ['steer', 'interrupt', 'preempt', 'generating', 'stop'],
Component: toggleControl({
stateAtom: store.steerInterruptsByDefault,
localizationKey: 'com_ui_steer_interrupts_default',
switchId: 'steerInterruptsByDefault',
hoverCardText: 'com_ui_steer_interrupts_default_info',
}),
},
{
id: 'saveDrafts',
tab: CHAT,

View file

@ -148,6 +148,13 @@ export interface SteerMessageParams {
text: string;
/** Attachment refs steered with the message (already uploaded). */
files?: TMessage['files'];
/**
* Ask the server to seal the live model stream at the next provider-safe
* boundary rather than waiting for a tool step. Never a rejection reason:
* a server or SDK without the capability still queues the steer and echoes
* `preempt: false`, which relabels the chip instead of erroring.
*/
preempt?: boolean;
}
/** Successful steer ACK: the server queued the message for mid-run injection. */
@ -156,11 +163,14 @@ export interface SteerMessageResponse {
steerId: string;
position: number;
conversationId: string;
/** Whether the seal request was actually armed; see {@link SteerMessageParams.preempt}. */
preempt?: boolean;
}
/**
* Queue a mid-run steering message against the conversation's active run.
* The server injects it at the next tool-batch boundary and streams an
* The server injects it at the next tool-batch boundary or, when `preempt`
* is armed, at the next provider-safe token boundary and streams an
* `on_steer_applied` event over the existing SSE; this only fires the POST.
* Rejections carry a `code` the caller degrades on (NO_ACTIVE_RUN normal
* send, RUN_PAUSED / STEER_UNSUPPORTED client-side queue).

View file

@ -508,6 +508,169 @@ describe('useSteering', () => {
});
});
describe('interrupt & steer (preempt)', () => {
it('posts preempt: true and marks the optimistic chip', () => {
const { result } = setup();
act(() => {
result.current.interruptSteer('stop and do this');
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ text: 'stop and do this', preempt: true }),
expect.anything(),
);
});
/**
* Steering needs a server-side job, so `submitSteer` hard-refuses without
* a real conversationId. Without this fallback the always-visible button
* would be dead for the whole first turn.
*/
it('falls back to interruptAndSend before a conversation exists', () => {
const { result, stopGenerating } = setup({
conversationId: Constants.NEW_CONVO as string,
});
let consumed = false;
act(() => {
consumed = result.current.interruptSteer('turn one interrupt');
});
expect(consumed).toBe(true);
expect(mockMutate).not.toHaveBeenCalled();
expect(stopGenerating).toHaveBeenCalled();
});
it('refuses empty text without touching the run', () => {
const { result, stopGenerating } = setup();
let consumed = true;
act(() => {
consumed = result.current.interruptSteer(' ');
});
expect(consumed).toBe(false);
expect(mockMutate).not.toHaveBeenCalled();
expect(stopGenerating).not.toHaveBeenCalled();
});
/**
* `canSteer` is false while paused, but routing that into
* `interruptAndSend` would hard-abort the run and discard the partial
* answer the opposite of what the action promises.
*/
it('refuses while paused on tool approval instead of aborting', () => {
mockMessages = [
{
messageId: 'm1',
conversationId: CONVO_ID,
isCreatedByUser: false,
content: [
{
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: { id: 'call_1', name: 't', approval: 'pending' },
},
],
} as unknown as TMessage,
];
const { result, stopGenerating } = setup();
let consumed = true;
act(() => {
consumed = result.current.interruptSteer('do not abort me');
});
mockMessages = undefined;
expect(consumed).toBe(false);
expect(stopGenerating).not.toHaveBeenCalled();
expect(mockMutate).not.toHaveBeenCalled();
});
/**
* The explicit Steer row and the Ctrl/Cmd+Enter alternate must stay
* non-preempting, or they become indistinguishable from Interrupt & steer.
*/
it('leaves the explicit Steer action non-preempting even with the preference on', () => {
const { result } = setup({}, ({ set }) => {
set(store.steerInterruptsByDefault, true);
});
act(() => {
result.current.steerFromComposer('explicit steer row');
});
expect(mockMutate).toHaveBeenCalledWith(
expect.not.objectContaining({ preempt: true }),
expect.anything(),
);
});
it('retry resubmits a failed interrupt-steer AS an interrupt', () => {
const { result } = setup();
act(() => {
result.current.retrySteer('chip-1', 'retry me', undefined, undefined, { preempt: true });
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ text: 'retry me', preempt: true }),
expect.anything(),
);
});
it('an ordinary steer does not preempt by default', () => {
const { result } = setup();
act(() => {
result.current.submitDuringRun('just steer');
});
expect(mockMutate).toHaveBeenCalledWith(
expect.not.objectContaining({ preempt: true }),
expect.anything(),
);
});
it('steerInterruptsByDefault makes the default Enter route preempt', () => {
const { result } = setup({}, ({ set }) => {
set(store.steerInterruptsByDefault, true);
});
act(() => {
result.current.submitDuringRun('enter should interrupt');
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ preempt: true }),
expect.anything(),
);
});
/**
* Capability degradation is a relabel, never an error: the server echoes
* what it actually armed and the chip follows it.
*/
it('honours a server echo of preempt: false on the ACK', () => {
mockMutate.mockImplementation((_params, { onSuccess }) => {
onSuccess({
status: 'queued',
steerId: 'server-1',
position: 1,
conversationId: CONVO_ID,
preempt: false,
});
});
const { result } = setup();
act(() => {
result.current.interruptSteer('interrupt me');
});
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ preempt: true }),
expect.anything(),
);
});
it('is idempotent enough for a double click (two chips, both armed)', () => {
const { result } = setup();
act(() => {
result.current.interruptSteer('first');
result.current.interruptSteer('second');
});
expect(mockMutate).toHaveBeenCalledTimes(2);
expect(mockMutate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ text: 'second', preempt: true }),
expect.anything(),
);
});
});
describe('interruptAndSend + queue helpers', () => {
function setupWithState(
params: HookParams = {},

View file

@ -124,6 +124,7 @@ export default function useSteering({
const { mutate: markFilesUsage } = useMarkFilesUsageMutation();
const defaultAction = useRecoilValue<DuringRunAction>(store.duringRunDefaultAction);
const setDefaultAction = useSetRecoilState(store.duringRunDefaultAction);
const steerInterruptsByDefault = useRecoilValue(store.steerInterruptsByDefault);
const endpoint = conversation?.endpointType ?? conversation?.endpoint;
const steerable = !isAssistantsEndpoint(endpoint);
@ -449,11 +450,17 @@ export default function useSteering({
* the item's quotes and manual skills survive. Composer-origin steers pass
* nothing, leaving their context staged in the composer atoms. */
const submitSteer = useCallback(
(text: string, steerFiles?: TMessage['files'], context?: QueuedMessageContext): boolean => {
(
text: string,
steerFiles?: TMessage['files'],
context?: QueuedMessageContext,
opts?: { preempt?: boolean },
): boolean => {
const trimmed = text.trim();
if (trimmed.length === 0 || !hasRealConvoId) {
return false;
}
const preempt = opts?.preempt === true;
const files = steerFiles && steerFiles.length > 0 ? steerFiles : undefined;
/** Rides every chip state so a terminal conversion (late ACK, run-end
* leftover report) can restore the queued item's full context. */
@ -470,18 +477,24 @@ export default function useSteering({
status: 'sending',
createdAt,
...(files && { files }),
...(preempt && { preempt: true }),
...carried,
});
steerMessage(
{ conversationId, text: trimmed, ...(files && { files }) },
{ conversationId, text: trimmed, ...(files && { files }), ...(preempt && { preempt }) },
{
onSuccess: (response) => {
/** The server's echo is authoritative: a deployment whose SDK
* cannot seal mid-stream still queues the steer and answers
* `preempt: false`, which relabels the chip to the ordinary
* wording instead of surfacing an error. */
acknowledgeSteer(conversationId, localId, {
steerId: response.steerId,
text: trimmed,
status: 'pending',
createdAt,
...(files && { files }),
...(response.preempt === true && { preempt: true }),
...carried,
});
},
@ -528,6 +541,7 @@ export default function useSteering({
status: 'failed',
createdAt,
...(files && { files }),
...(preempt && { preempt: true }),
...carried,
});
},
@ -553,12 +567,12 @@ export default function useSteering({
* ride the steer as one unit (the server re-fetches + encodes them at the
* injection boundary). Files are taken only after the guards pass. */
const steerFromComposer = useCallback(
(text: string): boolean => {
(text: string, preempt = false): boolean => {
const trimmed = text.trim();
if (trimmed.length === 0 || filesLoading || !hasRealConvoId) {
return false;
}
const consumed = submitSteer(trimmed, takeComposerFiles());
const consumed = submitSteer(trimmed, takeComposerFiles(), undefined, { preempt });
if (consumed) {
takeComposerDraft();
}
@ -589,9 +603,12 @@ export default function useSteering({
text: string,
steerFiles?: TMessage['files'],
context?: QueuedMessageContext,
opts?: { preempt?: boolean },
) => {
replaceSteerChip(conversationId, steerId, null);
submitSteer(text, steerFiles, context);
/** A failed interrupt-steer must retry AS an interrupt resubmitting it
* as an ordinary steer would silently let generation run on. */
submitSteer(text, steerFiles, context, opts);
},
[conversationId, replaceSteerChip, submitSteer],
);
@ -740,6 +757,51 @@ export default function useSteering({
],
);
/**
* Interrupt & steer: the same POST, queue, chip lifecycle and degradation
* ladder as an ordinary steer the only difference is that the server asks
* the generating replica to seal its model stream at the next
* provider-safe boundary instead of waiting for a tool step. The partial
* answer is kept and generation resumes in the same message.
*
* Falls back to `interruptAndSend` ONLY before a conversation exists:
* steering needs a server-side job, so `submitSteer` hard-refuses without a
* real conversationId, and an always-visible button would otherwise be dead
* for the entire first turn exactly when a user most wants to stop a long
* answer.
*
* A run paused on tool approval refuses outright instead. `canSteer` is
* false there too, but routing that into `interruptAndSend` would hard-abort
* the run and discard the partial answer the exact opposite of what this
* action promises. The standalone button is disabled while paused; the
* keyboard and hovercard paths reach here, so the guard lives here.
*/
const interruptSteer = useCallback(
(text: string): boolean => {
const trimmed = text.trim();
if (trimmed.length === 0 || filesLoading || pausedOnApproval) {
return false;
}
if (!hasRealConvoId) {
return interruptAndSend(trimmed);
}
const consumed = submitSteer(trimmed, takeComposerFiles(), undefined, { preempt: true });
if (consumed) {
takeComposerDraft();
}
return consumed;
},
[
filesLoading,
pausedOnApproval,
hasRealConvoId,
interruptAndSend,
takeComposerFiles,
takeComposerDraft,
submitSteer,
],
);
/** Routes a during-run submit to the effective action. Returns true when consumed. */
const submitDuringRun = useCallback(
(text: string): boolean => {
@ -747,11 +809,20 @@ export default function useSteering({
return false;
}
if (effectiveAction === 'steer') {
return steerFromComposer(text);
/** Only the DEFAULT route honours the preference the explicit Steer
* row and the Ctrl/Cmd+Enter alternate stay non-preempting, or they
* would become indistinguishable from Interrupt & steer. */
return steerFromComposer(text, steerInterruptsByDefault);
}
return queueFromComposer(text);
},
[duringRunActive, effectiveAction, steerFromComposer, queueFromComposer],
[
duringRunActive,
effectiveAction,
steerInterruptsByDefault,
steerFromComposer,
queueFromComposer,
],
);
/** Memoized so consumers like `memo(PendingSteerChips)` can bail on the
@ -778,6 +849,7 @@ export default function useSteering({
removeQueued,
sendQueuedNow,
interruptAndSend,
interruptSteer,
}),
[
enabled,
@ -800,6 +872,7 @@ export default function useSteering({
removeQueued,
sendQueuedNow,
interruptAndSend,
interruptSteer,
],
);
}

View file

@ -51,7 +51,7 @@ export default function useTextarea({
allowSubmitWhileGenerating?: boolean;
/** During-run modifier chords: /Ctrl+Enter = the non-default action,
* /Alt+Enter = interrupt & send. Enter itself submits the default. */
onDuringRunModifier?: (kind: 'other' | 'interrupt') => void;
onDuringRunModifier?: (kind: 'other' | 'interrupt' | 'preempt') => void;
}) {
const localize = useLocalize();
const getSender = useGetSender();
@ -212,6 +212,15 @@ export default function useTextarea({
onDuringRunModifier('interrupt');
return;
}
// Before the bare Ctrl/Cmd branch below, which would otherwise
// swallow the shifted chord. Yields to a rebound submit shortcut for
// the same reason that branch does: a user who bound submit to this
// chord must keep getting submit.
if ((e.ctrlKey || e.metaKey) && e.shiftKey && submitOverride === undefined) {
e.preventDefault();
onDuringRunModifier('preempt');
return;
}
// Only when plain Enter is the submit key — for Ctrl/Cmd+Enter
// submitters (enterToSend off or a rebound chord) the chord must
// keep meaning "submit the default action".

View file

@ -602,6 +602,7 @@ export default function useResumableSSE(
status: 'pending' as const,
createdAt: steer.createdAt ?? Date.now(),
...(steer.files && steer.files.length > 0 && { files: steer.files }),
...(steer.preempt === true && { preempt: true }),
...carriedSteerContext(chipById.get(steer.steerId)),
})),
...prev.filter((steer) => steer.status === 'failed'),

View file

@ -252,6 +252,7 @@ export default function useResumeOnLoad(
status: 'pending' as const,
createdAt: steer.createdAt ?? Date.now(),
...(steer.files && steer.files.length > 0 && { files: steer.files }),
...(steer.preempt === true && { preempt: true }),
...carriedSteerContext(chipById.get(steer.steerId)),
})),
...prev.filter((steer) => steer.status === 'failed'),

View file

@ -1296,6 +1296,9 @@
"com_ui_input": "Input",
"com_ui_instructions": "Instructions",
"com_ui_interrupt_send": "Interrupt & send",
"com_ui_interrupt_steer": "Interrupt & steer",
"com_ui_interrupt_steer_button": "Interrupt and steer the response",
"com_ui_interrupt_steer_desc": "Stops writing now and keeps what's written",
"com_ui_invalid_json": "Invalid JSON",
"com_ui_invocation_auto": "Auto",
"com_ui_invocation_auto_info": "The skill is automatically applied by the agent when relevant to the conversation",
@ -1915,6 +1918,9 @@
"com_ui_steer_edit_queued": "Your composer already has a draft, so that steering message was queued for after the response instead",
"com_ui_steer_failed": "Steering failed",
"com_ui_steer_in_flight": "Steering",
"com_ui_steer_in_flight_preempt": "Interrupting",
"com_ui_steer_interrupts_default": "Steering interrupts generation",
"com_ui_steer_interrupts_default_info": "When on, Enter stops the response at the next safe point instead of waiting for the agent's next tool step. Either way the partial answer is kept and the response continues.",
"com_ui_steer_paused_queued": "The agent is waiting for your review — your message was queued instead",
"com_ui_steer_retry": "Retry steering",
"com_ui_steer_run_ended_queued": "The response ended, so that steering message is queued as a follow-up",

View file

@ -308,10 +308,11 @@ const pendingQuotesByConvoId = atomFamily<string[], string>({
/**
* A steer message submitted mid-run. Server truth: `sending` covers the POST
* in flight, `pending` means the server queued it (awaiting a tool-batch
* boundary), `failed` keeps the text recoverable after a rejected POST. The
* chip disappears when `on_steer_applied` lands (the inline content part
* becomes the durable record).
* in flight, `pending` means the server queued it (awaiting its injection
* boundary the next tool batch, or the next safe token boundary when
* `preempt` was armed), `failed` keeps the text recoverable after a rejected
* POST. The chip disappears when `on_steer_applied` lands (the inline content
* part becomes the durable record).
*/
export type PendingSteer = {
steerId: string;
@ -325,6 +326,10 @@ export type PendingSteer = {
quotes?: string[];
/** Manual skill picks carried the same way as `quotes`. */
manualSkills?: string[];
/** Asked the run to seal generation at the next safe boundary rather than
* wait for a tool step. Labelling only the server owns the behaviour and
* echoes what it actually armed. */
preempt?: boolean;
};
/**

View file

@ -35,6 +35,14 @@ const localStorageAtoms = {
'duringRunDefaultAction',
'steer',
),
/**
* Whether a steer interrupts generation at the next safe boundary instead of
* waiting for the run's next tool step. Orthogonal to
* `duringRunDefaultAction`: that chooses steer-vs-queue, this chooses how
* soon a steer lands. The composer's interrupt button always interrupts
* regardless this only governs the default Enter/steer route.
*/
steerInterruptsByDefault: atomWithLocalStorage('steerInterruptsByDefault', false),
maximizeChatSpace: atomWithLocalStorage('maximizeChatSpace', false),
chatDirection: atomWithLocalStorage('chatDirection', 'LTR'),
autoExpandTools: atomWithLocalStorage(LocalStorageKeys.AUTO_EXPAND_TOOLS, false),