diff --git a/client/src/components/Chat/Input/Composer/Queue.tsx b/client/src/components/Chat/Input/Composer/Queue.tsx index 0b4e2158a4..919b7d5b35 100644 --- a/client/src/components/Chat/Input/Composer/Queue.tsx +++ b/client/src/components/Chat/Input/Composer/Queue.tsx @@ -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(null); + const gripRef = useRef(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({ + 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 ( +
+ {reorderable ? ( + + ) : ( +
+ ); +} + /** * 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 ( -
-
- ); - })} + {queued.map((message: QueuedMessage, index: number) => ( + + ))} + {queued.length > 1 && ( + + {localize('com_ui_queue_reorder_hint')} + + )} + + {announcement} + ); } diff --git a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx index 5dd091fe46..0cdc83e3e8 100644 --- a/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx +++ b/client/src/components/Chat/Input/Composer/__tests__/Queue.spec.tsx @@ -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 => function renderQueue(items: QueuedMessage[], steeringOverride: SteeringControls = steering) { return render( set(store.queuedMessagesByConvoId(CONVO_ID), items)}> - + {/* Mirrors `App`, which mounts the provider around the whole tree. */} + + + , ); } @@ -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'); diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts index 41b397e814..4ee15c68e4 100644 --- a/client/src/hooks/Chat/useSteering.ts +++ b/client/src/hooks/Chat/useSteering.ts @@ -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, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 0d7687e524..8d1cc247da 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",