mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
ea70803ab0
commit
440b097d99
4 changed files with 306 additions and 85 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue