From 22018b8a014817a8f9bcdb6969168ede1c5e4091 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:39:06 +0200 Subject: [PATCH] feat: restore interrupt escalation on the waiting-message surfaces The rebase onto dev dropped the four steering components dev had just added escalation to, so the arm endpoint, the escalateSteer shortcut and its translation keys all survived with nothing to act on: the shortcut resolved and found no target. Port the feature onto the surfaces that replaced those components rather than reinstating them. The arm logic moves into useSteerEscalate, so the queue rail and the in-thread pending steers escalate through identical race rules instead of two approximations: one idempotent op flips preempt on the existing queued item, a lost response is retried once, and every "too late" interleaving stays an honest armed: false. EscalateNowButton carries the shared shortcut wiring, so hovering or focusing a row aims escalateSteer at that exact message. Both surfaces enforce the single-interrupt invariant through escalatingSteerFamily, which covers an arm's round trip before its own chip can report preempt. The queued row keeps the control visible-but-disabled while paused on an approval, which is when cutting the reply short is most wanted. The in-thread row offers it only on an acknowledged steer: one still sending has no server id to arm, and one already interrupting has nothing left to escalate. Dev's "always interrupt instead" overflow toggle is deliberately not restored. It is a global preference, and the redesign moved those to Settings, where "Steering interrupts generation" already lives; the row keeps only what acts on that message. --- .../components/Chat/Input/Composer/Queue.tsx | 39 +++- .../Input/Composer/__tests__/Queue.spec.tsx | 44 ++++- .../Chat/Input/EscalateNowButton.tsx | 144 ++++++++++++++ .../Messages/Content/Parts/PendingSteers.tsx | 47 ++++- .../Parts/__tests__/PendingSteers.test.tsx | 68 ++++++- client/src/hooks/Chat/useSteerEscalate.ts | 175 ++++++++++++++++++ client/src/hooks/Chat/useSteering.ts | 7 +- client/src/locales/en/translation.json | 4 + client/src/store/steer.ts | 11 ++ 9 files changed, 528 insertions(+), 11 deletions(-) create mode 100644 client/src/components/Chat/Input/EscalateNowButton.tsx create mode 100644 client/src/hooks/Chat/useSteerEscalate.ts create mode 100644 client/src/store/steer.ts diff --git a/client/src/components/Chat/Input/Composer/Queue.tsx b/client/src/components/Chat/Input/Composer/Queue.tsx index bf11c313d6..028b440073 100644 --- a/client/src/components/Chat/Input/Composer/Queue.tsx +++ b/client/src/components/Chat/Input/Composer/Queue.tsx @@ -1,4 +1,5 @@ import { memo, useRef, useMemo, useState, useCallback } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useDrag, useDrop } from 'react-dnd'; import { X, Pencil, GripVertical } from 'lucide-react'; @@ -6,6 +7,8 @@ import { useMediaQuery, useToastContext } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { QueuedMessage } from '~/store/families'; +import EscalateNowButton from '~/components/Chat/Input/EscalateNowButton'; +import { escalatingSteerFamily } from '~/store/steer'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -55,6 +58,8 @@ interface QueueRowProps { order: string[]; steering: SteeringControls; conversationId: string; + /** One interrupt at a time: an arm is already unresolved somewhere. */ + interruptPending: boolean; onEditToComposer: QueueProps['onEditToComposer']; onRestoreToComposer: RestoreToComposer; onAnnounce: (message: string) => void; @@ -67,6 +72,7 @@ function QueueRow({ order, steering, conversationId, + interruptPending, onEditToComposer, onRestoreToComposer, onAnnounce, @@ -141,6 +147,14 @@ function QueueRow({ * accepting input) nor send (a run is still active), so it would just * re-queue the message with nothing visible happening. */ const sendDisabled = steering.duringRunActive && !steering.canSteer; + /* A recovered item is consumed atomically only when it starts a normal + generation. Escalating it would leave or duplicate the parked source. */ + const isRecovered = message.recoverySteerId != null; + /** `canSteer` is false while paused on approval, but the escalation control + * stays visible-and-disabled there: hiding it during the pause is exactly + * the discoverability gap this button closes. */ + const showEscalate = + !isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer)); return (
{localize('com_ui_send_now')} + {showEscalate && ( + steering.sendQueuedNow(message, { preempt: true })} + /> + )} + } + /> + + {chord && isActive ? `${label} · ${chord}` : label} + + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx b/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx index 3e22f86837..8d4dbe100d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx @@ -1,6 +1,12 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; +import EscalateNowButton from '~/components/Chat/Input/EscalateNowButton'; +import { hasLiveToolApproval } from '~/hooks/Chat/useSteering'; +import useSteerEscalate from '~/hooks/Chat/useSteerEscalate'; import useSteerRecovery from '~/hooks/Chat/useSteerRecovery'; +import { useGetMessagesByConvoId } from '~/data-provider'; +import { escalatingSteerFamily } from '~/store/steer'; import { useLocalize } from '~/hooks'; import SteerPart from './SteerPart'; import { cn } from '~/utils'; @@ -24,6 +30,22 @@ function PendingSteers({ conversationId }: PendingSteersProps) { const localize = useLocalize(); const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId)); const { retry, sendAsNew } = useSteerRecovery(conversationId); + const escalate = useSteerEscalate(conversationId); + const escalating = useAtomValue(escalatingSteerFamily(conversationId)); + /* Reads the cache the composer already populates, so the escalation control + is gated on the same pause the composer sees rather than round-tripping to + discover the run cannot accept an arm. Boolean `select` for the same reason + the composer uses one: streaming deltas must not re-render this row. */ + const { data: paused } = useGetMessagesByConvoId(conversationId, { + select: hasLiveToolApproval, + }); + /* Only one interrupt can be unresolved at a time: a second arm would seal the + same run twice. The flag covers an arm's round trip, before its own chip + can report `preempt`. */ + const interruptPending = useMemo( + () => escalating || steers.some((steer) => steer.preempt === true && steer.status !== 'failed'), + [escalating, steers], + ); if (steers.length === 0) { return null; @@ -58,8 +80,27 @@ function PendingSteers({ conversationId }: PendingSteersProps) {
) : ( -
- {localize('com_ui_sending')} +
+ + {localize( + steer.preempt === true ? 'com_ui_steer_in_flight_preempt' : 'com_ui_sending', + )} + + {/* Only a `pending` steer can be armed: `sending` has no server id + yet, and one already interrupting has nothing left to escalate. */} + {steer.status === 'pending' && steer.preempt !== true && ( + + escalate({ + steerId: steer.steerId, + generationCreatedAt: steer.generationCreatedAt, + }) + } + /> + )}
)}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx index 32bda0e51f..12a208adb8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx @@ -1,17 +1,25 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; +import { MemoryRouter } from 'react-router-dom'; import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PendingSteer } from '~/store/families'; import PendingSteers from '../PendingSteers'; import store from '~/store'; const mockRetry = jest.fn(); const mockSendAsNew = jest.fn(); +const mockEscalate = jest.fn(); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); +jest.mock('~/hooks/Chat/useSteerEscalate', () => ({ + __esModule: true, + default: () => mockEscalate, +})); + jest.mock('~/hooks/Chat/useSteerRecovery', () => ({ __esModule: true, default: () => ({ retry: mockRetry, sendAsNew: mockSendAsNew }), @@ -33,10 +41,22 @@ const pending = (over: Partial = {}): PendingSteer => ({ }); function renderPending(steers: PendingSteer[]) { + /* The escalation control reads the message cache to gate on an approval + pause, which is route-scoped — the same providers the chat view supplies + around this row in the app. */ + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); return render( - set(store.pendingSteersByConvoId(CONVO_ID), steers)}> - - , + + + set(store.pendingSteersByConvoId(CONVO_ID), steers)} + > + + + + , ); } @@ -76,4 +96,46 @@ describe('PendingSteers', () => { expect(mockSendAsNew).toHaveBeenCalledWith('s-failed'); expect(mockRetry).not.toHaveBeenCalled(); }); + + /* The escalation control is the in-thread half of the `escalateSteer` + shortcut: it only exists on a steer the server can still be asked to + interrupt, so the shortcut never aims at a row that cannot act. */ + describe('interrupt escalation', () => { + it('offers escalation on an acknowledged steer', () => { + renderPending([pending({ status: 'pending', steerId: 's-ack' })]); + expect(screen.getByTestId('steer-escalate-now')).toBeEnabled(); + }); + + it('arms the steer by id, carrying its own generation', () => { + renderPending([pending({ status: 'pending', steerId: 's-ack', generationCreatedAt: 4141 })]); + fireEvent.click(screen.getByTestId('steer-escalate-now')); + expect(mockEscalate).toHaveBeenCalledWith({ + steerId: 's-ack', + generationCreatedAt: 4141, + }); + }); + + it.each([ + ['still sending, so it has no server id to arm', { status: 'sending' as const }], + ['already interrupting', { status: 'pending' as const, preempt: true }], + ['failed, where retry is the offer instead', { status: 'failed' as const }], + ])('offers nothing on a steer that is %s', (_label, over) => { + renderPending([pending(over)]); + expect(screen.queryByTestId('steer-escalate-now')).not.toBeInTheDocument(); + }); + + /* One interrupt at a time: a second arm would seal the same run twice. */ + it('disables escalation while another steer is already interrupting', () => { + renderPending([ + pending({ status: 'pending', steerId: 's-arming', preempt: true }), + pending({ status: 'pending', steerId: 's-other' }), + ]); + expect(screen.getByTestId('steer-escalate-now')).toBeDisabled(); + }); + + it('labels a steer that is already interrupting', () => { + renderPending([pending({ status: 'pending', preempt: true })]); + expect(screen.getByText('com_ui_steer_in_flight_preempt')).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/hooks/Chat/useSteerEscalate.ts b/client/src/hooks/Chat/useSteerEscalate.ts new file mode 100644 index 0000000000..f830228d74 --- /dev/null +++ b/client/src/hooks/Chat/useSteerEscalate.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; +import { useToastContext } from '@librechat/client'; +import { useRecoilValue, useRecoilCallback } from 'recoil'; +import { supportsGenerationProtocolV2, useArmSteerMutation } from '~/data-provider'; +import { escalatingSteerFamily } from '~/store/steer'; +import useLocalize from '~/hooks/useLocalize'; +import store from '~/store'; + +/** Axios has no default request timeout. Bound the UI lock while preserving an + * honest unknown outcome; the idempotent arm may still complete server-side. */ +const ARM_CONFIRM_TIMEOUT_MS = 10_000; + +type ArmFailure = { + name?: string; + response?: { data?: { code?: string } }; +}; + +/** Only a failure without an HTTP response leaves the server-side outcome + * unknown. An HTTP rejection is a known response and must not replay the arm. */ +const isAmbiguousArmFailure = (error: unknown): boolean => { + const failure = error as ArmFailure | null | undefined; + return failure?.name !== 'AbortError' && failure?.response == null; +}; + +const armFailureCode = (error: unknown): string | undefined => + (error as ArmFailure | null | undefined)?.response?.data?.code; + +export interface EscalateTarget { + steerId: string; + /** The generation that owns this steer receipt; falls back to the active one. */ + generationCreatedAt?: number; +} + +/** + * Escalates a waiting steer to an interrupt: one idempotent 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. A transport + * failure is retried once because the first request may have committed even + * though its response was lost. 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. + * + * Extracted from the surfaces so the queue rail and the in-thread pending + * steers escalate through identical race rules rather than two approximations. + * `onArmed` lets a caller move focus off a control the success state removes. + */ +export default function useSteerEscalate(conversationId: string) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { mutateAsync: armSteer } = useArmSteerMutation(); + const setEscalating = useSetAtom(escalatingSteerFamily(conversationId)); + const activeGenerationCreatedAt = useRecoilValue( + store.activeGenerationCreatedAtByConvoId(conversationId), + ); + const activeGenerationProtocolVersion = useRecoilValue( + store.activeGenerationProtocolVersionByConvoId(conversationId), + ); + + /** Relabels the chip in place once the server confirms the durable arm — + * same steerId, same position, only the `preempt` flag flips. */ + const markSteerPreempt = useRecoilCallback( + ({ set }) => + (steerId: string, revision: number) => + set(store.pendingSteersByConvoId(conversationId), (prev) => + prev.map((item) => + item.steerId === steerId && revision >= (item.preemptRevision ?? 0) + ? { ...item, preempt: true, preemptRevision: revision } + : item, + ), + ), + [conversationId], + ); + + return useCallback( + (target: EscalateTarget, onArmed?: () => void) => { + const generationCreatedAt = target.generationCreatedAt ?? activeGenerationCreatedAt; + if (generationCreatedAt == null) { + return; + } + setEscalating(true); + const params = { conversationId, steerId: target.steerId, generationCreatedAt }; + const requestArm = async () => { + let firstResponseWasLost = false; + let acceptingRetry = true; + let timeout: ReturnType | undefined; + try { + const firstAttempt = armSteer(params); + const attempts = + activeGenerationProtocolVersion === 2 + ? firstAttempt.catch((error) => { + /** If the overall confirmation window already closed, do not let a + * very late rejection launch a detached retry behind the user's + * back. The first request itself may still have committed. */ + if (!acceptingRetry) { + throw error; + } + if (!isAmbiguousArmFailure(error)) { + throw error; + } + firstResponseWasLost = true; + return armSteer(params); + }) + : firstAttempt; + const response = await Promise.race([ + attempts, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('Steer arm confirmation timed out')), + ARM_CONFIRM_TIMEOUT_MS, + ); + }), + ]); + const responseSupportsNegotiatedProtocol = + activeGenerationProtocolVersion === 1 || supportsGenerationProtocolV2(response); + if (responseSupportsNegotiatedProtocol && response.armed === true) { + markSteerPreempt(target.steerId, response.preemptRevision ?? 0); + onArmed?.(); + return; + } + if (!responseSupportsNegotiatedProtocol) { + showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' }); + return; + } + /** Once a response was lost, a later `armed: false` cannot prove the + * first request did not commit: the steer may have drained or the job + * may have paused between attempts. Keep the chip event-driven and + * report the result as unknown instead of claiming a lost race. */ + if (firstResponseWasLost) { + showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' }); + 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', + }); + } catch (error) { + if (isAmbiguousArmFailure(error)) { + showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' }); + return; + } + showToast({ + message: localize( + armFailureCode(error) === 'PREEMPT_UNSUPPORTED' + ? 'com_ui_steer_preempt_unsupported' + : 'com_ui_steer_arm_lost_race', + ), + status: 'info', + }); + } finally { + acceptingRetry = false; + clearTimeout(timeout); + } + }; + void requestArm().finally(() => setEscalating(false)); + }, + [ + armSteer, + conversationId, + activeGenerationCreatedAt, + activeGenerationProtocolVersion, + setEscalating, + markSteerPreempt, + showToast, + localize, + ], + ); +} diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts index 9a8b05c291..46d72b9850 100644 --- a/client/src/hooks/Chat/useSteering.ts +++ b/client/src/hooks/Chat/useSteering.ts @@ -142,8 +142,11 @@ export function resolveAcknowledgedSteer( } /** True when the latest assistant message carries an unresolved tool approval — - * the run is (or is about to be) paused, so a steer POST would 409. */ -function hasLiveToolApproval(messages: TMessage[] | undefined): boolean { + * the run is (or is about to be) paused, so a steer POST would 409. + * + * Exported so the in-thread pending steers gate their escalation control on + * the same predicate the composer does, rather than a second approximation. */ +export function hasLiveToolApproval(messages: TMessage[] | undefined): boolean { if (!messages || messages.length === 0) { return false; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index d9b2de81d8..a9d144814d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1959,14 +1959,18 @@ "com_ui_stateful_sessions": "Stateful code sessions", "com_ui_status_prefix": "Status:", "com_ui_steer": "Steer", + "com_ui_steer_arm_lost_race": "The interrupt could not be armed. The steering message may already have moved on or may still be waiting for the next tool step", + "com_ui_steer_arm_unconfirmed": "Couldn't confirm whether the interrupt was armed. The steering message may interrupt or may wait for the next tool step", "com_ui_steer_cancel_failed": "Could not cancel the steering message — it may still reach the agent", "com_ui_steer_failed": "Steering failed", "com_ui_steer_failed_inline": "Couldn't add to this reply", "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_interrupts_enable_info": "Switches the during-run default to steering and stops the response at the next safe point. The partial answer is kept and the response continues.", "com_ui_steer_paused_queued": "The assistant is waiting for your review, so your message was queued", + "com_ui_steer_preempt_unsupported": "This deployment can't interrupt mid-response, so that steering message stays queued for the next tool step", "com_ui_steer_send": "Steer the current response", "com_ui_steered_info": "You added this message while the response was generating, so it was inserted into the response at this point.", "com_ui_stop": "Stop", diff --git a/client/src/store/steer.ts b/client/src/store/steer.ts new file mode 100644 index 0000000000..4671c90094 --- /dev/null +++ b/client/src/store/steer.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +/** + * Set synchronously before a bubble's arm request and cleared on settlement. + * Purely a UX gate: with the atomic in-place arm, a double-arm is harmless + * server-side (the run seals once and drains the whole queue in order), but + * every escalation control advertises "one interrupt at a time" by disabling, + * and the chip-derived check cannot see an arm until its response lands. + */ +export const escalatingSteerFamily = atomFamily((_conversationId: string) => atom(false));