diff --git a/api/server/controllers/agents/steer.js b/api/server/controllers/agents/steer.js index baa86de010..2b6cb24a6c 100644 --- a/api/server/controllers/agents/steer.js +++ b/api/server/controllers/agents/steer.js @@ -1,4 +1,9 @@ -const { checkAccess, handleSteerRequest, handleSteerCancel } = require('@librechat/api'); +const { + checkAccess, + handleSteerRequest, + handleSteerCancel, + handleSteerArm, +} = require('@librechat/api'); const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas'); const { Permissions, @@ -107,5 +112,25 @@ const SteerCancelController = async (req, res) => { } }; +/** + * POST /api/agents/chat/steer/arm + * + * Escalates a still-queued steer to an interrupt in place (the durable item + * keeps its FIFO position). `armed: false` is not an error — the steer + * already injected, was cancelled, or the deployment cannot seal mid-stream. + * No agent-access check: arming injects nothing model-bound, so ownership + * checks suffice, exactly like cancel. + */ +const SteerArmController = async (req, res) => { + try { + const { status, body } = await handleSteerArm(req.user ?? {}, req.body ?? {}); + return res.status(status).json(body); + } catch (error) { + logger.error('[SteerArmController] Failed to arm steer', error); + return res.status(500).json({ code: 'STEER_ARM_FAILED' }); + } +}; + module.exports = SteerController; module.exports.SteerCancelController = SteerCancelController; +module.exports.SteerArmController = SteerArmController; diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 217c94203b..5382b7c457 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -474,6 +474,19 @@ router.post( SteerController.SteerCancelController, ); +/** + * @route POST /chat/steer/arm + * @desc Escalate a still-queued steer to an interrupt in place (no new + * model-bound content, so no PII/moderation pass — just the shared limiters) + * @access Private + */ +router.post( + '/chat/steer/arm', + configMiddleware, + ...steerLimiters, + SteerController.SteerArmController, +); + router.use('/', v1); const chatRouter = express.Router(); diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 7e84c7001f..b71db39971 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -1,5 +1,5 @@ import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react'; -import { useSetAtom, useAtomValue } from 'jotai'; +import { useSetAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; import { useRecoilValue, useRecoilCallback } from 'recoil'; import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react'; @@ -9,11 +9,12 @@ import type { PendingSteer } from '~/store/families'; import type { MenuEntry } from './SteerMenu'; import { RowMenu, 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'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; +import { steerOverlayHeightFamily } from '~/store/steer'; +import { useArmSteerMutation } from '~/data-provider'; import { carriedSteerContext, cn } from '~/utils'; import store from '~/store'; @@ -130,27 +131,17 @@ const InFlightSteer = memo(function InFlightSteer({ [conversationId], ); - /** Fresh read at resubmit time: an interrupt armed while this escalation's - * reclaim was in flight (another bubble, a queued row, the composer chord) - * means resubmitting would break the one-interrupt invariant. */ - const hasUnresolvedInterrupt = useRecoilCallback( - ({ snapshot }) => - () => - snapshot - .getLoadable(store.pendingSteersByConvoId(conversationId)) - .getValue() - .some((item) => item.preempt === true && item.status !== 'failed'), + /** 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) => + set(store.pendingSteersByConvoId(conversationId), (prev) => + prev.map((item) => (item.steerId === steerId ? { ...item, preempt: true } : item)), + ), [conversationId], ); - const setEscalating = useSetAtom(escalatingSteerFamily(conversationId)); - - /** Latest controls for the escalation's async continuation: the `.then` - * closure otherwise holds the render's stale `steering`, blind to a run - * that paused (approval, answer mode) while the reclaim was in flight. */ - const steeringRef = useRef(steering); - useEffect(() => { - steeringRef.current = steering; - }); + const { mutateAsync: armSteer } = useArmSteerMutation(); /** * Takes the steer back off the server queue so its words can be re-homed. @@ -235,54 +226,40 @@ const InFlightSteer = memo(function InFlightSteer({ key: 'interrupt', label: localize('com_ui_interrupt_steer_now'), icon: