mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
feat: dedicated escalation arrow + shortcut, menu split into actions and preferences
The escalation was still half-hidden: the bubble only offered it inside the overflow menu, and the tooltip taught the composer chord, which does a different thing (interrupts with typed text, not this chip). Three changes make it a first-class command: - A shared EscalateNowButton (circular arrow, ghost-bordered like the composer's interrupt control) is always visible on BOTH surfaces: beside each queued row's Steer primary and on every waiting steer bubble next to its menu. It disappears once a steer is interrupting. - A dedicated registry shortcut, escalateSteer (Cmd/Ctrl+Shift+.), editing-allowed and rebindable like every other action. Deliberately NOT an Enter chord: the composer owns every Enter chord, and the yield design rests on no default binding using Enter besides submit. Its handler clicks the newest enabled arrow control (bubbles beat queued rows), so the shortcut can never diverge from the button, and the arrow's tooltip teaches THIS command via the registry display. - The overflow menus separate one-off actions from sticky behavior changes: Edit, Cancel, Queue, then a smaller "Preferences" section holding the queueing and always-interrupt toggles, each with the standard InfoHoverCard reusing the Settings panel's descriptions. "Interrupt & steer now" leaves the menu entirely. 386 client tests green, including a menu-structure test locking the order and the absence of the escalation entry; bubble escalation tests drive the visible arrow. The e2e spec's bubble test now clicks the arrow, and a fourth test drives the dedicated shortcut end to end through a real mid-stream seal.
This commit is contained in:
parent
199bd77d12
commit
9a0a8d1c15
7 changed files with 340 additions and 221 deletions
|
|
@ -7,7 +7,12 @@ import type { TFile, TMessage } from 'librechat-data-provider';
|
|||
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
|
||||
import type { PendingSteer } from '~/store/families';
|
||||
import type { MenuEntry } from './SteerMenu';
|
||||
import { RowMenu, useDefaultToggleEntry, useInterruptToggleEntry } from './SteerMenu';
|
||||
import {
|
||||
RowMenu,
|
||||
EscalateNowButton,
|
||||
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';
|
||||
|
|
@ -144,6 +149,53 @@ const InFlightSteer = memo(function InFlightSteer({
|
|||
const { mutateAsync: armSteer } = useArmSteerMutation();
|
||||
const setEscalating = useSetAtom(escalatingSteerFamily(conversationId));
|
||||
|
||||
/**
|
||||
* Escalate this waiting steer to an interrupt: ONE atomic 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. 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. The escalating flag flips 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".
|
||||
*/
|
||||
const escalate = useCallback(() => {
|
||||
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 what 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));
|
||||
}, [
|
||||
armSteer,
|
||||
conversationId,
|
||||
steer.steerId,
|
||||
setEscalating,
|
||||
markSteerPreempt,
|
||||
showToast,
|
||||
localize,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Takes the steer back off the server queue so its words can be re-homed.
|
||||
* The chip is left alone until the answer is known: only `reclaimed` proves
|
||||
|
|
@ -198,82 +250,6 @@ const InFlightSteer = memo(function InFlightSteer({
|
|||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'queue',
|
||||
label: localize('com_ui_convert_to_queue'),
|
||||
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
void reclaim().then((reclaimed) => {
|
||||
if (reclaimed) {
|
||||
steering.queueReclaimedSteer(steer);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
/* Escalate a waiting steer to interrupt at the next safe token boundary.
|
||||
* Reclaim first, same race rules as Edit: only `reclaimed` proves the
|
||||
* words never entered the run, then `retrySteer` swaps this chip for a
|
||||
* new preempting one. A run that ended mid-reclaim already queued the
|
||||
* words — there is nothing left to interrupt. The escalation lock covers
|
||||
* the reclaim window (no preempt chip exists yet to disable the other
|
||||
* controls), and the fresh recheck before resubmitting catches an
|
||||
* interrupt armed elsewhere meanwhile — those words re-home to the queue
|
||||
* rather than breaking the one-interrupt invariant. Offered only while
|
||||
* this steer is not already preempting. */
|
||||
...(preempting
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'interrupt',
|
||||
label: localize('com_ui_interrupt_steer_now'),
|
||||
icon: <ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />,
|
||||
/* UX gate only — correctness lives in the server's atomic arm,
|
||||
* which is fenced to this generation and refuses once the item
|
||||
* left the queue. `!duringRunActive` covers answer mode, where
|
||||
* `pausedOnApproval` stays false. */
|
||||
disabled: interruptPending || steering.pausedOnApproval || !steering.duringRunActive,
|
||||
/* One atomic server op: `preempt` flips on the EXISTING queued
|
||||
* item, so its FIFO position, id, and timestamp survive and there
|
||||
* is no reclaim window to race. 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. */
|
||||
onClick: () => {
|
||||
/* 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,
|
||||
]),
|
||||
{
|
||||
/* Non-destructive, but only when it is safe: cancel reliably first (the
|
||||
* optimistic hook removes the chip and restores it if the server would
|
||||
|
|
@ -306,9 +282,20 @@ const InFlightSteer = memo(function InFlightSteer({
|
|||
});
|
||||
},
|
||||
},
|
||||
toggleEntry,
|
||||
interruptToggle,
|
||||
{
|
||||
key: 'queue',
|
||||
label: localize('com_ui_convert_to_queue'),
|
||||
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
void reclaim().then((reclaimed) => {
|
||||
if (reclaimed) {
|
||||
steering.queueReclaimedSteer(steer);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -416,8 +403,24 @@ const InFlightSteer = memo(function InFlightSteer({
|
|||
* controls on the queued rows). `sticky` keeps it in view while the
|
||||
* user scrolls through a tall, expanded steer (the stack scrolls once
|
||||
* it passes 35vh). */
|
||||
<div data-testid="steer-controls" className="sticky top-2 flex shrink-0 items-center">
|
||||
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
|
||||
<div
|
||||
data-testid="steer-controls"
|
||||
className="sticky top-2 flex shrink-0 items-center gap-1"
|
||||
>
|
||||
{!preempting && (
|
||||
<EscalateNowButton
|
||||
surface="bubble"
|
||||
disabled={
|
||||
interruptPending || steering.pausedOnApproval || !steering.duringRunActive
|
||||
}
|
||||
onClick={escalate}
|
||||
/>
|
||||
)}
|
||||
<RowMenu
|
||||
label={localize('com_ui_more_options')}
|
||||
entries={entries}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
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';
|
||||
import { X, Zap, Send, Clock, Pencil, Trash2, Paperclip, RotateCcw } from 'lucide-react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
|
||||
import type { PendingSteer, QueuedMessage } from '~/store/families';
|
||||
|
|
@ -12,8 +11,8 @@ import {
|
|||
RowMenu,
|
||||
ICON_BTN_CLASS,
|
||||
PRIMARY_BTN_CLASS,
|
||||
EscalateNowButton,
|
||||
useDefaultToggleEntry,
|
||||
useInterruptChordHint,
|
||||
useInterruptToggleEntry,
|
||||
} from './SteerMenu';
|
||||
import { escalatingSteerFamily } from '~/store/steer';
|
||||
|
|
@ -37,40 +36,6 @@ function AttachmentCount({ count, label }: { count: number; label: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate one message past the queue: interrupt & steer it now, at the next
|
||||
* safe token boundary instead of the next tool step. Icon-only ZapOff, the
|
||||
* interrupt glyph everywhere in this UI; the tooltip teaches the composer
|
||||
* chord that does this in one step. Disabled while another interrupt is
|
||||
* unresolved or the run is paused on approval.
|
||||
*/
|
||||
function InterruptNowButton({ disabled, onClick }: { disabled: boolean; onClick: () => void }) {
|
||||
const localize = useLocalize();
|
||||
const chordHint = useInterruptChordHint();
|
||||
const label = localize('com_ui_interrupt_steer_now');
|
||||
return (
|
||||
<Ariakit.TooltipProvider placement="top" timeout={300}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid="queued-interrupt-now"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(ICON_BTN_CLASS, 'disabled:cursor-not-allowed disabled:opacity-40')}
|
||||
>
|
||||
<ZapOff className="h-4 w-4 text-amber-500" 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">
|
||||
{chordHint == null ? label : `${label} · ${chordHint}`}
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuedRow({
|
||||
message,
|
||||
steering,
|
||||
|
|
@ -114,9 +79,8 @@ function QueuedRow({
|
|||
});
|
||||
},
|
||||
},
|
||||
toggleEntry,
|
||||
interruptToggle,
|
||||
];
|
||||
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
|
||||
|
||||
return (
|
||||
<div role="listitem" className={ROW_CLASS} data-testid="queued-message-row">
|
||||
|
|
@ -148,7 +112,8 @@ function QueuedRow({
|
|||
</button>
|
||||
)}
|
||||
{showEscalate && (
|
||||
<InterruptNowButton
|
||||
<EscalateNowButton
|
||||
surface="queued"
|
||||
disabled={steering.pausedOnApproval || interruptPending}
|
||||
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
|
||||
/>
|
||||
|
|
@ -172,7 +137,11 @@ function QueuedRow({
|
|||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
|
||||
<RowMenu
|
||||
label={localize('com_ui_more_options')}
|
||||
entries={entries}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -217,9 +186,8 @@ function FailedSteerRow({
|
|||
manualSkills: steer.manualSkills,
|
||||
}),
|
||||
},
|
||||
toggleEntry,
|
||||
interruptToggle,
|
||||
];
|
||||
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -256,7 +224,11 @@ function FailedSteerRow({
|
|||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
|
||||
<RowMenu
|
||||
label={localize('com_ui_more_options')}
|
||||
entries={entries}
|
||||
preferences={preferences}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Zap, ZapOff, Clock, MoreHorizontal } from 'lucide-react';
|
||||
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { Zap, ZapOff, Clock, ArrowUp, MoreHorizontal } from 'lucide-react';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
import { isMacPlatform, resolveComposerKeyDown, bindingDisplayString } from '~/utils/shortcuts';
|
||||
import useComposerBindings from '~/hooks/Input/useComposerBindings';
|
||||
import { useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
/** Shared row/bubble affordances for the during-run surfaces: the in-flight
|
||||
|
|
@ -26,37 +27,115 @@ export type MenuEntry = {
|
|||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
/** Localized description shown as the standard info hovercard. */
|
||||
info?: string;
|
||||
};
|
||||
|
||||
/** Per-row "…" overflow menu (edit / mode toggle / conversions). */
|
||||
export function RowMenu({ label, entries }: { label: string; entries: MenuEntry[] }) {
|
||||
/**
|
||||
* Per-row "…" overflow menu: message actions first, then a visually separated
|
||||
* "Preferences" section for the sticky mode toggles, so one-off actions and
|
||||
* persistent behavior changes never read as the same kind of choice.
|
||||
*/
|
||||
export function RowMenu({
|
||||
label,
|
||||
entries,
|
||||
preferences,
|
||||
}: {
|
||||
label: string;
|
||||
entries: MenuEntry[];
|
||||
preferences?: MenuEntry[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const menu = Ariakit.useMenuStore({ placement: 'top-end' });
|
||||
const renderEntry = (entry: MenuEntry) => (
|
||||
<Ariakit.MenuItem
|
||||
key={entry.key}
|
||||
className={MENU_ITEM_CLASS}
|
||||
disabled={entry.disabled === true}
|
||||
accessibleWhenDisabled
|
||||
onClick={() => {
|
||||
entry.onClick();
|
||||
menu.hide();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
{entry.info != null && (
|
||||
<span className="ml-auto flex items-center" onClick={(event) => event.stopPropagation()}>
|
||||
<InfoHoverCard side={ESide.Top} text={entry.info} />
|
||||
</span>
|
||||
)}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Ariakit.MenuButton store={menu} aria-label={label} className={ICON_BTN_CLASS}>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Ariakit.MenuButton>
|
||||
<Ariakit.Menu store={menu} portal gutter={6} className={MENU_CLASS}>
|
||||
{entries.map((entry) => (
|
||||
<Ariakit.MenuItem
|
||||
key={entry.key}
|
||||
className={MENU_ITEM_CLASS}
|
||||
disabled={entry.disabled === true}
|
||||
accessibleWhenDisabled
|
||||
onClick={() => {
|
||||
entry.onClick();
|
||||
menu.hide();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</Ariakit.MenuItem>
|
||||
))}
|
||||
{entries.map(renderEntry)}
|
||||
{preferences != null && preferences.length > 0 && (
|
||||
<>
|
||||
<div role="separator" className="mx-2 my-1 border-t border-border-light" />
|
||||
<div className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_preferences')}
|
||||
</div>
|
||||
{preferences.map(renderEntry)}
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-visible 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);
|
||||
* the shortcut handler clicks whichever of these buttons is newest, so the
|
||||
* two can never diverge.
|
||||
*/
|
||||
export function EscalateNowButton({
|
||||
surface,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
surface: 'bubble' | 'queued';
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const chord = useShortcutDisplay('escalateSteer');
|
||||
const label = localize('com_ui_interrupt_steer_now');
|
||||
return (
|
||||
<Ariakit.TooltipProvider placement="top" timeout={300}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-escalate-steer={surface}
|
||||
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex size-7 shrink-0 items-center justify-center rounded-full border border-border-medium',
|
||||
'text-text-primary transition-colors hover:bg-surface-hover',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent',
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-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 ? `${label} · ${chord}` : label}
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The overflow item that flips the Enter-during-run default. Shown as the
|
||||
* OPPOSITE of the current default (the action you would switch to), matching
|
||||
|
|
@ -78,6 +157,7 @@ export function useDefaultToggleEntry(steering: SteeringControls): MenuEntry {
|
|||
) : (
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
),
|
||||
info: localize('com_nav_info_during_run_action'),
|
||||
onClick: () => steering.setDefaultAction(next),
|
||||
};
|
||||
}, [steering, localize]);
|
||||
|
|
@ -102,43 +182,9 @@ export function useInterruptToggleEntry(): MenuEntry {
|
|||
) : (
|
||||
<ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
),
|
||||
info: localize('com_ui_steer_interrupts_default_info'),
|
||||
onClick: () => setInterrupts(!interrupts),
|
||||
}),
|
||||
[interrupts, setInterrupts, localize],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interrupt chord for tooltips, present only while the chord still
|
||||
* preempts: a chord rebound to a global shortcut (or claimed by a rebound
|
||||
* submit) must not be advertised, and `resolveComposerKeyDown` is the one
|
||||
* source of that answer.
|
||||
*/
|
||||
export function useInterruptChordHint(): string | undefined {
|
||||
const enterToSend = useRecoilValue(store.enterToSend);
|
||||
const { submitOverride, yieldedChords } = useComposerBindings();
|
||||
return useMemo(() => {
|
||||
const chord = {
|
||||
meta: isMacPlatform,
|
||||
ctrl: !isMacPlatform,
|
||||
alt: false,
|
||||
shift: true,
|
||||
key: 'Enter',
|
||||
};
|
||||
const verdict = resolveComposerKeyDown(
|
||||
{ key: 'Enter', altKey: false, ctrlKey: chord.ctrl, metaKey: chord.meta, shiftKey: true },
|
||||
{
|
||||
isComposing: false,
|
||||
isSubmitting: true,
|
||||
allowSubmitWhileGenerating: true,
|
||||
hasDuringRunModifier: true,
|
||||
enterToSend,
|
||||
submitOverride,
|
||||
yieldedChords,
|
||||
},
|
||||
);
|
||||
return verdict === 'preempt'
|
||||
? (bindingDisplayString(chord, isMacPlatform) ?? undefined)
|
||||
: undefined;
|
||||
}, [enterToSend, submitOverride, yieldedChords]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ jest.mock('~/hooks', () => ({
|
|||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
InfoHoverCard: () => null,
|
||||
ESide: { Top: 'top', Bottom: 'bottom' },
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
|
|
@ -610,7 +612,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
|
||||
it("arms the interrupt in place, keeping the steer's id and position", async () => {
|
||||
renderSteers([{ steerId: 's1', text: 'hold on', status: 'pending', createdAt: 1 }]);
|
||||
await clickMenuItem('com_ui_interrupt_steer_now');
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('steer-escalate-now'));
|
||||
});
|
||||
|
||||
expect(mockArmMutateAsync).toHaveBeenCalledWith({
|
||||
conversationId: CONVO_ID,
|
||||
|
|
@ -622,32 +626,31 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
expect(screen.getByText('hold on')).toBeInTheDocument();
|
||||
|
||||
/** The chip relabelled in place: an interrupting steer offers no further
|
||||
* escalation, so the entry is gone on reopen. */
|
||||
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
|
||||
expect(await screen.findByText('com_ui_steer_cancel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
|
||||
* escalation, so its arrow control is gone. */
|
||||
expect(screen.queryByTestId('steer-escalate-now')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not offer escalation on a steer that is already interrupting', async () => {
|
||||
renderSteers([
|
||||
{ steerId: 's1', text: 'already sealing', status: 'pending', createdAt: 1, preempt: true },
|
||||
]);
|
||||
expect(screen.queryByTestId('steer-escalate-now')).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
|
||||
expect(await screen.findByText('com_ui_steer_cancel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the chip an ordinary steer when the deployment cannot seal', async () => {
|
||||
mockArmMutateAsync.mockResolvedValue({ armed: false, code: 'PREEMPT_UNSUPPORTED' });
|
||||
renderSteers([{ steerId: 's1', text: 'no seal here', status: 'pending', createdAt: 1 }]);
|
||||
await clickMenuItem('com_ui_interrupt_steer_now');
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('steer-escalate-now'));
|
||||
});
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'com_ui_steer_preempt_unsupported' }),
|
||||
);
|
||||
/** Still an ordinary steer: escalation stays offered. */
|
||||
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
|
||||
expect(await screen.findByText('com_ui_interrupt_steer_now')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('steer-escalate-now')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('stays neutral when the arm loses its race, whatever the reason', async () => {
|
||||
|
|
@ -655,7 +658,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
* alike, so the toast must not claim one specific outcome. */
|
||||
mockArmMutateAsync.mockResolvedValue({ armed: false });
|
||||
renderSteers([{ steerId: 's1', text: 'too late', status: 'pending', createdAt: 1 }]);
|
||||
await clickMenuItem('com_ui_interrupt_steer_now');
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('steer-escalate-now'));
|
||||
});
|
||||
|
||||
expect(mockRetrySteer).not.toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
|
|
@ -666,7 +671,9 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
it('reports an arm failure without touching the steer', async () => {
|
||||
mockArmMutateAsync.mockRejectedValue(new Error('network'));
|
||||
renderSteers([{ steerId: 's1', text: 'still queued', status: 'pending', createdAt: 1 }]);
|
||||
await clickMenuItem('com_ui_interrupt_steer_now');
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('steer-escalate-now'));
|
||||
});
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'com_ui_steer_arm_failed', status: 'error' }),
|
||||
|
|
@ -689,19 +696,12 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
{ 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'));
|
||||
const buttons = screen.getAllByTestId('steer-escalate-now');
|
||||
fireEvent.click(buttons[0]);
|
||||
|
||||
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');
|
||||
expect(buttons[1]).toBeDisabled();
|
||||
await act(async () => {
|
||||
fireEvent.click(secondItem);
|
||||
fireEvent.click(buttons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -716,12 +716,11 @@ describe('InFlightSteers — interrupt-now escalation', () => {
|
|||
{ steerId: 's1', text: 'plain steer', status: 'pending', createdAt: 1 },
|
||||
{ steerId: 's2', text: 'sealing now', status: 'pending', createdAt: 2, preempt: true },
|
||||
]);
|
||||
fireEvent.click(screen.getAllByLabelText('com_ui_more_options')[0]);
|
||||
const item = await screen.findByText('com_ui_interrupt_steer_now');
|
||||
expect(item.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
|
||||
const button = screen.getByTestId('steer-escalate-now');
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(item);
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(mockArmMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -743,13 +742,42 @@ describe('InFlightSteers — escalation while the run cannot accept a steer', ()
|
|||
renderSteers([{ steerId: 's1', text: 'waiting', status: 'pending', createdAt: 1 }], {
|
||||
duringRunActive: false,
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
|
||||
const item = await screen.findByText('com_ui_interrupt_steer_now');
|
||||
expect(item.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true');
|
||||
const button = screen.getByTestId('steer-escalate-now');
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(item);
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(mockArmMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* One-off message actions and sticky behavior changes must never read as the
|
||||
* same kind of choice: actions first (edit, cancel, queue), then a separated
|
||||
* "Preferences" section holding the two mode toggles. Escalation is not a
|
||||
* menu entry at all — it has its own always-visible arrow control.
|
||||
*/
|
||||
describe('InFlightSteers — menu structure', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockCancelMutateAsync.mockResolvedValue({ removed: true });
|
||||
});
|
||||
|
||||
it('orders actions above a separated Preferences section, with no escalation entry', async () => {
|
||||
renderSteers([{ steerId: 's1', text: 'structured', status: 'pending', createdAt: 1 }]);
|
||||
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
|
||||
await screen.findByText('com_ui_edit_message');
|
||||
|
||||
const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
|
||||
expect(items).toEqual([
|
||||
'com_ui_edit_message',
|
||||
'com_ui_steer_cancel',
|
||||
'com_ui_convert_to_queue',
|
||||
'com_ui_turn_on_queueing',
|
||||
'com_ui_always_interrupt',
|
||||
]);
|
||||
expect(screen.getByText('com_ui_preferences')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_interrupt_steer_now')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,6 +115,14 @@ export const shortcutDefinitions = {
|
|||
ariaMac: 'Meta+Shift+X',
|
||||
ariaOther: 'Control+Shift+X',
|
||||
},
|
||||
escalateSteer: {
|
||||
labelKey: 'com_ui_interrupt_steer_now',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
displayMac: '⌘ ⇧ .',
|
||||
displayOther: 'Ctrl+Shift+.',
|
||||
ariaMac: 'Meta+Shift+.',
|
||||
ariaOther: 'Control+Shift+.',
|
||||
},
|
||||
regenerateResponse: {
|
||||
labelKey: 'com_shortcut_regenerate_response',
|
||||
groupKey: 'com_shortcut_group_chat',
|
||||
|
|
@ -289,6 +297,7 @@ export const EDITING_ALLOWED_SHORTCUTS: ReadonlySet<ShortcutActionId> = new Set(
|
|||
'focusSearch',
|
||||
'showShortcuts',
|
||||
'submitMessage',
|
||||
'escalateSteer',
|
||||
]);
|
||||
|
||||
export type ShortcutAction = ShortcutDefinition & {
|
||||
|
|
@ -551,6 +560,25 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
[],
|
||||
);
|
||||
|
||||
/** Escalate the newest waiting message to an interrupt by pressing its own
|
||||
* visible arrow control, so the shortcut can never diverge from the
|
||||
* button's semantics. A waiting steer bubble beats a queued follow-up (it
|
||||
* is closer to the run); newest-last matches how both stacks append. */
|
||||
const handleEscalateSteer = useCallback(() => {
|
||||
const pick = (surface: string) => {
|
||||
const list = document.querySelectorAll<HTMLButtonElement>(
|
||||
`[data-escalate-steer="${surface}"]`,
|
||||
);
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (!isUnavailableElement(list[i])) {
|
||||
return list[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return clickTarget(pick('bubble') ?? pick('queued'));
|
||||
}, []);
|
||||
|
||||
const handleEditLastMessage = useCallback(() => {
|
||||
const userTurns = document.querySelectorAll('.user-turn');
|
||||
if (userTurns.length === 0) {
|
||||
|
|
@ -750,6 +778,7 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
focusSearch: handleFocusSearch,
|
||||
openSettings: handleOpenSettings,
|
||||
stopGenerating: handleStopGenerating,
|
||||
escalateSteer: handleEscalateSteer,
|
||||
regenerateResponse: handleRegenerateResponse,
|
||||
editLastMessage: handleEditLastMessage,
|
||||
copyLastCode: handleCopyLastCode,
|
||||
|
|
@ -779,6 +808,7 @@ export function useShortcutActions(): ShortcutAction[] {
|
|||
handleUploadFile,
|
||||
handleToggleSidebar,
|
||||
handleOpenModelSelector,
|
||||
handleEscalateSteer,
|
||||
handleFocusSearch,
|
||||
handleOpenSettings,
|
||||
handleStopGenerating,
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,7 @@
|
|||
"com_ui_pin_error": "Failed to pin conversation",
|
||||
"com_ui_pinned": "Pinned",
|
||||
"com_ui_plus_n_more": "+{{0}} more",
|
||||
"com_ui_preferences": "Preferences",
|
||||
"com_ui_preferences_updated": "Preferences updated successfully",
|
||||
"com_ui_prev": "Prev",
|
||||
"com_ui_prev_result": "Previous result",
|
||||
|
|
|
|||
|
|
@ -145,24 +145,19 @@ test.describe('escalating waiting messages to an interrupt', () => {
|
|||
const bubble = inFlightSteers(page).filter({ hasText: steerText });
|
||||
await expect(bubble).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Escalate from the bubble's overflow menu: ONE atomic in-place arm.
|
||||
await bubble.getByRole('button', { name: 'More options' }).click();
|
||||
// Escalate via the bubble's always-visible arrow control: ONE atomic
|
||||
// in-place arm.
|
||||
const [armResponse] = await Promise.all([
|
||||
page.waitForResponse(isArmRequest, { timeout: 15000 }),
|
||||
page.getByRole('menuitem', { name: 'Interrupt & steer now' }).click(),
|
||||
bubble.getByTestId('steer-escalate-now').click(),
|
||||
]);
|
||||
expect(armResponse.status()).toBe(200);
|
||||
expect(((await armResponse.json()) as { armed?: boolean }).armed).toBe(true);
|
||||
|
||||
// Relabelled IN PLACE: still exactly one bubble with the same text, and
|
||||
// an interrupting steer no longer offers escalation on reopen.
|
||||
// an interrupting steer no longer offers its escalation control.
|
||||
await expect(inFlightSteers(page)).toHaveCount(1);
|
||||
await bubble.getByRole('button', { name: 'More options' }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel steering message' })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page.getByRole('menuitem', { name: 'Interrupt & steer now' })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(bubble.getByTestId('steer-escalate-now')).toHaveCount(0);
|
||||
|
||||
// The armed steer seals mid-stream and injects with no tool boundary.
|
||||
await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, {
|
||||
|
|
@ -196,7 +191,9 @@ test.describe('escalating waiting messages to an interrupt', () => {
|
|||
const row = queuedRows(page).filter({ hasText: queueText });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The toggle lives in the row menu's separated Preferences section.
|
||||
await row.getByRole('button', { name: 'More options' }).click();
|
||||
await expect(page.getByText('Preferences', { exact: true })).toBeVisible({ timeout: 5000 });
|
||||
await page.getByRole('menuitem', { name: 'Always interrupt instead' }).click();
|
||||
|
||||
// The toggle is live for the SAME run: plain Enter now routes the default
|
||||
|
|
@ -222,4 +219,46 @@ test.describe('escalating waiting messages to an interrupt', () => {
|
|||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test('the dedicated shortcut escalates the newest waiting steer from the keyboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150000);
|
||||
const label = uniqueLabel('shortcut');
|
||||
const steerText = `Shortcut-armed steer ${label}`;
|
||||
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
await establishConversation(page, `shortcut-setup-${label}`);
|
||||
|
||||
const run = await sendMessage(page, `E2E_SLOW_REPLY:${label}`);
|
||||
expect(run.ok()).toBeTruthy();
|
||||
await expect(messagesView(page).getByText('chunk-010')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await typeDuringRun(page, steerText);
|
||||
const [steerResponse] = await Promise.all([
|
||||
page.waitForResponse(isSteerRequest, { timeout: 15000 }),
|
||||
messageInput(page).press('Enter'),
|
||||
]);
|
||||
expect(steerResponse.status()).toBe(202);
|
||||
await expect(inFlightSteers(page).filter({ hasText: steerText })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// The dedicated command works from the composer (it is editing-allowed),
|
||||
// pressing the newest waiting bubble's own arrow control.
|
||||
await messageInput(page).click();
|
||||
const [armResponse] = await Promise.all([
|
||||
page.waitForResponse(isArmRequest, { timeout: 15000 }),
|
||||
page.keyboard.press('ControlOrMeta+Shift+.'),
|
||||
]);
|
||||
expect(armResponse.status()).toBe(200);
|
||||
expect(((await armResponse.json()) as { armed?: boolean }).armed).toBe(true);
|
||||
|
||||
// And the armed steer seals mid-stream, same proof as the button path.
|
||||
await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, {
|
||||
timeout: 90000,
|
||||
});
|
||||
await expect(messagesView(page).getByText(SLOW_REPLY_LAST_CHUNK)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue