This commit is contained in:
Danny Avila 2026-08-04 14:27:37 +02:00 committed by GitHub
commit 7cc7e23f0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 3133 additions and 284 deletions

View file

@ -1,221 +1,25 @@
import { memo, useMemo, useRef, useState, useCallback } from 'react';
import { memo, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { X, Zap, Send, Clock, Pencil, Trash2, Paperclip, RotateCcw } from 'lucide-react';
import { X, Zap, Clock, Pencil, 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';
import type { RestoreToComposer } from './InFlightSteers';
import type { PendingSteer } from '~/store/families';
import type { MenuEntry } from './SteerMenu';
import {
RowMenu,
ICON_BTN_CLASS,
PRIMARY_BTN_CLASS,
EscalateNowButton,
useDefaultToggleEntry,
useInterruptToggleEntry,
} from './SteerMenu';
import { QueuedRow, QueuedOutbox, QueueSendingBanner, ROW_CLASS } from './QueuedOutbox';
import { escalatingSteerFamily } from '~/store/steer';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const ROW_CLASS =
'flex w-full items-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary';
function AttachmentCount({ count, label }: { count: number; label: string }) {
if (count === 0) {
return null;
}
return (
<span className="flex shrink-0 items-center gap-0.5 text-xs text-text-secondary">
<Paperclip className="h-3.5 w-3.5" aria-hidden="true" />
{count}
<span className="sr-only">{label}</span>
</span>
);
}
function QueuedRow({
message,
steering,
conversationId,
interruptPending,
onEditToComposer,
onRestoreToComposer,
}: {
message: QueuedMessage;
steering: SteeringControls;
conversationId: string;
interruptPending: boolean;
onEditToComposer: (
text: string,
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const toggleEntry = useDefaultToggleEntry(steering);
const interruptToggle = useInterruptToggleEntry();
const fileCount = message.files?.length ?? 0;
const isRecovered = message.recoverySteerId != null;
const actionPendingRef = useRef(false);
const [actionPending, setActionPending] = useState(false);
/** A recovered item has a replayable parked source. Edit/remove must first
* cancel that source by receipt; local-only rows settle synchronously through
* the same control. The ref closes the pre-render double-click window. */
const afterDiscard = useCallback(
(action: () => boolean) => {
if (actionPendingRef.current) {
return;
}
actionPendingRef.current = true;
setActionPending(true);
void (async () => {
let discarded = false;
try {
discarded = await steering.discardQueued(message);
} catch {
// The steering hook reports request failures and leaves the row in
// place. Keep this guard for test/custom control implementations.
}
if (!discarded) {
actionPendingRef.current = false;
setActionPending(false);
return;
}
if (!action()) {
actionPendingRef.current = false;
setActionPending(false);
}
})();
},
[message, steering],
);
// A recovered item is consumed atomically only when it starts a normal
// generation. Re-steering it would leave or duplicate the parked source;
// Edit/remove are safe because `afterDiscard` tombstones that source first.
const canSteerNow = steering.duringRunActive && steering.canSteer && !isRecovered;
const showPrimary = canSteerNow || (!steering.duringRunActive && steering.canSendQueuedNow);
/** `canSteer` is defined as false while paused on approval, but the
* escalation control must stay visible-and-disabled there hiding it
* during the pause is exactly the discoverability gap this button fixes. */
const showEscalate =
!isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer));
const entries: MenuEntry[] = [
{
key: 'edit',
label: localize('com_ui_edit_message'),
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
disabled: actionPending,
onClick: () => {
const context = { quotes: message.quotes, manualSkills: message.manualSkills };
if (!isRecovered) {
steering.removeQueued(message.id);
onEditToComposer(message.text, message.files, {
quotes: message.quotes,
manualSkills: message.manualSkills,
});
return;
}
afterDiscard(() => {
const restored = onRestoreToComposer(
message.text,
message.files,
context,
conversationId,
);
if (!restored) {
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
return false;
}
steering.removeQueued(message.id);
return true;
});
},
},
];
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
<div role="listitem" className={ROW_CLASS} data-testid="queued-message-row">
<Clock className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" title={message.text}>
{message.text}
</span>
<AttachmentCount
count={fileCount}
label={localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
/>
{showPrimary && (
<button
type="button"
className={PRIMARY_BTN_CLASS}
disabled={actionPending}
onClick={() => steering.sendQueuedNow(message)}
>
{canSteerNow ? (
<>
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
{localize('com_ui_steer')}
</>
) : (
<>
<Send className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_send_now')}
</>
)}
</button>
)}
{showEscalate && (
<EscalateNowButton
surface="queued"
messageText={message.text}
disabled={steering.pausedOnApproval || interruptPending || actionPending}
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
/>
)}
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
disabled={actionPending}
onClick={() => {
const remove = () => {
/* Same safety net as the in-flight cancel: once removal is safely
* settled, return the words to the composer when it is free (the
* gated restore refuses rather than clobber a draft). */
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
steering.removeQueued(message.id);
return true;
};
if (!isRecovered) {
remove();
return;
}
afterDiscard(remove);
}}
className={ICON_BTN_CLASS}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
<RowMenu
label={localize('com_ui_more_options')}
entries={entries}
preferences={preferences}
/>
</div>
);
}
function FailedSteerRow({
steer,
steering,
@ -332,8 +136,11 @@ function FailedSteerRow({
* (In-flight steers read as messages, not controls `InFlightSteers` renders
* them as bubbles anchored above the composer box.)
* - Failed steer rows (Zap, red): the POST failed, so the text never entered
* the thread kept recoverable with retry / edit / queue actions.
* - Queued rows (Clock): client-side follow-ups auto-sent after the run.
* the thread kept recoverable with retry / edit / queue actions. They stay
* OUTSIDE the queued group: a failure is an action item, so no disclosure
* may ever hide one.
* - Queued rows (Clock): client-side follow-ups auto-sent after the run. A lone
* one stays a plain chip; two or more collapse into `QueuedOutbox`.
*/
function PendingSteerChips({
conversationId,
@ -353,6 +160,7 @@ function PendingSteerChips({
const localize = useLocalize();
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
const drainHold = useRecoilValue(store.queueDrainHoldByConvoId(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. The
@ -383,17 +191,35 @@ function PendingSteerChips({
onEditToComposer={onEditToComposer}
/>
))}
{queued.map((message) => (
{drainHold != null && drainHold.status == null && queued.length > 0 && (
<QueueSendingBanner
count={queued.length}
dueAt={drainHold.dueAt}
onUndo={steering.cancelQueueDrain}
/>
)}
{queued.length === 1 && (
<QueuedRow
key={message.id}
message={message}
key={queued[0].id}
message={queued[0]}
steering={steering}
conversationId={conversationId}
interruptPending={interruptPending}
onEditToComposer={onEditToComposer}
canBump={false}
sendPending={drainHold != null && drainHold.status == null}
onRestoreToComposer={onRestoreToComposer}
/>
))}
)}
{queued.length > 1 && (
<QueuedOutbox
queued={queued}
steering={steering}
conversationId={conversationId}
interruptPending={interruptPending}
sendPending={drainHold != null && drainHold.status == null}
onRestoreToComposer={onRestoreToComposer}
/>
)}
</div>
);
}

View file

@ -0,0 +1,656 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { useToastContext } from '@librechat/client';
import {
Zap,
Send,
Clock,
Merge,
Pencil,
Trash2,
ChevronUp,
ChevronsUp,
ChevronDown,
Paperclip,
} from 'lucide-react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { RestoreToComposer } from './InFlightSteers';
import type { QueuedMessage } from '~/store/families';
import type { MenuEntry } from './SteerMenu';
import {
RowMenu,
ICON_BTN_CLASS,
PRIMARY_BTN_CLASS,
EscalateNowButton,
useDefaultToggleEntry,
useInterruptToggleEntry,
} from './SteerMenu';
import { isMergeableQueuedMessage, isSendableQueuedMessage } from '~/utils';
import { queueExpandedFamily } from '~/store/steer';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export const ROW_CLASS =
'flex w-full items-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary';
/** Enough to show a merged row's paragraphs without the row eating the box. */
const EDITOR_MAX_ROWS = 4;
/** Depth cue for the collapsed stack. Kept INSIDE the row's own footprint (the
* wrapper reserves the peek height) because the composer box clips overflow. */
const PEEK_CLASS =
'pointer-events-none absolute h-full rounded-xl border border-border-light bg-surface-tertiary';
function AttachmentCount({ count, label }: { count: number; label: string }) {
if (count === 0) {
return null;
}
return (
<span className="flex shrink-0 items-center gap-0.5 text-xs text-text-secondary">
<Paperclip className="h-3.5 w-3.5" aria-hidden="true" />
{count}
<span className="sr-only">{label}</span>
</span>
);
}
function QueuedRowBase({
message,
steering,
conversationId,
interruptPending,
canBump,
sendPending,
onRestoreToComposer,
}: {
message: QueuedMessage;
steering: SteeringControls;
conversationId: string;
interruptPending: boolean;
/** False for the front row (already next) and for a lone queued message. */
canBump: boolean;
/** An automatic send is withheld and about to fire for this queue. */
sendPending: boolean;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const toggleEntry = useDefaultToggleEntry(steering);
const interruptToggle = useInterruptToggleEntry();
const fileCount = message.files?.length ?? 0;
const isRecovered = message.recoverySteerId != null;
/** Not while a send is pending: the words are already on their way, so an
* editor that opened and instantly settled would read as a glitch. Undo
* first, then edit. */
const editable = isMergeableQueuedMessage(message) && !sendPending;
const actionPendingRef = useRef(false);
const [actionPending, setActionPending] = useState(false);
const [draft, setDraft] = useState<string | null>(null);
const editing = draft != null;
const editorRef = useRef<HTMLTextAreaElement>(null);
/** The pre-edit words, so Escape can put them back after the write-through
* below has already replaced them. */
const originalRef = useRef<string>(message.text);
/** A recovered item has a replayable parked source. Edit/remove must first
* cancel that source by receipt; local-only rows settle synchronously through
* the same control. The ref closes the pre-render double-click window. */
const afterDiscard = useCallback(
(action: () => boolean) => {
if (actionPendingRef.current) {
return;
}
actionPendingRef.current = true;
setActionPending(true);
void (async () => {
let discarded = false;
try {
discarded = await steering.discardQueued(message);
} catch {
// The steering hook reports request failures and leaves the row in
// place. Keep this guard for test/custom control implementations.
}
if (!discarded) {
actionPendingRef.current = false;
setActionPending(false);
return;
}
if (!action()) {
actionPendingRef.current = false;
setActionPending(false);
}
})();
},
[message, steering],
);
// A recovered item is consumed atomically only when it starts a normal
// generation. Re-steering it would leave or duplicate the parked source;
// Edit/remove are safe because `afterDiscard` tombstones that source first.
const canSteerNow = steering.duringRunActive && steering.canSteer && !isRecovered;
const showPrimary = canSteerNow || (!steering.duringRunActive && steering.canSendQueuedNow);
/** `canSteer` is defined as false while paused on approval, but the
* escalation control must stay visible-and-disabled there hiding it
* during the pause is exactly the discoverability gap this button fixes. */
const showEscalate =
!isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer));
/** The queue holds exactly what is typed, so "nothing to send" is visible in
* the row itself no shared claim to publish, release, or leak. */
const sendable = isSendableQueuedMessage(message);
/** Focus follows the explicit Edit action rather than mount, so the row can
* never steal focus from the composer on a re-render. */
useEffect(() => {
if (editing) {
editorRef.current?.focus();
}
}, [editing]);
const draftRef = useRef<string | null>(null);
draftRef.current = draft;
const beginEdit = useCallback(() => {
originalRef.current = message.text;
setDraft(message.text);
}, [message.text]);
/**
* Every keystroke writes through to the queue. The row is a living draft and
* the senders the drain, Send now, the escalate shortcut all read the
* atom, so anything still only local is a message that sends as its old
* text. Local state stays for display alone, because an emptied field has to
* survive on screen while the queue keeps the last non-empty words.
*
* The cost is one small-subtree render per keystroke (this stack, never the
* thread, which does not subscribe to the queue).
*/
const editDraft = useCallback(
(value: string) => {
setDraft(value);
steering.updateQueuedText(message.id, value);
},
[message.id, steering],
);
/** Leaving the editor settles the row: trimmed if there are words, and back to
* the pre-edit text if there are not. That is what keeps a blank row from
* ever outliving its editor, which is in turn why every reader can trust
* `isSendableQueuedMessage` on a resting queue. */
const closeEdit = useCallback(() => {
const typed = draftRef.current;
if (typed == null) {
return;
}
steering.updateQueuedText(
message.id,
typed.trim().length === 0 ? originalRef.current : typed.trim(),
);
setDraft(null);
}, [message.id, steering]);
const abandonEdit = useCallback(() => {
steering.updateQueuedText(message.id, originalRef.current);
setDraft(null);
}, [message.id, steering]);
const closeEditRef = useRef(closeEdit);
closeEditRef.current = closeEdit;
/** An editor removed by a remount fires no blur, so it settles on the way out
* otherwise a blank row could survive the group collapsing around it. */
useEffect(() => () => closeEditRef.current(), []);
/** A row whose send is pending closes its editor: the words are already
* written, and a countdown is no moment to keep typing into. An EMPTY editor
* is resolved the way Escape resolves it the blank was never written, so
* closing it alone would leave the queue holding words the screen no longer
* shows, and the drain would send those. */
useEffect(() => {
if (sendPending) {
closeEditRef.current();
}
}, [sendPending]);
/** An ordinary row is a living draft: it is rewritten in place. A recovered
* row's words are bound to a parked server source matched by exact text, so
* its Edit keeps the existing discard-then-hand-to-composer path. */
const entries: MenuEntry[] = [
{
key: 'edit',
label: localize(editable ? 'com_ui_queue_edit_inline' : 'com_ui_edit_message'),
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
disabled: actionPending,
onClick: () => {
if (editable) {
beginEdit();
return;
}
const context = { quotes: message.quotes, manualSkills: message.manualSkills };
afterDiscard(() => {
const restored = onRestoreToComposer(
message.text,
message.files,
context,
conversationId,
);
if (!restored) {
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
return false;
}
steering.removeQueued(message.id);
return true;
});
},
},
];
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
<div role="listitem" className={ROW_CLASS} data-testid="queued-message-row">
<Clock className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
{editing ? (
/* A textarea, not an input: merged rows carry paragraph breaks and an
* input silently flattens them on the first keystroke. Enter commits
* and Shift+Enter adds a line, matching the composer. */
<textarea
ref={editorRef}
value={draft}
rows={Math.min(EDITOR_MAX_ROWS, draft.split('\n').length)}
aria-label={localize('com_ui_queue_edit_inline')}
data-testid="queued-message-edit"
className="min-w-0 flex-1 resize-none bg-transparent text-sm text-text-primary outline-none"
onChange={(event) => editDraft(event.target.value)}
onBlur={closeEdit}
onKeyDown={(event) => {
/** An IME candidate confirmation arrives as an unshifted Enter
* while composition is still active; committing there would save
* half-typed text. Same guard the composer and DynamicTags use. */
if (event.nativeEvent.isComposing || event.keyCode === 229) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
closeEdit();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
abandonEdit();
}
}}
/>
) : (
<span
className={cn('min-w-0 flex-1 truncate', editable && 'cursor-text')}
title={editable ? localize('com_ui_queue_edit_inline') : message.text}
onClick={editable ? beginEdit : undefined}
>
{message.text}
</span>
)}
<AttachmentCount
count={fileCount}
label={localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
/>
{canBump && !editing && (
<button
type="button"
aria-label={localize('com_ui_queue_send_next')}
title={localize('com_ui_queue_send_next')}
data-testid="queued-send-next"
disabled={actionPending}
onClick={() => steering.bumpQueued(message.id)}
className={ICON_BTN_CLASS}
>
<ChevronsUp className="h-4 w-4" aria-hidden="true" />
</button>
)}
{showPrimary && (
<button
type="button"
className={PRIMARY_BTN_CLASS}
disabled={actionPending || !sendable}
title={sendable ? undefined : localize('com_ui_queue_edit_empty')}
onClick={() => steering.sendQueuedNow(message)}
>
{canSteerNow ? (
<>
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
{localize('com_ui_steer')}
</>
) : (
<>
<Send className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_send_now')}
</>
)}
</button>
)}
{showEscalate && (
<EscalateNowButton
surface="queued"
messageText={message.text}
disabled={steering.pausedOnApproval || interruptPending || actionPending || !sendable}
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
/>
)}
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
disabled={actionPending}
onClick={() => {
const remove = () => {
/* Same safety net as the in-flight cancel: once removal is safely
* settled, return the words to the composer when it is free (the
* gated restore refuses rather than clobber a draft).
*
* Skipped when the editor was left empty: the queue still holds the
* pre-edit words, so handing them back would resurrect text the
* user had visibly deleted. Emptying a row and then removing it
* reads as "delete this", and there is nothing to return. */
if (sendable) {
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
}
steering.removeQueued(message.id);
return true;
};
if (!isRecovered) {
remove();
return;
}
afterDiscard(remove);
}}
className={ICON_BTN_CLASS}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
<RowMenu
label={localize('com_ui_more_options')}
entries={entries}
preferences={preferences}
/>
</div>
);
}
/** Rows carry local edit state and a sibling's keystroke must not re-render
* the whole stack; the in-flight bubbles are memoized for the same reason. */
export const QueuedRow = memo(QueuedRowBase);
/**
* The withheld automatic send. Progress is announced rather than merely drawn,
* and the bar is a CSS animation so the window costs no re-renders while it
* runs this surface sits above a streaming thread.
*/
function QueueSendingBannerBase({
count,
dueAt,
onUndo,
}: {
count: number;
dueAt: number;
onUndo: () => void;
}) {
const localize = useLocalize();
const remaining = Math.max(0, dueAt - Date.now());
return (
<div
role="listitem"
className="flex w-full items-center gap-2 rounded-xl border border-border-light bg-surface-tertiary px-3 py-2 text-sm text-text-primary"
data-testid="queue-sending-banner"
>
<Send className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate" aria-live="polite">
{localize('com_ui_queue_sending', { 0: String(count) })}
</span>
<span
aria-hidden="true"
className="h-1 w-10 shrink-0 overflow-hidden rounded-full bg-surface-secondary"
>
<span
className="block h-full w-full origin-left bg-text-secondary motion-safe:animate-queue-undo-grace"
style={{ animationDuration: `${remaining}ms` }}
/>
</span>
<button
type="button"
className={PRIMARY_BTN_CLASS}
data-testid="queue-undo-send"
onClick={onUndo}
>
{localize('com_ui_queue_undo_send')}
</button>
</div>
);
}
export const QueueSendingBanner = memo(QueueSendingBannerBase);
/**
* Two or more waiting messages read as one outbox instead of a growing stack:
* collapsed, the group is a single row (the next message to send) over layered
* card edges, so the composer stops inflating with queue depth. Management
* lives inside the expansion, where the rows keep every per-row affordance.
*/
function QueuedOutboxBase({
queued,
steering,
conversationId,
interruptPending,
sendPending,
onRestoreToComposer,
}: {
queued: QueuedMessage[];
steering: SteeringControls;
conversationId: string;
interruptPending: boolean;
sendPending: boolean;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [expanded, setExpanded] = useAtom(queueExpandedFamily(steering.queueKey));
/** Every gate below reads the rows this component already holds. That is the
* whole point of recording blank text instead of refusing it: sendability is
* a property of the data, so there is no shared claim to keep in step. */
const allSendable = useMemo(() => queued.every(isSendableQueuedMessage), [queued]);
const mergeable = useMemo(() => queued.every(isMergeableQueuedMessage), [queued]);
/** Folding reads the queue, so an unresolved empty edit would carry the words
* the user just deleted into the merged message. Same standdown the senders
* use the row's own controls, the drain, and the shortcut proxy. */
const mergeBlockedReason = (() => {
if (!mergeable) {
return localize('com_ui_queue_merge_blocked');
}
return allSendable ? undefined : localize('com_ui_queue_edit_empty');
})();
/** Clear all folds the queue the same way Merge does, so an unresolved empty
* edit would hand words the user deleted back to the composer. Uniform with
* every other reader rather than an exception worth remembering. */
const clearBlockedReason = allSendable ? undefined : localize('com_ui_queue_edit_empty');
/** The shortcut's promise is the NEWEST waiting message, which is not the
* last array slot once a promotion has reordered the queue so pick by
* stamp. Recovery-bound rows are skipped because steering refuses them
* mid-run, exactly as the expanded rows omit their escalation controls. */
const escalatable = useMemo(() => {
let newest: QueuedMessage | undefined;
for (const item of queued) {
if (item.recoverySteerId != null) {
continue;
}
if (newest == null || item.createdAt > newest.createdAt) {
newest = item;
}
}
return newest;
}, [queued]);
const [next] = queued;
/** Index of the first row a promotion could overtake. Computed once: the
* per-row prefix scan it replaces allocated and walked a slice for every
* row, and the write-through editor re-renders this list on every
* keystroke. */
const firstOvertakeable = useMemo(
() => queued.findIndex((item) => item.priority !== true),
[queued],
);
const clearAll = useCallback(() => {
void (async () => {
const cleared = await steering.clearQueued();
if (cleared == null) {
return;
}
const restored = onRestoreToComposer(
cleared.text,
cleared.files,
{ quotes: cleared.quotes, manualSkills: cleared.manualSkills },
conversationId,
);
/** The gated restore refuses rather than clobber a draft the user has
* since staged. Hand the words back to the queue instead of dropping
* them one row now, since they were folded on the way out. The ITEM
* goes back whole: rebuilding one from parts dropped its predecessor
* fence once and its interrupt tier once, and the next field would be
* just as quiet. */
if (!restored) {
steering.requeueCleared([cleared]);
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
}
})();
}, [conversationId, localize, onRestoreToComposer, showToast, steering]);
/* One wrapper for both states, so the disclosure button keeps its position
* in the tree: remounting it on toggle would drop keyboard focus mid-use.
* It is the parent list's item collapsed, the group IS the only item; the
* rows become a nested list when expanded. */
return (
<div
role="listitem"
className={cn('relative flex flex-col gap-1.5', !expanded && 'pb-1.5')}
data-testid="queue-group"
>
{!expanded && (
<>
<span aria-hidden="true" className={cn(PEEK_CLASS, 'inset-x-3 top-3 opacity-40')} />
<span aria-hidden="true" className={cn(PEEK_CLASS, 'inset-x-1.5 top-1.5 opacity-70')} />
</>
)}
<button
type="button"
aria-expanded={expanded}
/* Deliberately unlabelled: the accessible name comes from the count and
* next-up preview inside, which is the only description of the queue
* while the rows are unmounted. `aria-expanded` carries the show/hide
* state, so a label would only overwrite that summary. */
data-testid="queue-group-toggle"
onClick={() => setExpanded((prev) => !prev)}
className={cn(ROW_CLASS, 'relative text-left hover:bg-surface-hover')}
>
<Clock className="h-4 w-4 shrink-0 text-cyan-500" aria-hidden="true" />
<span className="shrink-0 font-medium">
{localize('com_ui_queue_count', { 0: String(queued.length) })}
</span>
{!expanded && (
<span className="min-w-0 flex-1 truncate text-text-secondary">
{localize('com_ui_queue_next_up', { 0: next.text })}
</span>
)}
<span className="flex-1" />
{expanded ? (
<ChevronUp className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
)}
</button>
{expanded && (
<div
role="list"
/* Named by its own count rather than repeating the outer stack's
* label, which would nest two identically named lists. */
aria-label={localize('com_ui_queue_count', { 0: String(queued.length) })}
data-testid="queue-rows"
/* The composer box is `overflow-hidden`, so a deep queue would clip
* rows with no way to reach them. Cap the ROWS only: the disclosure
* above and the actions below must stay put. Same ceiling the
* in-flight overlay uses. */
className="flex max-h-[35vh] flex-col gap-1.5 overflow-y-auto"
>
{queued.map((message, position) => (
<QueuedRow
key={message.id}
message={message}
steering={steering}
conversationId={conversationId}
interruptPending={interruptPending}
/** A promotion lifts a row to the top of the promotions tier,
* which is still below every interrupt so offering it when
* only interrupts sit ahead would advertise a no-op. */
canBump={firstOvertakeable !== -1 && position > firstOvertakeable}
sendPending={sendPending}
onRestoreToComposer={onRestoreToComposer}
/>
))}
</div>
)}
{expanded && (
<div className="flex items-center gap-2 px-1 pb-0.5">
<button
type="button"
className={PRIMARY_BTN_CLASS}
data-testid="queue-merge"
disabled={mergeBlockedReason != null}
title={mergeBlockedReason}
onClick={() => steering.mergeQueued()}
>
<Merge className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_queue_merge')}
</button>
<span className="flex-1" />
<button
type="button"
className={PRIMARY_BTN_CLASS}
data-testid="queue-clear-all"
disabled={clearBlockedReason != null}
title={clearBlockedReason}
onClick={clearAll}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_queue_clear_all')}
</button>
</div>
)}
{/* Keyboard parity. The shortcut prefers a hovered or focused control and
* otherwise takes the LAST queued one in the document which is the
* last row in DRAIN order, not the newest message, once a promotion has
* reordered the queue. This offscreen control sits last in both states
* so the fallback resolves to the newest eligible message either way,
* while the visible per-row buttons stay clickable. */}
{escalatable != null &&
!interruptPending &&
steering.duringRunActive &&
steering.canSteer && (
<button
type="button"
/** Invoked by the shortcut through its data attribute, so it needs
* no sequential focus and an invisible tab stop with no focus
* presentation is worse than none. */
tabIndex={-1}
/** Its target's editor is empty, so the row still holds words the
* user deleted. Disabled rather than absent: the shortcut skips
* unavailable controls, so it falls through instead of sending
* them. */
disabled={!isSendableQueuedMessage(escalatable)}
className="sr-only"
data-escalate-steer="queued"
data-testid="queued-escalate-newest"
aria-label={localize('com_ui_queue_escalate_newest')}
onClick={() => steering.sendQueuedNow(escalatable, { preempt: true })}
/>
)}
</div>
);
}
export const QueuedOutbox = memo(QueuedOutboxBase);

View file

@ -3,9 +3,9 @@ import { getDefaultStore } from 'jotai';
import userEvent from '@testing-library/user-event';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import type { PendingSteer, QueuedMessage } from '~/store/families';
import type { PendingSteer, QueuedMessage, QueueDrainHold } from '~/store/families';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { escalatingSteerFamily } from '~/store/steer';
import { escalatingSteerFamily, queueExpandedFamily } from '~/store/steer';
import PendingSteerChips from '../PendingSteerChips';
import store from '~/store';
@ -37,6 +37,13 @@ function QueueState() {
return <output data-testid="queue-state">{JSON.stringify(queue)}</output>;
}
let setHoldForTest: ((value: QueueDrainHold | null) => void) | undefined;
function HoldState() {
setHoldForTest = useSetRecoilState(store.queueDrainHoldByConvoId(CONVO_ID));
return null;
}
function PreferenceState() {
const defaultAction = useRecoilValue(store.duringRunDefaultAction);
const interrupts = useRecoilValue(store.steerInterruptsByDefault);
@ -64,6 +71,14 @@ const steeringStub = (overrides: Partial<SteeringControls> = {}) =>
...overrides,
}) as unknown as SteeringControls;
/** The outbox disclosure is jotai state on the module-global default store, so
* it outlives a render and would otherwise leak between tests in this file. */
beforeEach(() => {
act(() => {
getDefaultStore().set(queueExpandedFamily(CONVO_ID), false);
});
});
function renderChips(
queued: QueuedMessage[],
options?: {
@ -91,6 +106,7 @@ function renderChips(
/>
<PreferenceState />
<QueueState />
<HoldState />
</RecoilRoot>,
);
}
@ -456,6 +472,9 @@ describe('PendingSteerChips — queued interrupt-now', () => {
renderChips([queuedMessage, { id: 'q2', text: 'urgent two', createdAt: 2 }], {
steering: liveRun,
});
/* Two or more queued messages collapse into the outbox group; the per-row
* escalation controls live in the expansion. */
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const [first, second] = screen.getAllByTestId('queued-interrupt-now');
expect(first).not.toHaveAttribute('aria-keyshortcuts');
@ -478,6 +497,7 @@ describe('PendingSteerChips — queued interrupt-now', () => {
renderChips([queuedMessage, { id: 'q2', text: 'urgent two', createdAt: 2 }], {
steering: liveRun,
});
fireEvent.click(screen.getByTestId('queue-group-toggle'));
expect(
screen.getByRole('button', { name: 'com_ui_interrupt_steer_now: urgent one' }),
@ -541,3 +561,730 @@ describe('PendingSteerChips — queued interrupt-now', () => {
},
);
});
const mockBumpQueued = jest.fn();
/** Mirrors the real writer: records what is typed, blank included, so the rows
* under test see the same queue the app would. */
const mockUpdateQueuedText = jest.fn((id: string, text: string) => {
updateQueueForTest?.((current) =>
current.map((item) => (item.id === id ? { ...item, text } : item)),
);
return true;
});
const mockMergeQueued = jest.fn(() => true);
const mockCancelQueueDrain = jest.fn();
const mockEnqueue = jest.fn();
const mockRequeueCleared = jest.fn();
let mockClearQueued = jest.fn(async (): Promise<QueuedMessage | null> => null);
const outboxSteering = (overrides: Partial<SteeringControls> = {}) => ({
bumpQueued: mockBumpQueued,
updateQueuedText: mockUpdateQueuedText,
mergeQueued: mockMergeQueued,
clearQueued: mockClearQueued,
cancelQueueDrain: mockCancelQueueDrain,
enqueue: mockEnqueue,
requeueCleared: mockRequeueCleared,
...overrides,
});
const twoQueued: QueuedMessage[] = [
{ id: 'q1', text: 'first thought', createdAt: 1 },
{ id: 'q2', text: 'second thought', createdAt: 2 },
];
describe('PendingSteerChips — queued outbox group', () => {
beforeEach(() => {
jest.clearAllMocks();
mockClearQueued = jest.fn(async (): Promise<QueuedMessage | null> => null);
});
it('leaves a lone queued message as a plain chip', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
expect(screen.queryByTestId('queue-group')).toBeNull();
expect(screen.getAllByTestId('queued-message-row')).toHaveLength(1);
});
it('collapses two or more into one row showing the count and what sends next', () => {
renderChips(twoQueued, { steering: outboxSteering() });
expect(screen.getByTestId('queue-group')).toBeInTheDocument();
expect(screen.getByTestId('queue-group-toggle')).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByText('com_ui_queue_count')).toBeInTheDocument();
expect(screen.getByText('com_ui_queue_next_up')).toBeInTheDocument();
// The footprint is constant: the rows themselves are not mounted.
expect(screen.queryByTestId('queued-message-row')).toBeNull();
});
it('expands to the full managed list and back', () => {
renderChips(twoQueued, { steering: outboxSteering() });
const toggle = screen.getByTestId('queue-group-toggle');
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getAllByTestId('queued-message-row')).toHaveLength(2);
fireEvent.click(toggle);
expect(screen.queryByTestId('queued-message-row')).toBeNull();
});
/** The escalate shortcut clicks the LAST queued control in the document, and
* collapsing unmounts the rows so the newest message keeps one here. */
it('keeps the escalate shortcut pointed at the newest message while collapsed', () => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
const controls = document.querySelectorAll('[data-escalate-steer="queued"]');
expect(controls).toHaveLength(1);
expect(controls[0]).toHaveAttribute('data-testid', 'queued-escalate-newest');
fireEvent.click(screen.getByTestId('queued-escalate-newest'));
expect(mockSendQueuedNow).toHaveBeenCalledWith(expect.objectContaining({ id: 'q2' }), {
preempt: true,
});
});
/** Steering refuses a recovery-bound item mid-run, so a proxy pointed at one
* would make the shortcut silently do nothing. */
it('points the collapsed escalate stand-in at the newest ELIGIBLE message', () => {
renderChips([twoQueued[0], { ...twoQueued[1], recoverySteerId: 'server-source' }], {
steering: outboxSteering({ duringRunActive: true, canSteer: true }),
});
fireEvent.click(screen.getByTestId('queued-escalate-newest'));
expect(mockSendQueuedNow).toHaveBeenCalledWith(expect.objectContaining({ id: 'q1' }), {
preempt: true,
});
});
it('offers no collapsed escalate stand-in when every row is recovery-bound', () => {
renderChips(
twoQueued.map((message, i) => ({ ...message, recoverySteerId: `server-source-${i}` })),
{ steering: outboxSteering({ duringRunActive: true, canSteer: true }) },
);
expect(screen.queryByTestId('queued-escalate-newest')).toBeNull();
expect(document.querySelectorAll('[data-escalate-steer="queued"]')).toHaveLength(0);
});
/** The group stands in for its rows while collapsed, so the accessible list
* must still contain an item the rows themselves are unmounted. */
it('exposes the collapsed group as the list`s item, and the rows as a nested list', () => {
renderChips(twoQueued, { steering: outboxSteering() });
const list = screen.getByRole('list');
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(list).toContainElement(screen.getByTestId('queue-group'));
fireEvent.click(screen.getByTestId('queue-group-toggle'));
// Outer item (the group) plus its two nested rows.
expect(screen.getAllByRole('list')).toHaveLength(2);
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
/** The shortcut promises the newest waiting message, and a promotion moves
* that row to the FRONT so the target cannot be read off the array tail. */
it('keeps the escalate stand-in on the newest message after a promotion', () => {
const promoted = [
{ id: 'q2', text: 'newest, promoted', createdAt: 2, bumpedAt: 500 },
{ id: 'q1', text: 'older', createdAt: 1 },
];
renderChips(promoted, {
steering: outboxSteering({ duringRunActive: true, canSteer: true }),
});
fireEvent.click(screen.getByTestId('queued-escalate-newest'));
expect(mockSendQueuedNow).toHaveBeenCalledWith(expect.objectContaining({ id: 'q2' }), {
preempt: true,
});
});
it('keeps the hidden escalate stand-in out of the tab order', () => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
expect(screen.getByTestId('queued-escalate-newest')).toHaveAttribute('tabindex', '-1');
});
/** The shortcut's fallback takes the LAST matching control in the document,
* and the rows render in drain order so the stand-in has to be last in
* BOTH states, or a promotion silently retargets the shortcut. */
it.each([
['collapsed', false],
['expanded', true],
])('leaves the escalate stand-in last in the document while %s', (_label, expand) => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
if (expand) {
fireEvent.click(screen.getByTestId('queue-group-toggle'));
}
const controls = document.querySelectorAll('[data-escalate-steer="queued"]');
expect(controls).toHaveLength(expand ? 3 : 1);
expect(controls[controls.length - 1]).toHaveAttribute('data-testid', 'queued-escalate-newest');
});
it('still offers every row its own escalation control when expanded', () => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
expect(screen.getAllByTestId('queued-interrupt-now')).toHaveLength(2);
});
it('offers Send next on every row except the one already next', () => {
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const bumps = screen.getAllByTestId('queued-send-next');
expect(bumps).toHaveLength(1);
fireEvent.click(bumps[0]);
expect(mockBumpQueued).toHaveBeenCalledWith('q2');
});
/** A promotion lifts a row to the top of the promotions tier, which is still
* below every interrupt so with only interrupts ahead the button would
* advertise a no-op. */
it('offers no Send next when only an unmovable interrupt is ahead', () => {
renderChips(
[
{ id: 'armed', text: 'interrupt', createdAt: 2, priority: true },
{ id: 'plain', text: 'ordinary', createdAt: 1 },
],
{ steering: outboxSteering() },
);
fireEvent.click(screen.getByTestId('queue-group-toggle'));
expect(screen.queryByTestId('queued-send-next')).toBeNull();
});
it('still offers Send next past a movable row that sits behind an interrupt', () => {
renderChips(
[
{ id: 'armed', text: 'interrupt', createdAt: 3, priority: true },
{ id: 'first', text: 'ordinary one', createdAt: 1 },
{ id: 'second', text: 'ordinary two', createdAt: 2 },
],
{ steering: outboxSteering() },
);
fireEvent.click(screen.getByTestId('queue-group-toggle'));
// Only the last row: it can overtake `first`, while `first` can overtake
// nothing but the interrupt.
const bumps = screen.getAllByTestId('queued-send-next');
expect(bumps).toHaveLength(1);
fireEvent.click(bumps[0]);
expect(mockBumpQueued).toHaveBeenCalledWith('second');
});
/** Promotion eligibility is computed in one pass, so verify the rule still
* holds for a queue deep enough that the old per-row prefix scan mattered. */
it('offers Send next on every row past the first movable one, at depth', () => {
const deep = [
{ id: 'armed', text: 'interrupt', createdAt: 9, priority: true },
...Array.from({ length: 5 }, (_, i) => ({
id: `q${i}`,
text: `ordinary ${i}`,
createdAt: i,
})),
];
renderChips(deep, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
// The interrupt and the first ordinary row cannot overtake anything.
expect(screen.getAllByTestId('queued-send-next')).toHaveLength(4);
});
it('merges the batch into one turn', () => {
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
fireEvent.click(screen.getByTestId('queue-merge'));
expect(mockMergeQueued).toHaveBeenCalledTimes(1);
});
/** Folding reads the queue, so an emptied editor would carry the words the
* user just deleted into the merged message. */
it('refuses to merge while an inline edit is empty', () => {
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const rows = screen.getAllByTestId('queued-message-row');
fireEvent.click(rows[1].querySelector('span[title]') as HTMLElement);
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: ' ' } });
const merge = screen.getByTestId('queue-merge');
expect(merge).toBeDisabled();
fireEvent.click(merge);
expect(mockMergeQueued).not.toHaveBeenCalled();
// Resolving the edit releases it.
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: 'rewritten' } });
expect(screen.getByTestId('queue-merge')).not.toBeDisabled();
});
/** The queue still holds the pre-edit words, so handing them back would
* resurrect text the user visibly deleted. Emptying a row and removing it
* reads as "delete this". */
it('does not return stale words to the composer when removing an emptied row', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: '' } });
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRestoreToComposer).not.toHaveBeenCalled();
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('still returns the words when removing a row that was not being emptied', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRestoreToComposer).toHaveBeenCalledWith(
'first thought',
undefined,
{ quotes: undefined, manualSkills: undefined },
CONVO_ID,
);
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
/** Clear all folds the queue exactly as Merge does, so it takes the same
* standdown rather than being a documented exception. */
it('refuses to clear all while an inline edit is empty', () => {
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const rows = screen.getAllByTestId('queued-message-row');
fireEvent.click(rows[1].querySelector('span[title]') as HTMLElement);
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: '' } });
const clear = screen.getByTestId('queue-clear-all');
expect(clear).toBeDisabled();
fireEvent.click(clear);
expect(mockClearQueued).not.toHaveBeenCalled();
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: 'kept' } });
expect(screen.getByTestId('queue-clear-all')).not.toBeDisabled();
});
/** The composer box clips, so a deep queue needs its own scroll and the
* disclosure and the actions must stay outside it to remain reachable. */
it('scrolls the expanded rows without clipping the header or the actions', () => {
renderChips(
Array.from({ length: 8 }, (_, i) => ({ id: `q${i}`, text: `queued ${i}`, createdAt: i })),
{ steering: outboxSteering() },
);
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const list = screen.getByTestId('queue-rows');
// Named by its own count, so it does not duplicate the outer stack's label.
expect(list).toHaveAttribute('role', 'list');
expect(list).toHaveAccessibleName('com_ui_queue_count');
expect(list.className).toContain('overflow-y-auto');
expect(list.className).toContain('max-h-[35vh]');
// Outside the scroll container, so they cannot be clipped away.
expect(list).not.toContainElement(screen.getByTestId('queue-group-toggle'));
expect(list).not.toContainElement(screen.getByTestId('queue-merge'));
expect(list).not.toContainElement(screen.getByTestId('queue-clear-all'));
});
/** Collapsing unmounts the rows, so the disclosure's own text is the queue's
* only description an aria-label would overwrite it. */
it('announces the count and next-up preview as the disclosure name', () => {
renderChips(twoQueued, { steering: outboxSteering() });
const toggle = screen.getByTestId('queue-group-toggle');
expect(toggle).not.toHaveAttribute('aria-label');
expect(toggle).toHaveAccessibleName(/com_ui_queue_count/);
expect(toggle).toHaveAccessibleName(/com_ui_queue_next_up/);
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
it('refuses to merge while a recovered row holds a parked server source', () => {
renderChips([twoQueued[0], { ...twoQueued[1], recoverySteerId: 'server-source' }], {
steering: outboxSteering(),
});
fireEvent.click(screen.getByTestId('queue-group-toggle'));
expect(screen.getByTestId('queue-merge')).toBeDisabled();
fireEvent.click(screen.getByTestId('queue-merge'));
expect(mockMergeQueued).not.toHaveBeenCalled();
});
it('clear all hands the folded words back to the composer', async () => {
const folded: QueuedMessage = {
id: 'q1',
text: 'first thought\n\nsecond thought',
createdAt: 1,
};
mockClearQueued = jest.fn(async () => folded);
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
fireEvent.click(screen.getByTestId('queue-clear-all'));
await waitFor(() => {
expect(mockRestoreToComposer).toHaveBeenCalledWith(
'first thought\n\nsecond thought',
undefined,
{ quotes: undefined, manualSkills: undefined },
CONVO_ID,
);
});
expect(mockEnqueue).not.toHaveBeenCalled();
});
it('returns the words to the queue when the composer refuses them', async () => {
const folded: QueuedMessage = {
id: 'q1',
text: 'not lost',
createdAt: 1,
expectedPredecessorCreatedAt: 4242,
priority: true,
bumpedAt: 99,
};
mockClearQueued = jest.fn(async () => folded);
mockRestoreToComposer.mockReturnValueOnce(false);
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
fireEvent.click(screen.getByTestId('queue-clear-all'));
await waitFor(() => {
/** The ITEM goes back whole, so no field can be quietly dropped the
* fence and the interrupt tier were each lost once when this path
* rebuilt a row from parts. */
expect(mockRequeueCleared).toHaveBeenCalledWith([folded]);
});
expect(mockEnqueue).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_edit_queued' }),
);
});
});
describe('PendingSteerChips — queued row editing', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('rewrites a waiting message in place', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: 'sharper thought' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(mockUpdateQueuedText).toHaveBeenCalledWith('q1', 'sharper thought');
});
/** The front row draining drops the queue below the grouping threshold, which
* remounts the surviving row. Unmounting an input fires no `blur`, so the
* flush has to happen on the way out or the typing is silently lost. */
it('flushes an in-progress edit when the group collapses under it', () => {
renderChips(twoQueued, { steering: outboxSteering() });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const rows = screen.getAllByTestId('queued-message-row');
fireEvent.click(rows[1].querySelector('span[title]') as HTMLElement);
fireEvent.change(screen.getByTestId('queued-message-edit'), {
target: { value: 'edited while the front row drained' },
});
// The front row drains, leaving a lone message: the group gives way to a
// plain chip and the edited row is remounted elsewhere in the tree.
act(() => {
updateQueueForTest!(() => [twoQueued[1]]);
});
expect(screen.queryByTestId('queue-group')).toBeNull();
expect(mockUpdateQueuedText).toHaveBeenCalledWith('q2', 'edited while the front row drained');
});
/** `steering` is rebuilt at every run boundary. Whatever the write model,
* the invariant is that a run ending under an open editor must not make the
* edit final Escape still has to put the original words back. */
it('keeps an edit abandonable when the run state changes under it', () => {
const Harness = ({ live }: { live: boolean }) => (
<PendingSteerChips
conversationId={CONVO_ID}
steering={steeringStub(outboxSteering({ duringRunActive: live, canSteer: live }))}
onEditToComposer={mockEditToComposer}
onRestoreToComposer={mockRestoreToComposer}
/>
);
const tree = (live: boolean) => (
<RecoilRoot
initializeState={({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [twoQueued[0]]);
}}
>
<Harness live={live} />
</RecoilRoot>
);
const { rerender } = render(tree(true));
fireEvent.click(screen.getByText('first thought'));
fireEvent.change(screen.getByTestId('queued-message-edit'), {
target: { value: 'still typing' },
});
// The run ends: same row, a freshly built `steering`.
rerender(tree(false));
expect(screen.getByTestId('queued-message-edit')).toBeInTheDocument();
fireEvent.keyDown(screen.getByTestId('queued-message-edit'), { key: 'Escape' });
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'first thought');
});
/** A merged row carries paragraph breaks, and a single-line input flattens
* them on the first keystroke. */
it('keeps paragraph breaks while editing a merged row', () => {
renderChips([{ id: 'merged', text: 'first part\n\nsecond part', createdAt: 1 }], {
steering: outboxSteering(),
});
fireEvent.click(screen.getByTitle('com_ui_queue_edit_inline'));
const editor = screen.getByTestId('queued-message-edit');
expect(editor.tagName).toBe('TEXTAREA');
expect(editor).toHaveValue('first part\n\nsecond part');
fireEvent.change(editor, { target: { value: 'first part\n\nsecond part edited' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(mockUpdateQueuedText).toHaveBeenCalledWith('merged', 'first part\n\nsecond part edited');
});
/** The drain reads the atom, not the local draft, so an edit still only local
* when the row sends would go out as the old text. The window opening is the
* last safe moment to settle it. */
it('closes an open editor the moment a send becomes pending', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
fireEvent.change(screen.getByTestId('queued-message-edit'), {
target: { value: 'final wording' },
});
// Written through immediately, so whatever sends is what is displayed.
expect(mockUpdateQueuedText).toHaveBeenCalledWith('q1', 'final wording');
// The run ends and the grace window opens.
act(() => {
setHoldForTest!({
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
expect(mockUpdateQueuedText).toHaveBeenCalledWith('q1', 'final wording');
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
it('does not offer an edit while a send is already pending', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
act(() => {
setHoldForTest!({
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
fireEvent.click(screen.getByText('first thought'));
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
/** An IME candidate confirmation arrives as an unshifted Enter while
* composition is still active; committing there saves half-typed text. */
it.each([
['isComposing', { key: 'Enter', isComposing: true }],
['keyCode 229', { key: 'Enter', keyCode: 229 }],
])('ignores Enter reported as %s by an IME', (_label, init) => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: 'partial candidate' } });
fireEvent.keyDown(editor, init);
// Still editing: candidate confirmation stays inside the editor.
expect(screen.getByTestId('queued-message-edit')).toBeInTheDocument();
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'partial candidate');
});
it('adds a line with Shift+Enter instead of committing', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
fireEvent.keyDown(screen.getByTestId('queued-message-edit'), { key: 'Enter', shiftKey: true });
expect(mockUpdateQueuedText).not.toHaveBeenCalled();
expect(screen.getByTestId('queued-message-edit')).toBeInTheDocument();
});
/** The write-through refuses blank text, so an emptied editor is the one state
* where the row still holds words the user has visibly deleted. */
it('stands the senders down while the editor is empty', () => {
renderChips([twoQueued[0]], {
steering: outboxSteering({ duringRunActive: true, canSteer: true }),
});
fireEvent.click(screen.getByText('first thought'));
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: ' ' } });
expect(screen.getByText('com_ui_steer').closest('button')).toBeDisabled();
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
// Resolving the edit brings them back.
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: 'rewritten' } });
expect(screen.getByText('com_ui_steer').closest('button')).not.toBeDisabled();
});
/** The blank was never written, so closing the editor alone would leave the
* queue holding words the screen no longer shows and the drain sends the
* queue. Resolve it the way Escape does. */
it('restores the original when a send arrives on an emptied editor', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: '' } });
act(() => {
setHoldForTest!({
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'first thought');
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
/** The shortcut proxy targets this row but lives in the group, and the
* shortcut fires even while a textarea has focus so emptying the editor has
* to reach it too, or Ctrl/Cmd+Shift+. sends the deleted words. */
it('stands the shortcut proxy down for an empty edit, and brings it back', () => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
// The newest row is the proxy's target.
const rows = screen.getAllByTestId('queued-message-row');
fireEvent.click(rows[1].querySelector('span[title]') as HTMLElement);
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: '' } });
expect(screen.getByTestId('queued-escalate-newest')).toBeDisabled();
fireEvent.change(screen.getByTestId('queued-message-edit'), { target: { value: 'rewritten' } });
expect(screen.getByTestId('queued-escalate-newest')).not.toBeDisabled();
});
it('releases the empty-edit claim when the editor closes', () => {
renderChips(twoQueued, { steering: outboxSteering({ duringRunActive: true, canSteer: true }) });
fireEvent.click(screen.getByTestId('queue-group-toggle'));
const rows = screen.getAllByTestId('queued-message-row');
fireEvent.click(rows[1].querySelector('span[title]') as HTMLElement);
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: '' } });
expect(screen.getByTestId('queued-escalate-newest')).toBeDisabled();
// Escape restores the original words, so nothing is held back any more.
fireEvent.keyDown(editor, { key: 'Escape' });
expect(screen.getByTestId('queued-escalate-newest')).not.toBeDisabled();
});
/** The invariant that lets every reader trust the data: a blank row cannot
* outlive its editor, so a resting queue never holds one. */
it('restores the original when an emptied editor is closed rather than abandoned', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: '' } });
// Blank while the editor is open, which is what the senders read.
expect(JSON.parse(screen.getByTestId('queue-state').textContent ?? '[]')[0].text).toBe('');
fireEvent.blur(editor);
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'first thought');
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
it('trims the words it settles', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: ' spaced out ' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'spaced out');
});
it('puts the original words back on Escape', () => {
renderChips([twoQueued[0]], { steering: outboxSteering() });
fireEvent.click(screen.getByText('first thought'));
const editor = screen.getByTestId('queued-message-edit');
fireEvent.change(editor, { target: { value: 'discard me' } });
expect(mockUpdateQueuedText).toHaveBeenCalledWith('q1', 'discard me');
fireEvent.keyDown(editor, { key: 'Escape' });
expect(mockUpdateQueuedText).toHaveBeenLastCalledWith('q1', 'first thought');
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
/** Its parked source is matched by exact text server-side, so the words may
* only change after the discard ladder has downgraded the row. */
it('never edits a recovered row in place', () => {
renderChips([{ ...twoQueued[0], recoverySteerId: 'server-source' }], {
steering: outboxSteering(),
});
fireEvent.click(screen.getByText('first thought'));
expect(screen.queryByTestId('queued-message-edit')).toBeNull();
});
});
describe('PendingSteerChips — withheld automatic send', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const withHold = (queued: QueuedMessage[]) =>
render(
<RecoilRoot
initializeState={({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), queued);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: {
conversationId: CONVO_ID,
outcome: 'completed' as const,
endedAt: 1,
},
dueAt: Date.now() + 3000,
});
}}
>
<PendingSteerChips
conversationId={CONVO_ID}
steering={steeringStub(outboxSteering())}
onEditToComposer={mockEditToComposer}
onRestoreToComposer={mockRestoreToComposer}
/>
</RecoilRoot>,
);
it('announces the send and offers to take it back', () => {
withHold(twoQueued);
const banner = screen.getByTestId('queue-sending-banner');
expect(banner).toBeInTheDocument();
expect(banner.querySelector('[aria-live="polite"]')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('queue-undo-send'));
expect(mockCancelQueueDrain).toHaveBeenCalledTimes(1);
});
it('shows nothing to undo once the queue is empty', () => {
withHold([]);
expect(screen.queryByTestId('queue-sending-banner')).toBeNull();
});
});

View file

@ -3,7 +3,7 @@ import { Constants } from 'librechat-data-provider';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil';
import type { DrainAfterAbort, RunEnd, QueuedMessage } from '~/store/families';
import type { DrainAfterAbort, RunEnd, QueuedMessage, QueueDrainHold } from '~/store/families';
import useQueueDrain from '../useQueueDrain';
import store from '~/store';
@ -38,7 +38,7 @@ function setup(
store.queuedMessagesByConvoId(Constants.NEW_CONVO),
);
setters.setInterruptFlag = useSetRecoilState(store.drainAfterAbortByIndex(INDEX));
useQueueDrain(INDEX, activeConversationId, ask);
useQueueDrain(INDEX, activeConversationId, ask, { undoGraceMs: 0 });
return null;
}
@ -97,6 +97,39 @@ describe('useQueueDrain', () => {
expect(ask).toHaveBeenCalledWith({ text: 'first follow-up' }, emptyOverrides);
});
/** A row being edited holds exactly what is typed, blank included. Blank is
* not a message, and skipping to the row behind it would send out of order
* so this epoch drains nothing and the next run end picks the queue up. */
it('drains nothing when the front row is mid-edit and blank', async () => {
const { ask, setters } = setup(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'q1', text: ' ', createdAt: 1 },
queuedMessage('q2', 'behind it'),
]);
});
act(() => {
setters.setRunEnd!(runEnd());
});
await new Promise((resolve) => setTimeout(resolve, 25));
expect(ask).not.toHaveBeenCalled();
// The epoch was consumed, so the effect is not spinning; a later run end
// drains normally once the row has words again.
act(() => {
setters.setQueue!([
queuedMessage('q1', 'now it has words'),
queuedMessage('q2', 'behind it'),
]);
});
act(() => {
setters.setRunEnd!(runEnd({ generationCreatedAt: 77 }));
});
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1));
expect(ask).toHaveBeenCalledWith({ text: 'now it has words' }, emptyOverrides);
});
it('parks a mismatched signal instead of draining into the wrong conversation', async () => {
const { ask, setters } = setup(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [queuedMessage('q1', 'stay put')]);
@ -460,7 +493,7 @@ describe('useQueueDrain', () => {
({ activeConversationId }: { activeConversationId: string }) => {
setRunEnd = useSetRecoilState(store.runEndByIndex(INDEX));
setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(INDEX));
useQueueDrain(INDEX, activeConversationId, ask);
useQueueDrain(INDEX, activeConversationId, ask, { undoGraceMs: 0 });
return {
indexEnd: useRecoilValue(store.runEndByIndex(INDEX)),
parkedA: useRecoilValue(store.pendingRunEndByConvoId(CONVO_A)),
@ -599,7 +632,7 @@ describe('useQueueDrain', () => {
setters.setInterruptFlag = useSetRecoilState(store.drainAfterAbortByIndex(INDEX));
state.newConvoQueue = useRecoilValue(store.queuedMessagesByConvoId(Constants.NEW_CONVO));
state.parkedUnderOptimistic = useRecoilValue(store.pendingRunEndByConvoId(OPTIMISTIC_ID));
useQueueDrain(INDEX, Constants.NEW_CONVO as string, ask);
useQueueDrain(INDEX, Constants.NEW_CONVO as string, ask, { undoGraceMs: 0 });
return null;
}
@ -693,3 +726,279 @@ describe('useQueueDrain', () => {
expect(ask).toHaveBeenCalledTimes(1);
});
});
/** A short real window rather than fake timers: the drain rides react-query
* mutations and Recoil propagation, which fake timers would also freeze. */
const GRACE_MS = 30;
const AFTER_GRACE_MS = 120;
describe('useQueueDrain undo grace', () => {
function setupGraced(initialize?: (snapshot: MutableSnapshot) => void) {
const ask = jest.fn();
const state: {
setRunEnd?: (value: RunEnd | null) => void;
setInterruptFlag?: (value: DrainAfterAbort | false) => void;
setIsSubmitting?: (value: boolean) => void;
cancelHold?: () => void;
queue?: QueuedMessage[];
newConvoQueue?: QueuedMessage[];
hold?: QueueDrainHold | null;
} = {};
function Harness() {
state.setRunEnd = useSetRecoilState(store.runEndByIndex(INDEX));
state.setInterruptFlag = useSetRecoilState(store.drainAfterAbortByIndex(INDEX));
const setHold = useSetRecoilState(store.queueDrainHoldByConvoId(CONVO_ID));
/** Mirrors `useSteering.cancelQueueDrain`: a released epoch is
* tombstoned rather than merely dropped. */
state.cancelHold = () =>
setHold((held) => {
if (held == null) {
return null;
}
return held.status === 'released' ? { ...held, status: 'cancelled' } : null;
});
state.setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(INDEX));
state.queue = useRecoilValue(store.queuedMessagesByConvoId(CONVO_ID));
state.newConvoQueue = useRecoilValue(store.queuedMessagesByConvoId(Constants.NEW_CONVO));
state.hold = useRecoilValue(store.queueDrainHoldByConvoId(CONVO_ID));
useQueueDrain(INDEX, CONVO_ID, ask, { undoGraceMs: GRACE_MS });
return null;
}
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initialize}>
<Harness />
{children}
</RecoilRoot>
</QueryClientProvider>
);
renderHook(() => null, { wrapper });
return { ask, state };
}
const withQueue =
(...items: QueuedMessage[]) =>
({ set }: MutableSnapshot) => {
set(store.queuedMessagesByConvoId(CONVO_ID), items);
};
it('withholds the send for the grace window, then drains', async () => {
const { ask, state } = setupGraced(withQueue(queuedMessage('q1', 'hold me')));
act(() => {
state.setRunEnd!(runEnd());
});
// Held: the epoch is parked and the queue is untouched, so there is
// nothing to restore if the user cancels.
await waitFor(() => expect(state.hold).not.toBeNull());
expect(ask).not.toHaveBeenCalled();
expect(state.queue).toHaveLength(1);
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1), { timeout: AFTER_GRACE_MS * 4 });
expect(ask).toHaveBeenCalledWith({ text: 'hold me' }, emptyOverrides);
expect(state.hold).toBeNull();
});
it('cancelling the hold sends nothing and leaves the queue intact', async () => {
const { ask, state } = setupGraced(withQueue(queuedMessage('q1', 'never sent')));
act(() => {
state.setRunEnd!(runEnd());
});
await waitFor(() => expect(state.hold).not.toBeNull());
act(() => {
state.cancelHold!();
});
await new Promise((resolve) => setTimeout(resolve, AFTER_GRACE_MS));
expect(ask).not.toHaveBeenCalled();
expect(state.queue).toEqual([expect.objectContaining({ id: 'q1', text: 'never sent' })]);
});
it('skips the grace when interrupt & send armed the drain', async () => {
const { ask, state } = setupGraced(withQueue(queuedMessage('q1', 'now please')));
act(() => {
state.setInterruptFlag!({ conversationId: CONVO_ID, generationCreatedAt: 41 });
});
act(() => {
state.setRunEnd!(runEnd({ outcome: 'aborted' }));
});
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1));
expect(state.hold).toBeNull();
});
it('does not hold when nothing is queued', async () => {
const { ask, state } = setupGraced();
act(() => {
state.setRunEnd!(runEnd());
});
await new Promise((resolve) => setTimeout(resolve, AFTER_GRACE_MS));
expect(state.hold).toBeNull();
expect(ask).not.toHaveBeenCalled();
});
it('parks a second epoch arriving inside the window so its drain is not lost', async () => {
const { ask, state } = setupGraced(
withQueue(queuedMessage('q1', 'first'), queuedMessage('q2', 'second')),
);
act(() => {
state.setRunEnd!(runEnd());
});
await waitFor(() => expect(state.hold).not.toBeNull());
act(() => {
state.setRunEnd!(runEnd({ generationCreatedAt: 99 }));
});
// Both epochs drain their one message each: the second was parked rather
// than overwriting the held one.
await waitFor(() => expect(ask).toHaveBeenCalledTimes(2), { timeout: AFTER_GRACE_MS * 8 });
expect(ask.mock.calls.map(([payload]) => payload.text)).toEqual(['first', 'second']);
});
/** The composer reads the resolved id by run end, so rows left under the
* optimistic key would vanish from the group for the whole window. */
it('migrates a first turn`s queue to the resolved conversation before opening the window', async () => {
const { ask, state } = setupGraced(({ set }) => {
set(store.queuedMessagesByConvoId(Constants.NEW_CONVO), [
queuedMessage('q1', 'queued on the first turn'),
]);
});
act(() => {
state.setRunEnd!(runEnd({ startedAsNewConvo: true }));
});
await waitFor(() => expect(state.hold).not.toBeNull());
expect(state.newConvoQueue).toEqual([]);
expect(state.queue).toEqual([
expect.objectContaining({ id: 'q1', text: 'queued on the first turn' }),
]);
expect(ask).not.toHaveBeenCalled();
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1), { timeout: AFTER_GRACE_MS * 4 });
expect(ask).toHaveBeenCalledWith({ text: 'queued on the first turn' }, emptyOverrides);
});
/** A run started INSIDE the window blocks the drain, so the released epoch
* sits parked with the banner still up. Undo then has to neutralize that
* epoch, or it sends anyway the moment the run goes idle. */
it('undo after the window closed still cancels the parked epoch', async () => {
const { ask, state } = setupGraced(withQueue(queuedMessage('q1', 'must not send')));
act(() => {
state.setRunEnd!(runEnd());
});
await waitFor(() => expect(state.hold).not.toBeNull());
// The user starts another turn before the window closes.
act(() => {
state.setIsSubmitting!(true);
});
// The timer hands the epoch back, but the drain is blocked, so the hold
// stays — marked released, which is what stops Undo being advertised.
await waitFor(() => expect(state.hold?.status).toBe('released'), {
timeout: AFTER_GRACE_MS * 4,
});
expect(ask).not.toHaveBeenCalled();
act(() => {
state.cancelHold!();
});
expect(state.hold?.status).toBe('cancelled');
act(() => {
state.setIsSubmitting!(false);
});
await new Promise((resolve) => setTimeout(resolve, AFTER_GRACE_MS));
expect(ask).not.toHaveBeenCalled();
expect(state.queue).toHaveLength(1);
expect(state.hold).toBeNull();
});
/** The window may belong to an unrelated earlier epoch, and the user aborted
* a run to say this now waiting out that timer contradicts the rule that
* armed interrupts skip the grace. */
it('lets an armed interrupt drain straight through a standing window', async () => {
const { ask, state } = setupGraced(
withQueue(queuedMessage('q1', 'interrupt text'), queuedMessage('q2', 'ordinary follow-up')),
);
act(() => {
state.setRunEnd!(runEnd());
});
await waitFor(() => expect(state.hold).not.toBeNull());
expect(ask).not.toHaveBeenCalled();
// Interrupt & send: the arm plus the aborted epoch it belongs to.
act(() => {
state.setInterruptFlag!({ conversationId: CONVO_ID, generationCreatedAt: 99 });
});
act(() => {
state.setRunEnd!(runEnd({ outcome: 'aborted', generationCreatedAt: 99 }));
});
// No timer wait: the assertion resolves well inside the grace window.
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1));
expect(ask).toHaveBeenCalledWith({ text: 'interrupt text' }, emptyOverrides);
});
/** A hold owns exactly one epoch. Acting on a NEWER terminal event on its
* behalf loses that signal and leaves the older one to reopen a window. */
it('a cancelled hold discards only its own epoch', async () => {
const { ask, state } = setupGraced(
withQueue(queuedMessage('q1', 'first'), queuedMessage('q2', 'second')),
);
act(() => {
state.setRunEnd!(runEnd({ generationCreatedAt: 41 }));
});
await waitFor(() => expect(state.hold).not.toBeNull());
// A newer run finishes while the window is open, so its epoch queues up
// behind the held one; then the window closes and Undo lands.
act(() => {
state.setIsSubmitting!(true);
});
act(() => {
state.setRunEnd!(runEnd({ generationCreatedAt: 77 }));
});
await waitFor(() => expect(state.hold?.status).toBe('released'));
act(() => {
state.cancelHold!();
});
act(() => {
state.setIsSubmitting!(false);
});
// The cancelled epoch sends nothing; the NEWER completion is still honoured
// and drains one row, rather than being swallowed on the hold's behalf.
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1), { timeout: AFTER_GRACE_MS * 8 });
expect(ask).toHaveBeenCalledWith({ text: 'first' }, emptyOverrides);
expect(state.queue).toHaveLength(1);
});
it('drains immediately when the window already expired while away', async () => {
const { ask } = setupGraced(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [queuedMessage('q1', 'overdue')]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: runEnd(),
dueAt: Date.now() - 1000,
});
});
await waitFor(() => expect(ask).toHaveBeenCalledTimes(1));
expect(ask).toHaveBeenCalledWith({ text: 'overdue' }, emptyOverrides);
});
});

View file

@ -2420,3 +2420,358 @@ describe('useSteering', () => {
});
});
});
describe('useSteering — clear all', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function setupClearAll(initialize?: (snapshot: MutableSnapshot) => void) {
const sendNow = jest.fn();
const stopGenerating = jest.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={withActiveGeneration(initialize)}>{children}</RecoilRoot>
);
return renderHook(
() => ({
steering: useSteering({
index: 0,
conversationId: CONVO_ID,
conversation: agentsConversation,
isSubmitting: false,
answerModeActive: false,
sendNow,
stopGenerating,
}),
queue: useQueue(CONVO_ID),
drainHold: useRecoilValue(store.queueDrainHoldByConvoId(CONVO_ID)),
}),
{ wrapper },
);
}
/** Removing the last row leaves nothing to auto-send, and a surviving window
* could later drain a row queued under a NEWER run. */
it('retires the pending send when removal empties the queue', () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [{ id: 'q1', text: 'only row', createdAt: 1 }]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
act(() => {
result.current.steering.removeQueued('q1');
});
expect(result.current.queue).toEqual([]);
expect(result.current.drainHold).toBeNull();
});
/** Once the timer has handed the epoch back, the hold is the only thing that
* can neutralize it dropping it outright lets the parked epoch through. */
it('tombstones a released window rather than dropping it', () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [{ id: 'q1', text: 'only row', createdAt: 1 }]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 1,
status: 'released',
});
});
act(() => {
result.current.steering.removeQueued('q1');
});
expect(result.current.queue).toEqual([]);
expect(result.current.drainHold).toMatchObject({ status: 'cancelled' });
});
/** A withheld epoch grants ONE drain; expediting a row spends it, so leaving
* the window armed for the remaining rows would send twice for one run end
* and would do it even if that manual run was stopped. */
it('retires the pending send when a row is expedited, rows remaining or not', () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'q1', text: 'sent by hand', createdAt: 1 },
{ id: 'q2', text: 'still waiting', createdAt: 2 },
]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
act(() => {
result.current.steering.sendQueuedNow({ id: 'q1', text: 'sent by hand', createdAt: 1 });
});
expect(result.current.queue).toEqual([
expect.objectContaining({ id: 'q2', text: 'still waiting' }),
]);
expect(result.current.drainHold).toBeNull();
});
it('keeps the pending send while other rows remain', () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'q1', text: 'first', createdAt: 1 },
{ id: 'q2', text: 'second', createdAt: 2 },
]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 4_000_000_000_000,
});
});
act(() => {
result.current.steering.removeQueued('q1');
});
expect(result.current.queue).toHaveLength(1);
expect(result.current.drainHold).not.toBeNull();
});
it('folds the queue into one payload and empties it', async () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'q1', text: 'first', createdAt: 1 },
{ id: 'q2', text: 'second', createdAt: 2 },
]);
});
let cleared: QueuedMessage | null | undefined;
await act(async () => {
cleared = await result.current.steering.clearQueued();
});
expect(cleared?.text).toBe('first\n\nsecond');
expect(result.current.queue).toEqual([]);
});
/** The row must be OUT of the queue for the cancellation round trip: a run
* completing mid-flight would otherwise drain and send the very message the
* user is removing. */
it('holds a recovered row out of the queue while its source cancels', async () => {
let settleCancel: ((value: { removed: boolean }) => void) | undefined;
mockCancelSteer.mockImplementationOnce(
() =>
new Promise<{ removed: boolean }>((resolve) => {
settleCancel = resolve;
}),
);
const recovered = {
id: 'q1',
text: 'being removed',
createdAt: 1,
recoverySteerId: 'server-source',
recoveryClientSteerId: 'client-source',
};
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [recovered]);
});
let pending: Promise<boolean> | undefined;
act(() => {
pending = result.current.steering.discardQueued(recovered);
});
// Nothing for a drain to find while the receipt is in flight.
expect(result.current.queue).toEqual([]);
let settled = false;
await act(async () => {
settleCancel?.({ removed: true });
settled = (await pending) ?? false;
});
expect(settled).toBe(true);
// Back in its slot, downgraded: the parked copy is gone, so it is an
// ordinary local row the caller can edit or remove.
expect(result.current.queue).toEqual([
expect.objectContaining({ id: 'q1', text: 'being removed' }),
]);
expect(result.current.queue[0].recoverySteerId).toBeUndefined();
expect(result.current.queue[0].recoveryClientSteerId).toBeUndefined();
});
it('returns a recovered row untouched when its source refuses to cancel', async () => {
mockCancelSteer.mockResolvedValueOnce({ removed: false, generationProtocolVersion: 2 });
const recovered = {
id: 'q1',
text: 'stays put',
createdAt: 1,
recoverySteerId: 'server-source',
recoveryClientSteerId: 'client-source',
};
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [recovered]);
});
let settled = true;
await act(async () => {
settled = await result.current.steering.discardQueued(recovered);
});
expect(settled).toBe(false);
expect(result.current.queue).toEqual([
expect.objectContaining({ id: 'q1', recoverySteerId: 'server-source' }),
]);
});
/** Retiring twice must not resurrect a parked epoch: the second call has to
* leave an existing tombstone standing. */
it('keeps a cancelled tombstone across a second queue action', () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'q1', text: 'first', createdAt: 1 },
{ id: 'q2', text: 'second', createdAt: 2 },
]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: 1,
status: 'cancelled',
});
});
act(() => {
result.current.steering.removeQueued('q1');
});
expect(result.current.drainHold).toMatchObject({ status: 'cancelled' });
act(() => {
result.current.steering.removeQueued('q2');
});
expect(result.current.drainHold).toMatchObject({ status: 'cancelled' });
});
/** A standing window would otherwise fire on whatever the fallbacks put back
* sending exactly what the user just cleared. */
it('cancels a pending automatic send as part of clearing', async () => {
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [{ id: 'q1', text: 'cleared', createdAt: 1 }]);
set(store.queueDrainHoldByConvoId(CONVO_ID), {
runEnd: { conversationId: CONVO_ID, outcome: 'completed', endedAt: 1 },
dueAt: Date.now() + 3000,
});
});
await act(async () => {
await result.current.steering.clearQueued();
});
expect(result.current.drainHold).toBeNull();
});
/** The rows must leave the queue BEFORE the cancellations are awaited: a run
* ending mid-clear would otherwise drain one of the very messages the user
* asked to take back. */
it('empties the queue up front rather than during the cancellations', () => {
let settleCancel: ((value: { removed: boolean }) => void) | undefined;
mockCancelSteer.mockImplementationOnce(
() =>
new Promise<{ removed: boolean }>((resolve) => {
settleCancel = resolve;
}),
);
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{
id: 'q1',
text: 'recovered row',
createdAt: 1,
recoverySteerId: 'server-source',
recoveryClientSteerId: 'client-source',
},
{ id: 'q2', text: 'ordinary row', createdAt: 2 },
]);
});
act(() => {
void result.current.steering.clearQueued();
});
// Nothing left to drain while the cancellation is still in flight.
expect(result.current.queue).toEqual([]);
settleCancel?.({ removed: true });
});
it('puts a row back when its parked source refuses to cancel', async () => {
mockCancelSteer.mockResolvedValueOnce({ removed: false, generationProtocolVersion: 2 });
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{
id: 'q1',
text: 'stuck row',
createdAt: 1,
recoverySteerId: 'server-source',
recoveryClientSteerId: 'client-source',
},
{ id: 'q2', text: 'ordinary row', createdAt: 2 },
]);
});
let cleared: QueuedMessage | null | undefined;
await act(async () => {
cleared = await result.current.steering.clearQueued();
});
// Only the row the client fully owns went to the composer.
expect(cleared?.text).toBe('ordinary row');
expect(result.current.queue).toEqual([
expect.objectContaining({ id: 'q1', recoverySteerId: 'server-source' }),
]);
});
/** Cancelling a parked source is a round trip, so the queue can grow while
* clear-all is in flight; a message the user queued meanwhile is not part
* of what they asked to clear. */
it('leaves a message queued after the clear started untouched', async () => {
let settleCancel: ((value: { removed: boolean }) => void) | undefined;
mockCancelSteer.mockImplementationOnce(
() =>
new Promise<{ removed: boolean }>((resolve) => {
settleCancel = resolve;
}),
);
const { result } = setupClearAll(({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), [
{
id: 'q1',
text: 'recovered row',
createdAt: 1,
recoverySteerId: 'server-source',
recoveryClientSteerId: 'client-source',
},
{ id: 'q2', text: 'ordinary row', createdAt: 2 },
]);
});
let pending: Promise<QueuedMessage | null> | undefined;
act(() => {
pending = result.current.steering.clearQueued();
});
// The user queues something else while the cancellation is in flight.
act(() => {
result.current.steering.enqueue('queued mid-clear', { id: 'q3', createdAt: 3 });
});
let cleared: QueuedMessage | null | undefined;
await act(async () => {
settleCancel?.({ removed: true });
cleared = (await pending) ?? null;
});
expect(cleared?.text).toBe('recovered row\n\nordinary row');
expect(result.current.queue).toEqual([
expect.objectContaining({ id: 'q3', text: 'queued mid-clear' }),
]);
});
});

View file

@ -3,6 +3,7 @@ import { Constants } from 'librechat-data-provider';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { DrainAfterAbort, QueuedMessage, QueuedMessageOrigin, RunEnd } from '~/store/families';
import type { TAskFunction } from '~/common';
import { compareQueuedMessages, isSameRunEpoch, isSendableQueuedMessage } from '~/utils';
import { useMarkFilesUsageMutation } from '~/data-provider';
import store from '~/store';
@ -36,8 +37,8 @@ const batchFileIds = (fileIds: string[]): string[][] => {
return batches;
};
const compareQueuedMessages = (a: QueuedMessage, b: QueuedMessage): number =>
Number(b.priority ?? false) - Number(a.priority ?? false) || a.createdAt - b.createdAt;
/** Gmail-style window to take an automatic send back before it fires. */
export const QUEUE_UNDO_GRACE_MS = 3000;
/** Interrupt intent belongs to one generation, never to whichever terminal
* event happens to occupy the shared pane slot next. The NEW_CONVO alias is
@ -67,17 +68,27 @@ const matchesInterruptArm = (armed: DrainAfterAbort | false, end: RunEnd): boole
* the next, so multi-message queues send FIFO in sequence.
* - Migrates a queue keyed under `NEW_CONVO` to the real conversation id when
* the finished run started as a new-conversation submission.
* - Withholds an automatic send for `undoGraceMs`: the terminal epoch is
* parked in `queueDrainHoldByConvoId` and re-posted when the window closes.
* The queue itself is untouched while a hold stands, so "undo" is a pure
* cancel nothing to restore, nothing to lose. Manual sends and armed
* interrupts skip the grace: both are the user asking for it NOW.
*/
export default function useQueueDrain(
index: string | number,
activeConversationId: string | undefined,
ask: TAskFunction,
options?: { undoGraceMs?: number },
) {
const undoGraceMs = options?.undoGraceMs ?? QUEUE_UNDO_GRACE_MS;
const runEnd = useRecoilValue(store.runEndByIndex(index));
const parkedRunEnd = useRecoilValue(
store.pendingRunEndByConvoId(activeConversationId ?? Constants.NEW_CONVO),
);
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
const drainHold = useRecoilValue(
store.queueDrainHoldByConvoId(activeConversationId ?? Constants.NEW_CONVO),
);
const { mutate: markFilesUsage } = useMarkFilesUsageMutation();
const ownQueue = useRecoilValue(
store.queuedMessagesByConvoId(activeConversationId ?? Constants.NEW_CONVO),
@ -205,6 +216,27 @@ export default function useQueueDrain(
if (end == null) {
return null;
}
/** A standing window short-circuits BEFORE anything is consumed or
* written: both signal carriers retain unconsumed epochs, so leaving
* this one in place is lossless and mutating nothing is what keeps
* the effect from re-running itself into a loop while it waits.
*
* An armed interrupt is exempt. The user aborted a run to say this
* now, and the standing window may belong to an unrelated earlier
* epoch; making it wait out that timer contradicts the rule that
* armed interrupts skip the grace. Read-only, so the no-loop
* guarantee holds the arm is consumed on the normal path below. */
if (end.conversationId != null) {
const standing = snapshot
.getLoadable(store.queueDrainHoldByConvoId(end.conversationId))
.getValue();
const armedNow = snapshot.getLoadable(store.drainAfterAbortByIndex(index)).getValue();
const interruptWaiting =
matchesInterruptArm(armedNow, end) || end.interruptArmed === true;
if (standing != null && standing.dueAt > Date.now() && !interruptWaiting) {
return null;
}
}
// Consume the signal first — a hard double-fire guard even if the
// effect re-runs before Recoil propagates.
if (fromParked && activeConversationId) {
@ -241,7 +273,50 @@ export default function useQueueDrain(
: ownQueue;
const shouldDrain = end.outcome === 'completed' || interruptArmed;
const next = shouldDrain ? (merged[0] ?? null) : null;
const hold = snapshot.getLoadable(store.queueDrainHoldByConvoId(conversationId)).getValue();
const now = Date.now();
/** A hold owns exactly ONE epoch. A newer terminal event can be the one
* consumed above, and retiring the hold (or discarding that event) on
* its behalf would lose the newer signal and leave the older one to
* reopen a window so act only on the epoch the hold actually holds. */
const holdOwnsEnd = hold != null && isSameRunEpoch(hold.runEnd, end);
if (holdOwnsEnd) {
set(store.queueDrainHoldByConvoId(conversationId), null);
}
/** Undo landed after the timer handed this epoch back. The epoch is
* consumed above, so discarding it here is what makes the visible
* Undo mean what it says. */
if (holdOwnsEnd && hold?.status === 'cancelled') {
return null;
}
/** Withhold the epoch, never the queue: the rows stay exactly where
* they are, so cancelling is a no-op and a reload costs no text.
* A first turn's queue does have to migrate before the window opens
* the composer reads the resolved id by then, so leaving the rows
* under `NEW_CONVO` would blank the group for the whole window. */
if (
shouldDrain &&
!interruptArmed &&
hold == null &&
undoGraceMs > 0 &&
merged.length > 0
) {
if (shouldMigrate && newConvoQueue.length > 0) {
set(store.queuedMessagesByConvoId(Constants.NEW_CONVO), []);
set(store.queuedMessagesByConvoId(conversationId), merged);
}
set(store.queueDrainHoldByConvoId(conversationId), {
runEnd: end,
dueAt: now + undoGraceMs,
});
return null;
}
/** A row mid-edit can be blank, and blank is not a message. Skipping to
* the row behind it would send out of order, so this epoch simply
* drains nothing consumed above, so the effect cannot spin and the
* next run end picks the queue up again. */
const front = merged[0] ?? null;
const next = shouldDrain && front != null && isSendableQueuedMessage(front) ? front : null;
const remainder = next ? merged.slice(1) : merged;
if (shouldMigrate && newConvoQueue.length > 0) {
@ -267,6 +342,39 @@ export default function useQueueDrain(
[index, activeConversationId],
);
/** Hands the held epoch back to the drain through the same parked slot a
* returning navigation uses, so an expired window resumes identically
* whether or not the user stayed on the conversation. */
const releaseDrainHold = useRecoilCallback(
({ snapshot, set }) =>
(convoId: string) => {
const held = snapshot.getLoadable(store.queueDrainHoldByConvoId(convoId)).getValue();
if (held == null || held.status != null) {
return;
}
/** Marked before the epoch goes back so Undo stops being offered the
* moment the window closes a new run can block the drain, and an
* Undo that no longer undoes anything is worse than none. */
set(store.queueDrainHoldByConvoId(convoId), { ...held, status: 'released' });
set(store.pendingRunEndByConvoId(convoId), held.runEnd);
},
[],
);
useEffect(() => {
if (drainHold == null || drainHold.status != null) {
return;
}
const convoId = activeConversationId ?? Constants.NEW_CONVO;
const remaining = drainHold.dueAt - Date.now();
if (remaining <= 0) {
releaseDrainHold(convoId);
return;
}
const timer = setTimeout(() => releaseDrainHold(convoId), remaining);
return () => clearTimeout(timer);
}, [drainHold, activeConversationId, releaseDrainHold]);
const restoreQueued = useRecoilCallback(
({ set }) =>
(convoId: string, item: QueuedMessage) => {
@ -334,6 +442,10 @@ export default function useQueueDrain(
}, [
runEnd,
parkedRunEnd,
/** A standing window leaves later epochs untouched, so removing it has to
* bring the drain back to reconsider them otherwise a follow-up sits
* stranded until some unrelated dependency happens to change. */
drainHold,
isSubmitting,
activeConversationId,
parkForeignRunEnd,

View file

@ -5,7 +5,12 @@ import type { TPendingSteer } from 'librechat-data-provider';
import type { QueuedMessage, QueuedMessageOrigin } from '~/store/families';
import type { GenerationProtocolVersion } from '~/data-provider';
import type { SteerCarriedContext } from '~/utils';
import { appendAppliedSteerIds, carriedSteerContext, insertQueuedOrigin } from '~/utils';
import {
appendAppliedSteerIds,
carriedSteerContext,
compareQueuedMessages,
insertQueuedOrigin,
} from '~/utils';
import { fetchStreamStatus, getGenerationProtocolVersion } from '~/data-provider';
import store from '~/store';
@ -165,9 +170,7 @@ export default function useSteerConvert() {
* captured position instead of being re-minted under the server id. */
const ordinary = fresh.filter(({ queuedOrigin }) => queuedOrigin == null);
let merged: QueuedMessage[] = [...existing, ...ordinary.map(({ item }) => item)].sort(
(a, b) =>
Number(b.priority ?? false) - Number(a.priority ?? false) ||
a.createdAt - b.createdAt,
compareQueuedMessages,
);
for (const { queuedOrigin } of fresh) {
if (queuedOrigin != null) {

View file

@ -7,6 +7,18 @@ import type { TMessage, TConversation, TMessageContentParts } from 'librechat-da
import type { RunEnd, PendingSteer, QueuedMessage, QueuedMessageOrigin } from '~/store/families';
import type { GenerationProtocolVersion } from '~/data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import {
appendAppliedSteerIds,
bumpQueuedMessage,
carriedSteerContext,
clearAllDrafts,
compareQueuedMessages,
insertQueuedOrigin,
isMergeableQueuedMessage,
isSameRunEpoch,
isSendableQueuedMessage,
mergeQueuedMessages,
} from '~/utils';
import {
useGetMessagesByConvoId,
useCancelSteerMutation,
@ -14,12 +26,6 @@ import {
useMarkFilesUsageMutation,
supportsGenerationProtocolV2,
} from '~/data-provider';
import {
appendAppliedSteerIds,
carriedSteerContext,
clearAllDrafts,
insertQueuedOrigin,
} from '~/utils';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
import { useSetFilesToDelete } from '~/hooks/Files';
import useLocalize from '~/hooks/useLocalize';
@ -83,16 +89,6 @@ function isDefiniteSteerRejection(error: unknown): boolean {
);
}
function isSameRunEpoch(a: RunEnd | null, b: RunEnd): boolean {
if (a == null || a.conversationId !== b.conversationId) {
return false;
}
if (a.generationCreatedAt != null || b.generationCreatedAt != null) {
return a.generationCreatedAt === b.generationCreatedAt;
}
return a.endedAt === b.endedAt;
}
/** 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 {
@ -559,11 +555,7 @@ export default function useSteering({
...(options?.front && { priority: true }),
};
set(store.queuedMessagesByConvoId(queueKey), (prev) =>
[...prev, item].sort(
(a, b) =>
Number(b.priority ?? false) - Number(a.priority ?? false) ||
a.createdAt - b.createdAt,
),
[...prev, item].sort(compareQueuedMessages),
);
if (options?.skipUsageMark !== true) {
markQueuedFilesUsage(options?.files);
@ -633,53 +625,128 @@ export default function useSteering({
clearAllDrafts(Constants.PENDING_CONVO);
}, []);
const removeQueued = useRecoilCallback(
({ set }) =>
(id: string) => {
set(store.queuedMessagesByConvoId(queueKey), (prev) =>
prev.filter((item) => item.id !== id),
/**
* Stops a pending automatic send. A window still counting down is simply
* dropped, but once the timer has handed its epoch back the hold is the only
* thing that can neutralize it so that case leaves a tombstone the drain
* consumes, rather than a null that lets the parked epoch through.
*/
const retireDrainHold = useRecoilCallback(
({ snapshot, set }) =>
() => {
const held = snapshot.getLoadable(store.queueDrainHoldByConvoId(queueKey)).getValue();
if (held == null) {
return;
}
/** Already neutralized and waiting for its parked epoch to be consumed.
* Clearing it here would expose that epoch again, so a second queue
* action must leave the tombstone standing. */
if (held.status === 'cancelled') {
return;
}
set(
store.queueDrainHoldByConvoId(queueKey),
held.status === 'released' ? { ...held, status: 'cancelled' } : null,
);
},
[queueKey],
);
/** Once a parked source is discarded it must never be retried as a recovery
* attempt. Downgrade the row in place so a guarded Edit that finds a newer
* draft can leave the same words, context, identity, and queue position as
* an ordinary local follow-up. */
const downgradeQueuedRecovery = useRecoilCallback(
const removeQueued = useRecoilCallback(
({ snapshot, set }) =>
(id: string): boolean => {
(id: string) => {
const queue = snapshot.getLoadable(store.queuedMessagesByConvoId(queueKey)).getValue();
let found = false;
const next = queue.map((item) => {
if (item.id !== id) {
return item;
}
found = true;
const {
clientRequestId: _clientRequestId,
recoverySteerId: _recoverySteerId,
recoveryClientSteerId: _recoveryClientSteerId,
...ordinary
} = item;
return ordinary;
});
if (found) {
set(store.queuedMessagesByConvoId(queueKey), next);
const next = queue.filter((item) => item.id !== id);
if (next.length === queue.length) {
return;
}
return found;
set(store.queuedMessagesByConvoId(queueKey), next);
/** Nothing left to auto-send: retiring the window here stops a stale
* completion from later draining a row queued under a NEWER run. */
if (next.length === 0) {
retireDrainHold();
}
},
[queueKey, retireDrainHold],
);
/** "Send next". Order lives in the sort key rather than the array so the
* choice survives the next enqueue, drain, or leftover conversion. */
const bumpQueued = useRecoilCallback(
({ set }) =>
(id: string) => {
set(store.queuedMessagesByConvoId(queueKey), (prev) =>
bumpQueuedMessage(prev, id, Date.now()),
);
},
[queueKey],
);
/** In-place rewrite of a waiting row. Refuses a recovery-bound row: its
* parked source is matched by exact text server-side, so the words may only
* change after `discardQueued` has downgraded it to an ordinary row. */
const updateQueuedText = useRecoilCallback(
({ snapshot, set }) =>
(id: string, text: string): boolean => {
const queue = snapshot.getLoadable(store.queuedMessagesByConvoId(queueKey)).getValue();
const target = queue.find((item) => item.id === id);
if (target == null || !isMergeableQueuedMessage(target)) {
return false;
}
/** Blank is recorded, not refused. Refusing it was the root of a whole
* family of bugs: the queue kept words the screen no longer showed, and
* every reader the drain, Send now, the escalate shortcut, Merge,
* Clear all, the trash had to be taught about that disagreement.
* Now the row simply is not sendable, which each reader can see for
* itself via `isSendableQueuedMessage`. */
if (target.text === text) {
return true;
}
set(
store.queuedMessagesByConvoId(queueKey),
queue.map((item) => (item.id === id ? { ...item, text } : item)),
);
return true;
},
[queueKey],
);
/** Folds every ordinary waiting row into one turn. All-or-nothing: a batch
* holding a recovery-bound row is refused rather than partially folded, so
* no parked source is ever stranded behind a merged message. */
const mergeQueued = useRecoilCallback(
({ snapshot, set }) =>
(): boolean => {
const queue = snapshot.getLoadable(store.queuedMessagesByConvoId(queueKey)).getValue();
const merged = mergeQueuedMessages(queue);
if (merged == null) {
return false;
}
set(store.queuedMessagesByConvoId(queueKey), [merged]);
return true;
},
[queueKey],
);
/** Cancels a withheld automatic send. The queue was never popped, so this
* only drops the epoch the rows stay put for a manual send. If the timer
* already handed the epoch back (a new run can block the drain, leaving it
* parked), dropping the hold alone would let it drain anyway: tombstone it
* instead so the drain discards that epoch when it finally runs. */
/** Cancels a withheld automatic send. The queue was never popped, so the
* rows simply stay put for a manual send. */
const cancelQueueDrain = retireDrainHold;
/** Settle a queued row's terminal recovery source before an Edit/Remove.
* Ordinary rows have no server copy. A v2 leftover first uses its durable
* receipt to atomically discard the parked copy, then becomes an ordinary
* local row; the caller decides whether the live composer can consume it.
* The cancel deliberately omits the active epoch: the receipt belongs to the
* terminal source generation, not whichever run now occupies the chat. */
const discardQueued = useCallback(
/** Cancels a row's parked server copy by receipt, independent of whether the
* row is still in the queue clear-all takes its rows out first, so the
* downgrade step cannot be the thing that reports success. */
const cancelParkedSource = useCallback(
async (item: QueuedMessage): Promise<boolean> => {
if (item.recoverySteerId == null) {
return true;
@ -698,15 +765,99 @@ export default function useSteering({
showToast({ message: localize('com_ui_steer_cancel_failed'), status: 'error' });
return false;
}
return downgradeQueuedRecovery(item.id);
return true;
} catch {
showToast({ message: localize('com_ui_steer_cancel_failed'), status: 'error' });
return false;
}
},
[cancelSteer, conversationId, downgradeQueuedRecovery, hasRealConvoId, localize, showToast],
[cancelSteer, conversationId, hasRealConvoId, localize, showToast],
);
/** Empties the queue and hands back what it held. Removing up front is the
* point: the parked-source cancellations below are round trips, and rows
* left in place could be drained by a run ending mid-clear sending the
* very messages the user asked to take back. */
const takeAllQueued = useRecoilCallback(
({ snapshot, set }) =>
(): QueuedMessage[] => {
const queue = snapshot.getLoadable(store.queuedMessagesByConvoId(queueKey)).getValue();
if (queue.length > 0) {
set(store.queuedMessagesByConvoId(queueKey), []);
}
return queue;
},
[queueKey],
);
/**
* Puts rows back without clobbering anything queued in the meantime. Also the
* public requeue path for a cleared payload the composer refused: passing the
* ITEM back preserves every field it carries, where rebuilding one from parts
* has twice now lost a field that mattered (the predecessor fence, then the
* interrupt tier).
*/
const restoreQueuedBatch = useRecoilCallback(
({ set }) =>
(items: QueuedMessage[]) => {
if (items.length === 0) {
return;
}
set(store.queuedMessagesByConvoId(queueKey), (prev) => {
const held = new Set(prev.map((item) => item.id));
const missing = items.filter((item) => !held.has(item.id));
return missing.length === 0 ? prev : [...prev, ...missing].sort(compareQueuedMessages);
});
},
[queueKey],
);
/**
* "Clear all": hands the waiting words back to the user rather than deleting
* them. Rows leave the queue immediately, then each parked server copy is
* cancelled by receipt a surviving copy would be re-offered as a fresh row
* on the next status read. A row whose cancellation fails goes back in the
* queue; its words are never dropped on the floor. The caller owns the
* composer and decides whether it can accept the payload.
*/
const clearQueued = useCallback(async (): Promise<QueuedMessage | null> => {
/** Taking the words back cancels any pending automatic send outright. A
* standing window would otherwise fire on whatever the fallbacks put back
* a requeued payload the composer refused, or a row whose parked source
* would not cancel sending exactly what was just cleared. */
cancelQueueDrain();
const taken = takeAllQueued();
if (taken.length === 0) {
return null;
}
const cleared: QueuedMessage[] = [];
const stuck: QueuedMessage[] = [];
for (const item of taken) {
if (item.recoverySteerId == null) {
cleared.push(item);
continue;
}
if (!(await cancelParkedSource(item))) {
stuck.push(item);
continue;
}
/** The parked copy is gone, so the row is an ordinary local follow-up
* now and only ordinary rows can be folded together. */
const {
clientRequestId: _clientRequestId,
recoverySteerId: _recoverySteerId,
recoveryClientSteerId: _recoveryClientSteerId,
...ordinary
} = item;
cleared.push(ordinary);
}
restoreQueuedBatch(stuck);
if (cleared.length === 0) {
return null;
}
return cleared.length === 1 ? cleared[0] : mergeQueuedMessages(cleared);
}, [cancelQueueDrain, takeAllQueued, cancelParkedSource, restoreQueuedBatch]);
/** Capture-then-remove, including the item's neighbours, so any refused send
* or rejected steer can restore the ORIGINAL item in place even if the run
* drains an adjacent entry while the request is in flight. */
@ -736,11 +887,17 @@ export default function useSteering({
};
queuedOrigins.set(id, origin);
set(store.queuedMessagesByConvoId(queueKey), (prev) =>
prev.filter((item) => item.id !== id),
prev.filter((queued) => queued.id !== id),
);
/** A withheld epoch grants exactly ONE drain, and expediting a row is
* the user spending it themselves the manually started run's own
* completion drains whatever comes next. Leaving the window armed for
* any remaining rows would send twice for one run end, and would do it
* even when that manual run was stopped or errored. */
retireDrainHold();
return origin;
},
[queueKey],
[queueKey, retireDrainHold],
);
const releaseQueuedOrigin = useCallback(
@ -778,6 +935,40 @@ export default function useSteering({
[queueKey, releaseQueuedOrigin],
);
/**
* Settles a row's parked source for an Edit or Remove, holding the row OUT of
* the queue for the round trip. Leaving it in place let a run completing
* mid-cancellation drain and send the very message being removed the same
* hazard clear-all avoids by taking its rows up front. Cancellation refused:
* the row returns to its original slot untouched. Cancellation settled: it
* returns already downgraded, so what the caller does next (hand the words to
* the composer, remove it) sees an ordinary local row with no parked copy.
*/
const discardQueued = useCallback(
async (item: QueuedMessage): Promise<boolean> => {
if (item.recoverySteerId == null) {
return true;
}
const origin = takeQueued(item.id);
if (origin == null) {
return false;
}
if (!(await cancelParkedSource(item))) {
restoreQueued(origin);
return false;
}
const {
clientRequestId: _clientRequestId,
recoverySteerId: _recoverySteerId,
recoveryClientSteerId: _recoveryClientSteerId,
...ordinary
} = origin.item;
restoreQueued({ ...origin, item: ordinary });
return true;
},
[cancelParkedSource, restoreQueued, takeQueued],
);
/**
* Re-posts a spent run-end signal so the drain wakes and reconsiders the
* queue. No-op while a signal for THIS conversation is still armed: that
@ -794,7 +985,15 @@ export default function useSteering({
(convoId: string, end: RunEnd) => {
const indexArmed = snapshot.getLoadable(store.runEndByIndex(index)).getValue();
const parkedArmed = snapshot.getLoadable(store.pendingRunEndByConvoId(convoId)).getValue();
if (isSameRunEpoch(indexArmed, end) || isSameRunEpoch(parkedArmed, end)) {
/** A withheld epoch lives in the hold, in neither carrier without
* this the re-arm appends a copy and the release appends the held one,
* leaving two identical completions to drain two rows. */
const held = snapshot.getLoadable(store.queueDrainHoldByConvoId(convoId)).getValue();
if (
isSameRunEpoch(indexArmed, end) ||
isSameRunEpoch(parkedArmed, end) ||
isSameRunEpoch(held?.runEnd, end)
) {
return;
}
set(store.pendingRunEndByConvoId(convoId), end);
@ -1275,6 +1474,10 @@ export default function useSteering({
if (isSubmitting && (!duringRunActive || !canSteer || item.recoverySteerId != null)) {
return;
}
/** Nothing to send: the row is mid-edit and currently blank. */
if (!isSendableQueuedMessage(item)) {
return;
}
/** UI callers always find the item; a stale/direct caller has no original
* neighbours, so restoration falls back to the queue's priority split. */
const origin = takeQueued(item.id) ?? { item, beforeIds: [], afterIds: [] };
@ -1460,8 +1663,14 @@ export default function useSteering({
convertSteerToQueue,
queueReclaimedSteer,
enqueue,
requeueCleared: restoreQueuedBatch,
removeQueued,
discardQueued,
bumpQueued,
updateQueuedText,
mergeQueued,
clearQueued,
cancelQueueDrain,
sendQueuedNow,
interruptAndSend,
interruptSteer,
@ -1486,8 +1695,14 @@ export default function useSteering({
convertSteerToQueue,
queueReclaimedSteer,
enqueue,
restoreQueuedBatch,
removeQueued,
discardQueued,
bumpQueued,
updateQueuedText,
mergeQueued,
clearQueued,
cancelQueueDrain,
sendQueuedNow,
interruptAndSend,
interruptSteer,

View file

@ -1617,7 +1617,18 @@
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
"com_ui_question_unanswered": "No answer was given",
"com_ui_queue": "Queue",
"com_ui_queue_clear_all": "Clear all",
"com_ui_queue_count": "{{0}} queued",
"com_ui_queue_edit_empty": "Type a message, or press Escape to keep the original",
"com_ui_queue_edit_inline": "Edit this queued message",
"com_ui_queue_escalate_newest": "Interrupt the run and send the newest queued message",
"com_ui_queue_merge": "Merge into one message",
"com_ui_queue_merge_blocked": "Recovered messages can't be merged — remove or send them first",
"com_ui_queue_next_up": "Next: {{0}}",
"com_ui_queue_send": "Queue message for after the response",
"com_ui_queue_send_next": "Send this one next",
"com_ui_queue_sending": "Sending 1 of {{0}}…",
"com_ui_queue_undo_send": "Undo",
"com_ui_queued_attachment_count": "{{0}} attachments queued with this message",
"com_ui_queued_messages": "Queued messages",
"com_ui_quote_selections": "{{0}} selections",

View file

@ -387,6 +387,10 @@ export type QueuedMessage = {
/** Front-inserted by "Interrupt & send": stays ahead of chronologically
* older items when leftover steers are merged back into the queue. */
priority?: boolean;
/** Set by "Send next": promotes the row above ordinary follow-ups (but never
* above an interrupt), most recent promotion first. Absent on rows the user
* never reordered. */
bumpedAt?: number;
};
/** Snapshot of a queued item's logical position while it is temporarily sent
@ -408,6 +412,28 @@ const queuedMessagesByConvoId = atomFamily<QueuedMessage[], string>({
default: [],
});
/**
* A terminal epoch withheld from the drain for its undo grace. The queue is
* NOT popped while a hold stands only the epoch is parked so cancelling
* costs nothing and a reload during the window loses no text. `dueAt` is
* absolute so a hold survives leaving and re-entering the conversation.
*/
export type QueueDrainHold = {
runEnd: RunEnd;
dueAt: number;
/** Absent while the window is open and Undo is offered. `released` means the
* epoch was handed back to the drain (the window is over, so Undo is no
* longer advertised); `cancelled` neutralizes an epoch already handed back,
* for the frame in which Undo and release can race. */
status?: 'released' | 'cancelled';
};
/** Per-conversation undo grace on the next automatic drain. */
const queueDrainHoldByConvoId = atomFamily<QueueDrainHold | null, string>({
key: 'queueDrainHoldByConvoId',
default: null,
});
/**
* One-shot run-termination signal written by the SSE final/error handlers and
* consumed (reset to null) by `useQueueDrain`. Keyed by chat index like
@ -714,6 +740,7 @@ export default {
pendingQuotesByConvoId,
pendingSteersByConvoId,
queuedMessagesByConvoId,
queueDrainHoldByConvoId,
runEndByIndex,
pendingRunEndByConvoId,
drainAfterAbortByIndex,

View file

@ -20,3 +20,11 @@ export const steerOverlayHeightFamily = atomFamily((_conversationId: string) =>
* and the chip-derived check cannot see an arm until its response lands.
*/
export const escalatingSteerFamily = atomFamily((_conversationId: string) => atom<boolean>(false));
/**
* Whether the queued-message outbox is expanded, per queue key (so a brand-new
* chat and its resolved id are distinct, matching `useSteering.queueKey`).
* Collapsed by default and deliberately in memory only: the queue itself does
* not survive a reload, so persisting the disclosure would outlive its subject.
*/
export const queueExpandedFamily = atomFamily((_queueKey: string) => atom<boolean>(false));

View file

@ -1,13 +1,20 @@
import { Constants, ContentTypes } from 'librechat-data-provider';
import type { TMessage, TSteerAppliedEvent } from 'librechat-data-provider';
import type { QueuedMessage } from '~/store/families';
import {
isSameRunEpoch,
isSendableQueuedMessage,
getSteerPart,
applySteerPart,
resolveRunEndTarget,
bumpQueuedMessage,
mergeQueuedMessages,
findSteerMessageIndex,
appendAppliedSteerIds,
resolveAbortSteerTarget,
compareQueuedMessages,
insertQueuedOrigin,
isMergeableQueuedMessage,
} from '../steer';
const buildEvent = (overrides: Partial<TSteerAppliedEvent> = {}): TSteerAppliedEvent => ({
@ -218,3 +225,239 @@ describe('insertQueuedOrigin', () => {
]);
});
});
const queued = (overrides: Partial<QueuedMessage> & { id: string }): QueuedMessage => ({
text: `text ${overrides.id}`,
createdAt: 1,
...overrides,
});
describe('insertQueuedOrigin — promotions made while a row was away', () => {
/** B leaves for a steer attempt, C is promoted in its absence, then the
* attempt fails: B's captured neighbours must not put it back in front of
* the row the user explicitly chose to send next. */
it('never restores a row ahead of a promotion it did not know about', () => {
const origin = {
item: { id: 'b', text: 'send B', createdAt: 2 },
beforeIds: ['a'],
afterIds: ['c'],
};
const promoted = bumpQueuedMessage(
[
{ id: 'a', text: 'send A', createdAt: 1 },
{ id: 'c', text: 'send C', createdAt: 3 },
],
'c',
500,
);
expect(insertQueuedOrigin(promoted, origin).map(({ id }) => id)).toEqual(['c', 'a', 'b']);
});
it('still honours captured neighbours when nothing was promoted', () => {
const origin = {
item: { id: 'b', text: 'send B', createdAt: 2 },
beforeIds: ['a'],
afterIds: ['c'],
};
const queue = [
{ id: 'a', text: 'send A', createdAt: 1 },
{ id: 'c', text: 'send C', createdAt: 3 },
];
expect(insertQueuedOrigin(queue, origin).map(({ id }) => id)).toEqual(['a', 'b', 'c']);
});
});
describe('isSameRunEpoch', () => {
const epoch = { conversationId: 'c1', outcome: 'completed' as const, endedAt: 10 };
it('matches on the generation stamp when either side carries one', () => {
expect(
isSameRunEpoch(
{ ...epoch, generationCreatedAt: 41 },
{ ...epoch, generationCreatedAt: 41, endedAt: 99 },
),
).toBe(true);
expect(
isSameRunEpoch({ ...epoch, generationCreatedAt: 41 }, { ...epoch, generationCreatedAt: 42 }),
).toBe(false);
expect(isSameRunEpoch({ ...epoch, generationCreatedAt: 41 }, epoch)).toBe(false);
});
it('falls back to the termination time, and never matches across conversations', () => {
expect(isSameRunEpoch(epoch, { ...epoch })).toBe(true);
expect(isSameRunEpoch(epoch, { ...epoch, endedAt: 11 })).toBe(false);
expect(isSameRunEpoch(epoch, { ...epoch, conversationId: 'c2' })).toBe(false);
});
it('treats an absent signal as no match', () => {
expect(isSameRunEpoch(null, epoch)).toBe(false);
expect(isSameRunEpoch(undefined, epoch)).toBe(false);
});
});
describe('compareQueuedMessages', () => {
const ids = (items: QueuedMessage[]) =>
[...items].sort(compareQueuedMessages).map(({ id }) => id);
it('orders by enqueue time by default', () => {
expect(ids([queued({ id: 'b', createdAt: 2 }), queued({ id: 'a', createdAt: 1 })])).toEqual([
'a',
'b',
]);
});
it('keeps interrupt front-inserts ahead of chronologically older rows', () => {
expect(
ids([
queued({ id: 'old', createdAt: 1 }),
queued({ id: 'armed', createdAt: 9, priority: true }),
]),
).toEqual(['armed', 'old']);
});
it('drains the most recently promoted row first', () => {
const items = [
queued({ id: 'first-bump', createdAt: 1, bumpedAt: 100 }),
queued({ id: 'second-bump', createdAt: 2, bumpedAt: 200 }),
queued({ id: 'plain', createdAt: 3 }),
];
expect(ids(items)).toEqual(['second-bump', 'first-bump', 'plain']);
});
/** An interrupt aborted a run to be said now, so it outranks a promotion
* whichever came first and two interrupts stay FIFO among themselves. */
it('keeps interrupts ahead of promotions in both arrival orders', () => {
expect(
ids([
queued({ id: 'bumped', createdAt: 1, bumpedAt: 500 }),
queued({ id: 'armed', createdAt: 5, priority: true }),
]),
).toEqual(['armed', 'bumped']);
expect(
ids([
queued({ id: 'armed', createdAt: 1, priority: true }),
queued({ id: 'bumped', createdAt: 5, bumpedAt: 500 }),
]),
).toEqual(['armed', 'bumped']);
});
it('keeps two interrupts FIFO, as sequential instructions rather than rivals', () => {
expect(
ids([
queued({ id: 'second', createdAt: 20, priority: true }),
queued({ id: 'first', createdAt: 10, priority: true }),
]),
).toEqual(['first', 'second']);
});
});
describe('bumpQueuedMessage', () => {
const queue = [
queued({ id: 'a', createdAt: 1 }),
queued({ id: 'b', createdAt: 2 }),
queued({ id: 'c', createdAt: 3 }),
];
it('moves the chosen row to the front and preserves the rest of the order', () => {
expect(bumpQueuedMessage(queue, 'c', 500).map(({ id }) => id)).toEqual(['c', 'a', 'b']);
});
it('survives a later enqueue, because the order lives in the sort key', () => {
const bumped = bumpQueuedMessage(queue, 'c', 500);
const afterEnqueue = [...bumped, queued({ id: 'd', createdAt: 4 })].sort(compareQueuedMessages);
expect(afterEnqueue.map(({ id }) => id)).toEqual(['c', 'a', 'b', 'd']);
});
it('returns the same array when the id is gone', () => {
expect(bumpQueuedMessage(queue, 'missing', 500)).toBe(queue);
});
});
describe('isMergeableQueuedMessage', () => {
it('rejects rows bound to a parked server source', () => {
expect(isMergeableQueuedMessage(queued({ id: 'a', recoverySteerId: 'steer-1' }))).toBe(false);
expect(isMergeableQueuedMessage(queued({ id: 'b', clientRequestId: 'req-1' }))).toBe(false);
});
it('accepts ordinary local rows', () => {
expect(isMergeableQueuedMessage(queued({ id: 'c' }))).toBe(true);
});
});
describe('isSendableQueuedMessage', () => {
it('is the whole rule: a row with words is sendable, one without is not', () => {
expect(isSendableQueuedMessage(queued({ id: 'a', text: 'words' }))).toBe(true);
expect(isSendableQueuedMessage(queued({ id: 'b', text: '' }))).toBe(false);
expect(isSendableQueuedMessage(queued({ id: 'c', text: ' \n ' }))).toBe(false);
});
});
describe('mergeQueuedMessages', () => {
it('refuses a batch containing a row being emptied, so a fold cannot bake in a blank', () => {
expect(
mergeQueuedMessages([queued({ id: 'a', text: 'kept' }), queued({ id: 'b', text: ' ' })]),
).toBeNull();
});
it('joins texts in drain order as paragraphs', () => {
const merged = mergeQueuedMessages([
queued({ id: 'a', text: 'first thought', createdAt: 1 }),
queued({ id: 'b', text: 'second thought', createdAt: 2 }),
]);
expect(merged?.text).toBe('first thought\n\nsecond thought');
});
it('keeps the front row`s identity and position', () => {
const merged = mergeQueuedMessages([
queued({ id: 'a', createdAt: 7, priority: true, bumpedAt: 90 }),
queued({ id: 'b', createdAt: 8 }),
]);
expect(merged).toMatchObject({ id: 'a', createdAt: 7, priority: true, bumpedAt: 90 });
});
it('unions attachments, quotes and skill picks without duplicates', () => {
const merged = mergeQueuedMessages([
queued({
id: 'a',
files: [{ file_id: 'f1', filepath: '/f1', type: 'image/png' }],
quotes: ['q1'],
manualSkills: ['s1'],
}),
queued({
id: 'b',
files: [
{ file_id: 'f1', filepath: '/f1', type: 'image/png' },
{ file_id: 'f2', filepath: '/f2', type: 'image/png' },
],
quotes: ['q1', 'q2'],
manualSkills: ['s2'],
}),
]);
expect(merged?.files?.map((file) => file.file_id)).toEqual(['f1', 'f2']);
expect(merged?.quotes).toEqual(['q1', 'q2']);
expect(merged?.manualSkills).toEqual(['s1', 's2']);
});
it('takes the latest predecessor fence so the merged turn is gated on everything it followed', () => {
const merged = mergeQueuedMessages([
queued({ id: 'a', expectedPredecessorCreatedAt: 100 }),
queued({ id: 'b', expectedPredecessorCreatedAt: 400 }),
]);
expect(merged?.expectedPredecessorCreatedAt).toBe(400);
});
it('refuses to merge a recovery-bound row, whose parked source must be discarded first', () => {
expect(
mergeQueuedMessages([
queued({ id: 'a' }),
queued({ id: 'b', recoverySteerId: 'steer-1', recoveryClientSteerId: 'local-1' }),
]),
).toBeNull();
});
it('refuses a batch of fewer than two rows', () => {
expect(mergeQueuedMessages([queued({ id: 'a' })])).toBeNull();
});
});

View file

@ -5,7 +5,7 @@ import type {
TSteerAppliedEvent,
TMessageContentParts,
} from 'librechat-data-provider';
import type { QueuedMessage, QueuedMessageOrigin } from '~/store/families';
import type { QueuedMessage, QueuedMessageOrigin, RunEnd } from '~/store/families';
type SteerPart = Extract<TMessageContentParts, { type: ContentTypes.STEER }>;
@ -141,6 +141,21 @@ export function appendAppliedSteerIds(prev: string[], steerIds: string[]): strin
return [...prev, ...fresh].slice(-APPLIED_STEER_IDS_CAP);
}
/**
* Whether two terminal signals are the same generation. Conversation ids are
* reused by later turns, so identity is the exact epoch: the generation stamp
* when either side carries one, else the termination time.
*/
export function isSameRunEpoch(a: RunEnd | null | undefined, b: RunEnd): boolean {
if (a == null || a.conversationId !== b.conversationId) {
return false;
}
if (a.generationCreatedAt != null || b.generationCreatedAt != null) {
return a.generationCreatedAt === b.generationCreatedAt;
}
return a.endedAt === b.endedAt;
}
export type SteerCarriedContext = { quotes?: string[]; manualSkills?: string[] };
/** Quotes/skill picks are client-only (a steer never sends them to the
@ -155,6 +170,137 @@ export function carriedSteerContext(source?: SteerCarriedContext): SteerCarriedC
};
}
/**
* The queue's one ordering rule, shared by every writer so a reorder cannot be
* undone by the next enqueue re-sorting on a different key. Three tiers:
*
* 0. "Interrupt & send" front-inserts. They aborted a run to be said now, so
* they outrank everything and stay FIFO among themselves, because two
* interrupts are sequential instructions, not competing ones.
* 1. "Send next" promotions, most recently promoted first.
* 2. Everything else, in enqueue order.
*/
function orderingTier(item: QueuedMessage): number {
if (item.priority === true) {
return 0;
}
return item.bumpedAt != null ? 1 : 2;
}
export function compareQueuedMessages(a: QueuedMessage, b: QueuedMessage): number {
const tier = orderingTier(a) - orderingTier(b);
if (tier !== 0) {
return tier;
}
if (a.bumpedAt != null && b.bumpedAt != null && a.bumpedAt !== b.bumpedAt) {
return b.bumpedAt - a.bumpedAt;
}
return a.createdAt - b.createdAt;
}
/**
* Whether a row can be sent as it stands. A row being edited holds exactly what
* the user has typed, blank included, so "there is nothing to send" is a fact
* about the row rather than a state every sender has to be told about
* separately. Blank rows exist only while their editor is open closing it
* restores the pre-edit words so this never describes a resting queue.
*/
export function isSendableQueuedMessage(item: QueuedMessage): boolean {
return item.text.trim().length > 0;
}
/** Queued texts are separate thoughts, so a join reads as paragraphs. */
export const QUEUED_TEXT_SEPARATOR = '\n\n';
/**
* A row bound to a parked server source cannot be merged or rewritten: the
* recovery turn must reproduce that source's exact text and file set, and its
* user-row identity is the receipt id, so two sources cannot become one turn.
* Discarding the parked copy first (`discardQueued`) downgrades the row and
* makes it mergeable like any local follow-up.
*/
export function isMergeableQueuedMessage(item: QueuedMessage): boolean {
return item.recoverySteerId == null && item.clientRequestId == null;
}
const dedupeFiles = (items: QueuedMessage[]): TMessage['files'] => {
const byId = new Map<string, NonNullable<TMessage['files']>[number]>();
for (const item of items) {
for (const file of item.files ?? []) {
const key = file.file_id ?? file.filepath ?? '';
if (key.length > 0 && !byId.has(key)) {
byId.set(key, file);
}
}
}
return byId.size > 0 ? [...byId.values()] : undefined;
};
const dedupeStrings = (values: Array<string[] | undefined>): string[] | undefined => {
const merged = new Set<string>();
for (const list of values) {
for (const value of list ?? []) {
merged.add(value);
}
}
return merged.size > 0 ? [...merged] : undefined;
};
/**
* Folds queued rows into the one turn they were probably always meant to be
* each extra turn costs a full model round trip and context replay. Keeps the
* front-most row's identity and position so the merged message drains exactly
* where the first of its parts would have, and takes the LATEST predecessor
* fence of the batch so the merged turn is gated on everything it followed.
*/
export function mergeQueuedMessages(items: QueuedMessage[]): QueuedMessage | null {
if (items.length < 2 || items.some((item) => !isMergeableQueuedMessage(item))) {
return null;
}
/** Folding a row mid-edit would bake in whatever is on screen at that instant,
* blank included. Self-protecting here so no caller has to remember. */
if (items.some((item) => !isSendableQueuedMessage(item))) {
return null;
}
const [first] = items;
const files = dedupeFiles(items);
const quotes = dedupeStrings(items.map((item) => item.quotes));
const manualSkills = dedupeStrings(items.map((item) => item.manualSkills));
const fences = items
.map((item) => item.expectedPredecessorCreatedAt)
.filter((value): value is number => value != null);
return {
id: first.id,
text: items.map((item) => item.text).join(QUEUED_TEXT_SEPARATOR),
createdAt: first.createdAt,
...(first.priority === true && { priority: true }),
...(first.bumpedAt != null && { bumpedAt: first.bumpedAt }),
...(fences.length > 0 && { expectedPredecessorCreatedAt: Math.max(...fences) }),
...(files && { files }),
...(quotes && { quotes }),
...(manualSkills && { manualSkills }),
};
}
/**
* Promotes an item to drain next, leaving every other item's order intact.
* Deliberately does NOT set `priority`: that tier belongs to interrupts, which
* aborted a run and must stay ahead of a promotion made before them.
*/
export function bumpQueuedMessage(
queue: QueuedMessage[],
id: string,
bumpedAt: number,
): QueuedMessage[] {
const index = queue.findIndex((item) => item.id === id);
if (index < 0) {
return queue;
}
const next = [...queue];
next[index] = { ...next[index], bumpedAt };
return next.sort(compareQueuedMessages);
}
/** Restore a temporarily removed queue item using surviving original
* neighbours first, then the queue's durable priority/time ordering. */
export function insertQueuedOrigin(
@ -199,18 +345,19 @@ export function insertQueuedOrigin(
}
}
if (index < 0) {
index = queue.findIndex((queued) => {
const itemPriority = Number(restoredItem.priority === true);
const queuedPriority = Number(queued.priority === true);
return (
itemPriority > queuedPriority ||
(itemPriority === queuedPriority && restoredItem.createdAt < queued.createdAt)
);
});
index = queue.findIndex((queued) => compareQueuedMessages(restoredItem, queued) < 0);
if (index < 0) {
index = queue.length;
}
}
/** Captured neighbours describe where the row WAS, and the queue may have
* been reordered while it was away a "Send this one next" promotion must
* not be undone by a restore landing in front of it. Walk the neighbour
* slot forward past anything that now outranks the row; with no promotion
* the neighbour slot already satisfies the comparator and this is a no-op. */
while (index < queue.length && compareQueuedMessages(queue[index], restoredItem) < 0) {
index += 1;
}
return [...queue.slice(0, index), restoredItem, ...queue.slice(index)];
}

View file

@ -23,6 +23,10 @@ module.exports = {
authPageWidth: '370px',
},
keyframes: {
'queue-undo-grace': {
from: { transform: 'scaleX(1)' },
to: { transform: 'scaleX(0)' },
},
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
@ -62,6 +66,8 @@ module.exports = {
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
'shortcut-shake': 'shortcut-shake 0.25s ease-in-out',
/* Duration is overridden inline with the window's remaining time. */
'queue-undo-grace': 'queue-undo-grace 3s linear forwards',
},
colors: {
gray: {

View file

@ -0,0 +1,184 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import {
MOCK_ENDPOINTS,
NEW_CHAT_PATH,
messagesView,
replyPrompt,
replyText,
selectMockEndpoint,
sendMessage,
} from './helpers';
const uniqueLabel = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
const messageInput = (page: Page) => page.getByRole('textbox', { name: 'Message input' });
const duringRunSendButton = (page: Page) => page.getByTestId('during-run-send-button');
const queuedRows = (page: Page) => page.getByTestId('queued-message-row');
const messageTurns = (page: Page) => messagesView(page).locator('.message-render');
const outboxGroup = (page: Page) => page.getByTestId('queue-group');
const outboxToggle = (page: Page) => page.getByTestId('queue-group-toggle');
async function establishConversation(page: Page, label: string) {
const setup = await sendMessage(page, replyPrompt(label));
expect(setup.ok()).toBeTruthy();
await expect(messagesView(page).getByText(replyText(label))).toBeVisible({ timeout: 30000 });
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}$/, { timeout: 15000 });
}
/** Fill the composer mid-run: the during-run send button must take the
* send/stop slot (it becomes the form submit target for Enter). */
async function typeDuringRun(page: Page, text: string) {
const input = messageInput(page);
await input.click();
await input.fill(text);
await expect(duringRunSendButton(page)).toBeVisible({ timeout: 5000 });
}
/** Ctrl/Cmd+Enter routes to the non-default during-run action (queue). */
async function queueDuringRun(page: Page, text: string) {
await typeDuringRun(page, text);
await messageInput(page).press('ControlOrMeta+Enter');
}
/** Starts a slow run and parks `texts` in the queue behind it. */
async function queueBehindSlowRun(page: Page, label: string, texts: string[]) {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
await establishConversation(page, `outbox-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 });
for (const text of texts) {
await queueDuringRun(page, text);
}
}
test.describe('queued message outbox', () => {
/**
* Two or more waiting messages collapse into one row so the composer stops
* growing with queue depth; the managed list lives in the expansion.
*/
test('groups queued messages behind one row and expands to manage them', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('outbox-group');
const first = `First queued ${label}`;
const second = `Second queued ${label}`;
await queueBehindSlowRun(page, label, [first, second]);
// Collapsed: one summary row, the individual rows are not mounted.
await expect(outboxGroup(page)).toBeVisible({ timeout: 10000 });
await expect(outboxToggle(page)).toHaveAttribute('aria-expanded', 'false');
await expect(outboxToggle(page)).toContainText('2 queued');
await expect(outboxToggle(page)).toContainText(first);
await expect(queuedRows(page)).toHaveCount(0);
await outboxToggle(page).click();
await expect(outboxToggle(page)).toHaveAttribute('aria-expanded', 'true');
await expect(queuedRows(page)).toHaveCount(2);
await expect(queuedRows(page).nth(0)).toContainText(first);
await expect(queuedRows(page).nth(1)).toContainText(second);
// Both still drain, in order, after the run completes.
await expect(queuedRows(page)).toHaveCount(0, { timeout: 90000 });
const turns = messageTurns(page);
await expect(turns.filter({ hasText: first })).toHaveCount(1, { timeout: 60000 });
await expect(turns.filter({ hasText: second })).toHaveCount(1, { timeout: 60000 });
});
/** "Send next" promotes a waiting row past the one in front of it. */
test('send next changes which queued message drains first', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('outbox-bump');
const first = `Typed first ${label}`;
const second = `Wanted first ${label}`;
await queueBehindSlowRun(page, label, [first, second]);
await outboxToggle(page).click();
await expect(queuedRows(page)).toHaveCount(2);
// The front row is already next, so only the second offers the promotion.
const bump = page.getByTestId('queued-send-next');
await expect(bump).toHaveCount(1);
await bump.click();
await expect(queuedRows(page).nth(0)).toContainText(second);
await expect(queuedRows(page).nth(1)).toContainText(first);
// Drain order follows the rendered order: the promoted message sends first.
await expect(queuedRows(page)).toHaveCount(0, { timeout: 90000 });
const userTurns = messagesView(page).locator('.user-turn');
await expect(userTurns.filter({ hasText: first })).toHaveCount(1, { timeout: 60000 });
const sent = await userTurns.allInnerTexts();
const promotedIndex = sent.findIndex((text) => text.includes(second));
const typedIndex = sent.findIndex((text) => text.includes(first));
expect(promotedIndex).toBeGreaterThanOrEqual(0);
expect(promotedIndex).toBeLessThan(typedIndex);
});
/**
* Burst-typed fragments are usually one thought, and every extra turn costs a
* full model round trip merging sends them as a single turn.
*/
test('merge folds the queue into a single turn', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('outbox-merge');
const first = `Fragment one ${label}`;
const second = `Fragment two ${label}`;
await queueBehindSlowRun(page, label, [first, second]);
await outboxToggle(page).click();
await page.getByTestId('queue-merge').click();
// One row now holds both texts, so the group collapses back to a chip.
await expect(outboxGroup(page)).toHaveCount(0);
const merged = queuedRows(page);
await expect(merged).toHaveCount(1);
await expect(merged).toContainText(first);
await expect(queuedRows(page)).toHaveCount(0, { timeout: 90000 });
// ONE user turn carries both fragments, rather than two turns.
const mergedTurn = messagesView(page).locator('.user-turn').filter({ hasText: first });
await expect(mergedTurn).toHaveCount(1, { timeout: 60000 });
await expect(mergedTurn).toContainText(second);
});
/**
* The automatic send is withheld briefly at run end. Undo cancels it without
* touching the queue, so the words stay put for a manual send.
*/
test('undo takes back the automatic send and keeps the message queued', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('outbox-undo');
const queued = `Held back ${label}`;
await queueBehindSlowRun(page, label, [queued]);
await expect(queuedRows(page)).toHaveCount(1, { timeout: 10000 });
// The banner only appears once the run ends and the send is pending. The
// window is deliberately short, so wait on the mutation rather than a
// polling assertion and click the moment it lands.
const undo = page.getByTestId('queue-undo-send');
await undo.waitFor({ state: 'visible', timeout: 90000 });
await undo.click({ timeout: 2000 });
await expect(page.getByTestId('queue-sending-banner')).toHaveCount(0);
// Still queued, and no follow-up turn was sent.
await expect(queuedRows(page)).toHaveCount(1);
await expect(queuedRows(page)).toContainText(queued);
await expect(messagesView(page).locator('.user-turn').filter({ hasText: queued })).toHaveCount(
0,
);
// The words are recoverable on demand: Send now starts the turn.
await page.getByText('Send now', { exact: true }).click();
await expect(messagesView(page).locator('.user-turn').filter({ hasText: queued })).toHaveCount(
1,
{ timeout: 60000 },
);
});
});