feat: reorder queued messages by drag or arrow keys

The rail is the send order, and it was fixed at the moment each message
was written. Rows carry a handle now; the keys work without a pointer and
announce the new position.
This commit is contained in:
Marco Beretta 2026-07-27 04:26:23 +02:00
parent ea70803ab0
commit 440b097d99
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 306 additions and 85 deletions

View file

@ -1,15 +1,27 @@
import { memo } from 'react';
import { memo, useRef, useState, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { X, Clock, Pencil } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import { useMediaQuery } from '@librechat/client';
import { X, Clock, Pencil, GripVertical } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const ICON_BTN =
'shrink-0 rounded-full p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
const DRAG_TYPE = 'queued-message';
/** Shared by every handle, so the keys are stated once per rail. */
const REORDER_HINT_ID = 'composer-queue-reorder-hint';
interface DragItem {
id: string;
index: number;
}
/** Restores a message's text into the composer, or refuses (false) when the
* composer is occupied / on another chat see `restoreReclaimedSteer` in
* `ChatForm`. Used by the queue rail's edit/trash actions. */
@ -31,12 +43,204 @@ interface QueueProps {
onRestoreToComposer: RestoreToComposer;
}
interface QueueRowProps {
message: QueuedMessage;
index: number;
total: number;
steering: SteeringControls;
conversationId: string;
onEditToComposer: QueueProps['onEditToComposer'];
onRestoreToComposer: RestoreToComposer;
onAnnounce: (message: string) => void;
}
function QueueRow({
message,
index,
total,
steering,
conversationId,
onEditToComposer,
onRestoreToComposer,
onAnnounce,
}: QueueRowProps) {
const localize = useLocalize();
const rowRef = useRef<HTMLDivElement>(null);
const gripRef = useRef<HTMLButtonElement>(null);
const { reorderQueued } = steering;
/* The queue is sent in order, so one message cannot be ahead of or behind
itself: the handle only means something once there is somewhere to go. */
const reorderable = total > 1;
/* HTML5 drag needs a hover-capable pointer; on touch it would take the
gesture away from scrolling the rail. Arrow keys reorder either way. */
const canDrag = useMediaQuery('(hover: hover)');
const [, drop] = useDrop<DragItem>({
accept: DRAG_TYPE,
hover(item, monitor) {
const bounds = rowRef.current?.getBoundingClientRect();
const pointer = monitor.getClientOffset();
if (item.index === index || bounds == null || pointer == null) {
return;
}
/* Swap on the crossing of the midpoint rather than on entry, so a row
does not flip back and forth under a pointer resting on its edge. */
const middle = (bounds.bottom - bounds.top) / 2;
const offset = pointer.y - bounds.top;
if (item.index < index ? offset < middle : offset > middle) {
return;
}
reorderQueued(item.id, index);
item.index = index;
},
});
const [{ isDragging }, drag] = useDrag({
type: DRAG_TYPE,
canDrag: reorderable && canDrag,
item: (): DragItem => ({ id: message.id, index }),
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
});
const move = useCallback(
(offset: number) => {
const target = index + offset;
if (target < 0 || target >= total) {
return;
}
reorderQueued(message.id, target);
onAnnounce(localize('com_ui_queue_moved', { 0: String(target + 1), 1: String(total) }));
/* The row travels with its message, so the handle keeps the focus it
had; the position it reports is what changed. */
gripRef.current?.focus();
},
[index, total, reorderQueued, message.id, onAnnounce, localize],
);
drop(rowRef);
drag(gripRef);
const fileCount = message.files?.length ?? 0;
/** Paused-on-approval: `sendQueuedNow` can neither steer (no live reply
* accepting input) nor send (a run is still active), so it would just
* re-queue the message with nothing visible happening. */
const sendDisabled = steering.duringRunActive && !steering.canSteer;
return (
<div
ref={rowRef}
role="listitem"
data-testid="queued-message-row"
className={cn(
'flex items-center gap-2 border-b border-border-light px-3 py-1.5 text-sm last:border-b-0',
isDragging && 'opacity-40',
)}
>
{reorderable ? (
<button
ref={gripRef}
type="button"
data-testid="queued-message-grip"
aria-label={localize('com_ui_queue_reorder', {
0: String(index + 1),
1: String(total),
})}
/* A handle announces what it is but not how to work it, and the keys
are the only way through it without a pointer. */
aria-describedby={REORDER_HINT_ID}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return;
}
/* Both keys scroll the rail's container otherwise, which would
chase the row the press just moved. */
event.preventDefault();
move(event.key === 'ArrowUp' ? -1 : 1);
}}
className={cn(
ICON_BTN,
'cursor-grab p-0.5 active:cursor-grabbing',
canDrag && 'touch-none',
)}
>
<GripVertical className="h-4 w-4" aria-hidden="true" />
</button>
) : (
<Clock className="h-3.5 w-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<span className="min-w-0 flex-1 truncate text-text-primary" title={message.text}>
{message.text}
</span>
{fileCount > 0 && (
<span
className="shrink-0 text-xs text-text-secondary"
title={localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
>
<span className="sr-only">
{localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
</span>
<span aria-hidden="true">
{localize(fileCount === 1 ? 'com_ui_attachment_count_one' : 'com_ui_attachment_count', {
count: fileCount,
})}
</span>
</span>
)}
<button
type="button"
disabled={sendDisabled}
aria-disabled={sendDisabled}
title={sendDisabled ? localize('com_ui_send_now_paused') : undefined}
onClick={() => steering.sendQueuedNow(message)}
className="shrink-0 rounded-lg px-2 py-0.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy disabled:cursor-not-allowed disabled:opacity-40"
>
{localize('com_ui_send_now')}
</button>
<button
type="button"
aria-label={localize('com_ui_edit_message')}
onClick={() => {
steering.removeQueued(message.id);
onEditToComposer(message.text, message.files, {
quotes: message.quotes,
manualSkills: message.manualSkills,
});
}}
className={ICON_BTN}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
onClick={() => {
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
steering.removeQueued(message.id);
}}
className={ICON_BTN}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
}
/**
* Messages waiting for the current reply to finish, as a rail tucked behind
* the composer's top edge. One row per message, three visible actions and no
* overflow menu: the menu is where the old design hid a global preference
* among item actions.
*
* The rail is also the running order: whatever sits at the top is what gets
* sent when the reply lands, so rows can be dragged past one another by the
* handle, or moved with the arrow keys while it holds focus. Only the drag is
* pointer-bound, which is why the keys are on the handle rather than under it.
*
* Send-now resolves itself: `sendQueuedNow` steers into the live reply when
* the run accepts it, or sends right away once nothing is running. While a
* run is paused on a pending approval it would only re-queue the message at
@ -46,6 +250,9 @@ interface QueueProps {
function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer }: QueueProps) {
const localize = useLocalize();
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
/* Spoken only for the keys. A drag reorders on every crossing, and a reader
narrating each one would be behind the pointer and in the way of it. */
const [announcement, setAnnouncement] = useState('');
if (queued.length === 0) {
return null;
@ -60,83 +267,27 @@ function Queue({ steering, conversationId, onEditToComposer, onRestoreToComposer
the composer rather than a second composer stacked on it. */
className="mx-3 flex flex-col overflow-hidden rounded-t-2xl border border-b-0 border-border-light bg-surface-secondary"
>
{queued.map((message: QueuedMessage) => {
const fileCount = message.files?.length ?? 0;
/** Paused-on-approval: `sendQueuedNow` can neither steer (no live
* reply accepting input) nor send (a run is still active), so it
* would just re-queue the message with nothing visible happening. */
const sendDisabled = steering.duringRunActive && !steering.canSteer;
return (
<div
key={message.id}
role="listitem"
data-testid="queued-message-row"
className="flex items-center gap-2 border-b border-border-light px-3 py-1.5 text-sm last:border-b-0"
>
<Clock className="h-3.5 w-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-text-primary" title={message.text}>
{message.text}
</span>
{fileCount > 0 && (
<span
className="shrink-0 text-xs text-text-secondary"
title={localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
>
<span className="sr-only">
{localize('com_ui_queued_attachment_count', { 0: String(fileCount) })}
</span>
<span aria-hidden="true">
{localize(
fileCount === 1 ? 'com_ui_attachment_count_one' : 'com_ui_attachment_count',
{ count: fileCount },
)}
</span>
</span>
)}
<button
type="button"
disabled={sendDisabled}
aria-disabled={sendDisabled}
title={sendDisabled ? localize('com_ui_send_now_paused') : undefined}
onClick={() => steering.sendQueuedNow(message)}
className="shrink-0 rounded-lg px-2 py-0.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy disabled:cursor-not-allowed disabled:opacity-40"
>
{localize('com_ui_send_now')}
</button>
<button
type="button"
aria-label={localize('com_ui_edit_message')}
onClick={() => {
steering.removeQueued(message.id);
onEditToComposer(message.text, message.files, {
quotes: message.quotes,
manualSkills: message.manualSkills,
});
}}
className={ICON_BTN}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
onClick={() => {
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
steering.removeQueued(message.id);
}}
className={ICON_BTN}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
})}
{queued.map((message: QueuedMessage, index: number) => (
<QueueRow
key={message.id}
message={message}
index={index}
total={queued.length}
steering={steering}
conversationId={conversationId}
onEditToComposer={onEditToComposer}
onRestoreToComposer={onRestoreToComposer}
onAnnounce={setAnnouncement}
/>
))}
{queued.length > 1 && (
<span id={REORDER_HINT_ID} className="sr-only">
{localize('com_ui_queue_reorder_hint')}
</span>
)}
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
}

View file

@ -1,5 +1,7 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { render, screen, within, fireEvent } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
@ -19,6 +21,7 @@ jest.mock('~/hooks', () => ({
const CONVO_ID = 'convo-1';
const mockSendQueuedNow = jest.fn();
const mockRemoveQueued = jest.fn();
const mockReorderQueued = jest.fn();
const steering = {
queueKey: CONVO_ID,
@ -26,6 +29,7 @@ const steering = {
canSteer: true,
sendQueuedNow: mockSendQueuedNow,
removeQueued: mockRemoveQueued,
reorderQueued: mockReorderQueued,
} as unknown as SteeringControls;
const pausedSteering = { ...steering, canSteer: false } as unknown as SteeringControls;
@ -43,12 +47,15 @@ const queued = (over: Partial<QueuedMessage> = {}): QueuedMessage =>
function renderQueue(items: QueuedMessage[], steeringOverride: SteeringControls = steering) {
return render(
<RecoilRoot initializeState={({ set }) => set(store.queuedMessagesByConvoId(CONVO_ID), items)}>
<Queue
steering={steeringOverride}
conversationId={CONVO_ID}
onEditToComposer={jest.fn()}
onRestoreToComposer={jest.fn()}
/>
{/* Mirrors `App`, which mounts the provider around the whole tree. */}
<DndProvider backend={HTML5Backend}>
<Queue
steering={steeringOverride}
conversationId={CONVO_ID}
onEditToComposer={jest.fn()}
onRestoreToComposer={jest.fn()}
/>
</DndProvider>
</RecoilRoot>,
);
}
@ -89,6 +96,37 @@ describe('Queue', () => {
expect(mockSendQueuedNow).not.toHaveBeenCalled();
});
it('moves a message down the queue with the arrow keys', () => {
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2' })]);
const grips = screen.getAllByTestId('queued-message-grip');
fireEvent.keyDown(grips[0], { key: 'ArrowDown' });
expect(mockReorderQueued).toHaveBeenCalledWith('q1', 1);
fireEvent.keyDown(grips[1], { key: 'ArrowUp' });
expect(mockReorderQueued).toHaveBeenCalledWith('q2', 0);
});
it('refuses to move a message past either end of the queue', () => {
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2' })]);
const grips = screen.getAllByTestId('queued-message-grip');
fireEvent.keyDown(grips[0], { key: 'ArrowUp' });
fireEvent.keyDown(grips[1], { key: 'ArrowDown' });
expect(mockReorderQueued).not.toHaveBeenCalled();
});
it('announces where a moved message landed', () => {
renderQueue([queued({ id: 'q1' }), queued({ id: 'q2' })]);
fireEvent.keyDown(screen.getAllByTestId('queued-message-grip')[0], { key: 'ArrowDown' });
expect(screen.getByRole('status')).toHaveTextContent('com_ui_queue_moved:2');
});
it('offers no handle when the only message has nowhere to go', () => {
renderQueue([queued()]);
expect(screen.queryByTestId('queued-message-grip')).not.toBeInTheDocument();
});
it('shows an attachment count when files ride along', () => {
renderQueue([queued({ files: [{ file_id: 'f1' }, { file_id: 'f2' }] as never })]);
const attachmentLabel = screen.getByText('com_ui_attachment_count:2');

View file

@ -691,6 +691,33 @@ export default function useSteering({
[queueKey],
);
/**
* Moves a queued message to another place in the queue. The drain always
* takes the head, so the order of this list is the order the messages will be
* sent in: reordering it is the only way to change which one goes next
* without sending or deleting anything.
*
* Addressed by id rather than by the index the caller is holding, which a
* drain can invalidate between the drag starting and the drop landing.
*/
const reorderQueued = useRecoilCallback(
({ set }) =>
(id: string, targetIndex: number) => {
set(store.queuedMessagesByConvoId(queueKey), (prev) => {
const from = prev.findIndex((item) => item.id === id);
const to = Math.min(Math.max(targetIndex, 0), prev.length - 1);
if (from === -1 || from === to) {
return prev;
}
const next = prev.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
},
[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
@ -1510,6 +1537,7 @@ export default function useSteering({
enqueue,
removeQueued,
discardQueued,
reorderQueued,
sendQueuedNow,
interruptAndSend,
interruptSteer,
@ -1536,6 +1564,7 @@ export default function useSteering({
enqueue,
removeQueued,
discardQueued,
reorderQueued,
sendQueuedNow,
interruptAndSend,
interruptSteer,

View file

@ -1616,6 +1616,9 @@
"com_ui_question_unanswered": "No answer was given",
"com_ui_queue": "Queue",
"com_ui_queue_send": "Queue message for after the response",
"com_ui_queue_moved": "Moved to {{0}} of {{1}}",
"com_ui_queue_reorder": "Reorder message, {{0}} of {{1}}",
"com_ui_queue_reorder_hint": "Use the up and down arrow keys to move this message in the queue.",
"com_ui_queued_attachment_count": "{{0}} attachments queued with this message",
"com_ui_queued_messages": "Queued messages",
"com_ui_quote_selections": "{{0}} selections",