diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index e087f39aad..8de9161ab4 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 } from 'jotai'; +import { useSetAtom, useAtomValue } 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,11 @@ 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'; @@ -142,6 +142,7 @@ const InFlightSteer = memo(function InFlightSteer({ [conversationId], ); const { mutateAsync: armSteer } = useArmSteerMutation(); + const setEscalating = useSetAtom(escalatingSteerFamily(conversationId)); /** * Takes the steer back off the server queue so its words can be re-homed. @@ -238,32 +239,38 @@ const InFlightSteer = memo(function InFlightSteer({ * `armed: false`, and the chip is only relabelled on a confirmed * durable arm. */ onClick: () => { - void armSteer({ conversationId, steerId: steer.steerId }).then( - (response) => { - if (response.armed === true) { - markSteerPreempt(steer.steerId); - 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 whatever actually happened. */ - showToast({ - message: localize( - response.code === 'PREEMPT_UNSUPPORTED' - ? 'com_ui_steer_preempt_unsupported' - : 'com_ui_steer_arm_lost_race', - ), - status: 'info', - }); - }, - () => { - showToast({ - message: localize('com_ui_steer_arm_failed'), - status: 'error', - }); - }, - ); + /* Synchronously, before the request: the chip-derived gate + * cannot see this arm until the response lands, and the other + * escalation controls advertise "one interrupt at a time". */ + setEscalating(true); + void armSteer({ conversationId, steerId: steer.steerId }) + .then( + (response) => { + if (response.armed === true) { + markSteerPreempt(steer.steerId); + 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 whatever actually happened. */ + showToast({ + message: localize( + response.code === 'PREEMPT_UNSUPPORTED' + ? 'com_ui_steer_preempt_unsupported' + : 'com_ui_steer_arm_lost_race', + ), + status: 'info', + }); + }, + () => { + showToast({ + message: localize('com_ui_steer_arm_failed'), + status: 'error', + }); + }, + ) + .finally(() => setEscalating(false)); }, } satisfies MenuEntry, ]), @@ -448,10 +455,13 @@ const InFlightSteers = memo(function InFlightSteers({ const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId)); const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]); /** Mirrors `PendingSteerChips`: while one interrupt is unresolved, every - * other escalation control disables rather than arming a second seal. */ + * other escalation control disables rather than arming a second seal. The + * escalating flag covers an arm request's round trip, before its chip + * relabels for the chip-derived check to see. */ + const escalating = useAtomValue(escalatingSteerFamily(conversationId)); const interruptPending = useMemo( - () => inFlight.some((steer) => steer.preempt === true), - [inFlight], + () => escalating || inFlight.some((steer) => steer.preempt === true), + [escalating, inFlight], ); const setOverlayHeight = useSetAtom(steerOverlayHeightFamily(conversationId)); diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx index 0f7456e4c7..057423264a 100644 --- a/client/src/components/Chat/Input/PendingSteerChips.tsx +++ b/client/src/components/Chat/Input/PendingSteerChips.tsx @@ -1,4 +1,5 @@ import { memo, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import * as Ariakit from '@ariakit/react'; import { X, Zap, Send, Clock, Pencil, Trash2, ZapOff, Paperclip, RotateCcw } from 'lucide-react'; @@ -15,6 +16,7 @@ import { useInterruptChordHint, useInterruptToggleEntry, } from './SteerMenu'; +import { escalatingSteerFamily } from '~/store/steer'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -289,10 +291,13 @@ function PendingSteerChips({ const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey)); const failedSteers = useMemo(() => steers.filter((steer) => steer.status === 'failed'), [steers]); /** Only one interrupt can be in flight: a second preempt while one is - * unresolved would arm a second seal, so escalation buttons disable. */ + * unresolved would arm a second seal, so escalation buttons disable. The + * escalating flag covers a bubble arm's round trip, before its chip + * relabels for the chip-derived check to see. */ + const escalating = useAtomValue(escalatingSteerFamily(conversationId)); const interruptPending = useMemo( - () => steers.some((steer) => steer.preempt === true && steer.status !== 'failed'), - [steers], + () => escalating || steers.some((steer) => steer.preempt === true && steer.status !== 'failed'), + [escalating, steers], ); if (failedSteers.length === 0 && queued.length === 0) { diff --git a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx index 8b4e9c91a8..011c79c4c9 100644 --- a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx +++ b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { getDefaultStore } from 'jotai'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, within, fireEvent, act } from '@testing-library/react'; import type { SteeringControls } from '~/hooks/Chat/useSteering'; import type { PendingSteer } from '~/store/families'; -import { steerOverlayHeightFamily } from '~/store/steer'; +import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer'; import InFlightSteers from '../InFlightSteers'; import store from '~/store'; @@ -599,9 +599,13 @@ describe('InFlightSteers', () => { describe('InFlightSteers — interrupt-now escalation', () => { beforeEach(() => { jest.clearAllMocks(); + mockArmMutateAsync.mockReset(); mockCancelMutateAsync.mockResolvedValue({ removed: true }); mockArmMutateAsync.mockResolvedValue({ armed: true }); mockRestoreToComposer.mockReturnValue(true); + act(() => { + getDefaultStore().set(escalatingSteerFamily(CONVO_ID), false); + }); }); it("arms the interrupt in place, keeping the steer's id and position", async () => { @@ -670,6 +674,43 @@ describe('InFlightSteers — interrupt-now escalation', () => { expect(screen.getByText('still queued')).toBeInTheDocument(); }); + it('locks every escalation control while an arm request is in flight', async () => { + /** The chip-derived gate cannot see an arm until its response lands, so + * the flag flips synchronously at click — otherwise two bubbles could + * both arm on a slow connection, belying the disabled controls. */ + let resolveArm: (value: { armed: boolean }) => void = () => {}; + mockArmMutateAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveArm = resolve; + }), + ); + renderSteers([ + { steerId: 's1', text: 'first', status: 'pending', createdAt: 1 }, + { steerId: 's2', text: 'second', status: 'pending', createdAt: 2 }, + ]); + /** Every bubble's menu content mounts (hidden) up front, so item lookups + * must scope to the menu the clicked button controls. */ + const menuFor = (button: HTMLElement) => + document.getElementById(button.getAttribute('aria-controls') ?? '') as HTMLElement; + const menus = screen.getAllByLabelText('com_ui_more_options'); + fireEvent.click(menus[0]); + fireEvent.click(await within(menuFor(menus[0])).findByText('com_ui_interrupt_steer_now')); + + fireEvent.click(menus[1]); + const secondItem = await within(menuFor(menus[1])).findByText('com_ui_interrupt_steer_now'); + expect(secondItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true'); + await act(async () => { + fireEvent.click(secondItem); + }); + + await act(async () => { + resolveArm({ armed: true }); + }); + expect(mockArmMutateAsync).toHaveBeenCalledTimes(1); + expect(mockArmMutateAsync).toHaveBeenCalledWith({ conversationId: CONVO_ID, steerId: 's1' }); + }); + it('disables escalation on every bubble while an interrupt is unresolved', async () => { renderSteers([ { steerId: 's1', text: 'plain steer', status: 'pending', createdAt: 1 }, diff --git a/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx index fc143e4874..dd9237d570 100644 --- a/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx +++ b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { getDefaultStore } from 'jotai'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import type { PendingSteer, QueuedMessage } from '~/store/families'; import type { SteeringControls } from '~/hooks/Chat/useSteering'; +import { escalatingSteerFamily } from '~/store/steer'; import PendingSteerChips from '../PendingSteerChips'; import store from '~/store'; @@ -134,6 +136,21 @@ describe('PendingSteerChips — queued interrupt-now', () => { expect(mockSendQueuedNow).not.toHaveBeenCalled(); }); + it('disables escalation while a bubble arm request is in flight', () => { + const jotai = getDefaultStore(); + act(() => { + jotai.set(escalatingSteerFamily(CONVO_ID), true); + }); + try { + renderChips([queuedMessage], { steering: liveRun }); + expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled(); + } finally { + act(() => { + jotai.set(escalatingSteerFamily(CONVO_ID), false); + }); + } + }); + it('stays visible but disabled while the run is paused on approval', () => { /** The real hook invariant: `canSteer = hasRealConvoId && !pausedOnApproval`, * so a paused run always reads `canSteer: false`. The control must remain diff --git a/client/src/store/steer.ts b/client/src/store/steer.ts index b3fd63c451..d0c152f483 100644 --- a/client/src/store/steer.ts +++ b/client/src/store/steer.ts @@ -11,3 +11,12 @@ import { atomFamily } from 'jotai/utils'; * each holds a single number per visited conversation. */ export const steerOverlayHeightFamily = atomFamily((_conversationId: string) => atom(0)); + +/** + * 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));