mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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.
This commit is contained in:
parent
7f418d149a
commit
22018b8a01
9 changed files with 528 additions and 11 deletions
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -214,6 +228,14 @@ function QueueRow({
|
|||
>
|
||||
{localize('com_ui_send_now')}
|
||||
</button>
|
||||
{showEscalate && (
|
||||
<EscalateNowButton
|
||||
surface="queued"
|
||||
messageText={message.text}
|
||||
disabled={steering.pausedOnApproval || interruptPending}
|
||||
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_edit_message')}
|
||||
|
|
@ -273,9 +295,10 @@ function QueueRow({
|
|||
|
||||
/**
|
||||
* Messages waiting for the current reply to finish, as a rail tucked behind
|
||||
* the composer's top edge. One row per message, three visible actions and no
|
||||
* the composer's top edge. One row per message, its actions all visible and no
|
||||
* overflow menu: the menu is where the old design hid a global preference
|
||||
* among item actions.
|
||||
* among item actions. That preference ("steering interrupts generation") lives
|
||||
* in Settings now; what stays on the row is only what acts on THAT message.
|
||||
*
|
||||
* The rail is also the running order: whatever sits at the top is what gets
|
||||
* sent when the reply lands, so rows can be dragged past one another by the
|
||||
|
|
@ -291,6 +314,17 @@ function QueueRow({
|
|||
function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer }: QueueProps) {
|
||||
const localize = useLocalize();
|
||||
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
|
||||
const pendingSteers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
|
||||
const escalating = useAtomValue(escalatingSteerFamily(conversationId));
|
||||
/* Only one interrupt can be unresolved at a time: a second arm would seal the
|
||||
same run twice. The escalating flag covers an arm's round trip, before its
|
||||
chip can show `preempt`. */
|
||||
const interruptPending = useMemo(
|
||||
() =>
|
||||
escalating ||
|
||||
pendingSteers.some((steer) => steer.preempt === true && steer.status !== 'failed'),
|
||||
[escalating, pendingSteers],
|
||||
);
|
||||
/* Spoken only for the keys. A drag reorders on every crossing, and a reader
|
||||
narrating each one would be behind the pointer and in the way of it. */
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
|
@ -332,6 +366,7 @@ function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer
|
|||
order={order}
|
||||
steering={steering}
|
||||
conversationId={conversationId}
|
||||
interruptPending={interruptPending}
|
||||
onEditToComposer={onEditToComposer}
|
||||
onRestoreToComposer={onRestoreToComposer}
|
||||
onAnnounce={setAnnouncement}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ const steeringWith = (over: Partial<SteeringControls> = {}): SteeringControls =>
|
|||
|
||||
const steering = steeringWith();
|
||||
const pausedSteering = steeringWith({ canSteer: false });
|
||||
/** Paused on a tool approval: steering is unavailable, but the escalation
|
||||
* control stays visible-and-disabled rather than vanishing mid-pause. */
|
||||
const approvalPausedSteering = steeringWith({ canSteer: false, pausedOnApproval: true });
|
||||
|
||||
const queued = (over: Partial<QueuedMessage> = {}): QueuedMessage =>
|
||||
({
|
||||
|
|
@ -88,13 +91,14 @@ describe('Queue', () => {
|
|||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders a row per queued message with three visible actions', () => {
|
||||
it('renders a row per queued message with every action visible, none behind a menu', () => {
|
||||
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2' })]);
|
||||
const rows = screen.getAllByTestId('queued-message-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const firstRow = within(rows[0]);
|
||||
expect(firstRow.getByText('com_ui_send_now')).toBeInTheDocument();
|
||||
expect(firstRow.getByTestId('queued-interrupt-now')).toBeInTheDocument();
|
||||
expect(firstRow.getByLabelText('com_ui_edit_message')).toBeInTheDocument();
|
||||
expect(firstRow.getByLabelText('com_ui_remove_queued')).toBeInTheDocument();
|
||||
expect(firstRow.queryByLabelText('com_ui_more_options')).not.toBeInTheDocument();
|
||||
|
|
@ -333,4 +337,42 @@ describe('Queue', () => {
|
|||
expect(attachmentLabel.parentElement).not.toHaveAttribute('aria-label');
|
||||
expect(screen.getByText('com_ui_queued_attachment_count:2')).toHaveClass('sr-only');
|
||||
});
|
||||
|
||||
/* Escalation is the only way to make a waiting message interrupt the reply
|
||||
rather than wait for its next tool step. Send now sends it as an ordinary
|
||||
steer; this sends it as an interrupt. */
|
||||
describe('interrupt escalation', () => {
|
||||
it('escalates the row that was clicked, as a preempt', () => {
|
||||
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2', text: 'the second one' })]);
|
||||
fireEvent.click(screen.getAllByTestId('queued-interrupt-now')[1]);
|
||||
expect(mockSendQueuedNow).toHaveBeenCalledWith(expect.objectContaining({ id: 'q2' }), {
|
||||
preempt: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves Send now as an ordinary steer', () => {
|
||||
renderQueue([queued({ id: 'q1' })]);
|
||||
fireEvent.click(screen.getByText('com_ui_send_now'));
|
||||
expect(mockSendQueuedNow).toHaveBeenCalledWith(expect.objectContaining({ id: 'q1' }));
|
||||
});
|
||||
|
||||
/* Hiding it during the pause is the discoverability gap this button
|
||||
closes: the pause is exactly when a user wants to cut the reply short. */
|
||||
it('stays visible but disabled while paused on an approval', () => {
|
||||
renderQueue([queued()], approvalPausedSteering);
|
||||
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers nothing once the run is over', () => {
|
||||
renderQueue([queued()], steeringWith({ duringRunActive: false, canSteer: false }));
|
||||
expect(screen.queryByTestId('queued-interrupt-now')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* A recovered row is consumed atomically only by a normal generation, so
|
||||
escalating it would leave or duplicate its parked server copy. */
|
||||
it('offers nothing on a recovered row', () => {
|
||||
renderQueue([queued({ recoverySteerId: 'srv-1' })]);
|
||||
expect(screen.queryByTestId('queued-interrupt-now')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
144
client/src/components/Chat/Input/EscalateNowButton.tsx
Normal file
144
client/src/components/Chat/Input/EscalateNowButton.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useEffect, useId, useSyncExternalStore } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** Longest message excerpt spoken as part of the button's accessible name;
|
||||
* past this the row is identified by its opening words rather than read out. */
|
||||
const MESSAGE_LABEL_MAX_LENGTH = 80;
|
||||
|
||||
/* Which escalation control the keyboard shortcut acts on. Module state rather
|
||||
than context: the shortcut handler lives at the document level and only ever
|
||||
needs the single hovered/focused target, so every button subscribing to one
|
||||
store is cheaper than a provider spanning both surfaces. Focus wins over
|
||||
hover, matching what a keyboard user is actually pointed at. */
|
||||
const listeners = new Set<() => void>();
|
||||
let hoveredTarget: string | null = null;
|
||||
let focusedTarget: string | null = null;
|
||||
|
||||
function getActiveTarget() {
|
||||
return focusedTarget ?? hoveredTarget;
|
||||
}
|
||||
|
||||
function subscribeToActiveTarget(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function updateActiveTarget(kind: 'hover' | 'focus', targetId: string, active: boolean) {
|
||||
const previous = getActiveTarget();
|
||||
if (kind === 'hover') {
|
||||
if (active) {
|
||||
hoveredTarget = targetId;
|
||||
} else if (hoveredTarget === targetId) {
|
||||
hoveredTarget = null;
|
||||
}
|
||||
} else if (active) {
|
||||
focusedTarget = targetId;
|
||||
} else if (focusedTarget === targetId) {
|
||||
focusedTarget = null;
|
||||
}
|
||||
if (previous !== getActiveTarget()) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveTarget(targetId: string) {
|
||||
const previous = getActiveTarget();
|
||||
if (hoveredTarget === targetId) {
|
||||
hoveredTarget = null;
|
||||
}
|
||||
if (focusedTarget === targetId) {
|
||||
focusedTarget = null;
|
||||
}
|
||||
if (previous !== getActiveTarget()) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
interface EscalateNowButtonProps {
|
||||
/** Which waiting surface this row belongs to; the `escalateSteer` shortcut
|
||||
* prefers a bubble over a queued row when nothing is hovered or focused. */
|
||||
surface: 'bubble' | 'queued';
|
||||
disabled: boolean;
|
||||
messageText: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The escalation control on a waiting message: interrupt & steer it now, at
|
||||
* the next safe token boundary. The tooltip teaches this action's OWN shortcut
|
||||
* (registry-aware, so a rebinding shows correctly). Hover/focus marks this
|
||||
* exact button as the shortcut's target; with no active row the shortcut keeps
|
||||
* its newest-waiting-message fallback.
|
||||
*/
|
||||
export default function EscalateNowButton({
|
||||
surface,
|
||||
disabled,
|
||||
messageText,
|
||||
onClick,
|
||||
}: EscalateNowButtonProps) {
|
||||
const localize = useLocalize();
|
||||
const chord = useShortcutDisplay('escalateSteer');
|
||||
const ariaKey = useShortcutAriaKey('escalateSteer');
|
||||
const targetId = useId();
|
||||
const activeTarget = useSyncExternalStore(subscribeToActiveTarget, getActiveTarget, () => null);
|
||||
const isActive = !disabled && activeTarget === targetId;
|
||||
const label = localize('com_ui_interrupt_steer_now');
|
||||
const normalized = messageText.trim().replace(/\s+/g, ' ');
|
||||
const characters = Array.from(normalized);
|
||||
const excerpt =
|
||||
characters.length > MESSAGE_LABEL_MAX_LENGTH
|
||||
? `${characters
|
||||
.slice(0, MESSAGE_LABEL_MAX_LENGTH - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`
|
||||
: normalized;
|
||||
const accessibleLabel = excerpt.length > 0 ? `${label}: ${excerpt}` : label;
|
||||
|
||||
/* A disabled button keeps neither hover nor focus, so a row that locks while
|
||||
the pointer rests on it would otherwise leave the shortcut aimed at a
|
||||
control that can no longer run. */
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
clearActiveTarget(targetId);
|
||||
}
|
||||
return () => clearActiveTarget(targetId);
|
||||
}, [disabled, targetId]);
|
||||
|
||||
return (
|
||||
<Ariakit.TooltipProvider placement="top" timeout={300}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
aria-keyshortcuts={isActive ? ariaKey : undefined}
|
||||
data-escalate-steer={surface}
|
||||
data-escalate-steer-active={isActive ? 'true' : undefined}
|
||||
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
|
||||
disabled={disabled}
|
||||
onPointerEnter={() => !disabled && updateActiveTarget('hover', targetId, true)}
|
||||
onPointerLeave={() => updateActiveTarget('hover', targetId, false)}
|
||||
onFocus={() => !disabled && updateActiveTarget('focus', targetId, true)}
|
||||
onBlur={() => updateActiveTarget('focus', targetId, false)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
'bg-text-primary text-surface-primary transition-opacity hover:opacity-85',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
|
||||
'disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35',
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.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">
|
||||
{chord && isActive ? `${label} · ${chord}` : label}
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<boolean>(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) {
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mt-2 mb-2 pl-9 text-xs text-text-secondary">
|
||||
{localize('com_ui_sending')}
|
||||
<div className="-mt-2 mb-2 flex items-center gap-2 pl-9 text-xs text-text-secondary">
|
||||
<span>
|
||||
{localize(
|
||||
steer.preempt === true ? 'com_ui_steer_in_flight_preempt' : 'com_ui_sending',
|
||||
)}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<EscalateNowButton
|
||||
surface="bubble"
|
||||
messageText={steer.text}
|
||||
disabled={paused === true || interruptPending}
|
||||
onClick={() =>
|
||||
escalate({
|
||||
steerId: steer.steerId,
|
||||
generationCreatedAt: steer.generationCreatedAt,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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(
|
||||
<RecoilRoot initializeState={({ set }) => set(store.pendingSteersByConvoId(CONVO_ID), steers)}>
|
||||
<PendingSteers conversationId={CONVO_ID} />
|
||||
</RecoilRoot>,
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => set(store.pendingSteersByConvoId(CONVO_ID), steers)}
|
||||
>
|
||||
<PendingSteers conversationId={CONVO_ID} />
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
175
client/src/hooks/Chat/useSteerEscalate.ts
Normal file
175
client/src/hooks/Chat/useSteerEscalate.ts
Normal file
|
|
@ -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<typeof setTimeout> | 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<never>((_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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
11
client/src/store/steer.ts
Normal file
11
client/src/store/steer.ts
Normal file
|
|
@ -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<boolean>(false));
|
||||
Loading…
Add table
Add a link
Reference in a new issue