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 (
+
+
+
+
+ }
+ />
+
+ {localize('com_ui_interrupt_steer_desc')}
+
+
+ );
+});
+
+InterruptSteerButton.displayName = 'InterruptSteerButton';
+
+export default InterruptSteerButton;
diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx
index 7e4503ac2a..34d98324aa 100644
--- a/client/src/components/Chat/Input/PendingSteerChips.tsx
+++ b/client/src/components/Chat/Input/PendingSteerChips.tsx
@@ -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 },
+ )
}
>
diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx
index 3b66289a05..c572ef58ce 100644
--- a/client/src/components/Nav/Settings/registry.tsx
+++ b/client/src/components/Nav/Settings/registry.tsx
@@ -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,
diff --git a/client/src/data-provider/SSE/mutations.ts b/client/src/data-provider/SSE/mutations.ts
index 8b8aeb8e65..0f6b3d0afd 100644
--- a/client/src/data-provider/SSE/mutations.ts
+++ b/client/src/data-provider/SSE/mutations.ts
@@ -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).
diff --git a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
index 43099a68e3..88073ceaea 100644
--- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
@@ -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 = {},
diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts
index a0ad175c5a..0799f0a787 100644
--- a/client/src/hooks/Chat/useSteering.ts
+++ b/client/src/hooks/Chat/useSteering.ts
@@ -124,6 +124,7 @@ export default function useSteering({
const { mutate: markFilesUsage } = useMarkFilesUsageMutation();
const defaultAction = useRecoilValue
(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,
],
);
}
diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts
index 3a8ad42f0b..04f93c6bf6 100644
--- a/client/src/hooks/Input/useTextarea.ts
+++ b/client/src/hooks/Input/useTextarea.ts
@@ -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".
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 0701c35135..472b705920 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -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'),
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 131973ff21..f0df858e32 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -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'),
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 5ca95632c6..022cdfefbb 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/store/families.ts b/client/src/store/families.ts
index b22f970afc..63bc32dda2 100644
--- a/client/src/store/families.ts
+++ b/client/src/store/families.ts
@@ -308,10 +308,11 @@ const pendingQuotesByConvoId = atomFamily({
/**
* 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;
};
/**
diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts
index fbb0b37224..9e60b9b418 100644
--- a/client/src/store/settings.ts
+++ b/client/src/store/settings.ts
@@ -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),