From c0e167b3d49c95e9c5684aa80ee3854099c5dbcc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 4 Aug 2026 08:27:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B1=20refactor:=20Make=20Queue=20Senda?= =?UTF-8?q?bility=20A=20Property=20Of=20The=20Data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the empty-edit standdown as a mechanism, rather than teaching it to a seventh consumer. The root cause was that `updateQueuedText` REFUSED blank text. That made the queue deliberately disagree with the screen — the row still held the words the user had just deleted — and every reader had to be told about the disagreement through a shared atom: the row's own send and escalate controls, the offscreen shortcut proxy, the drain, Merge, Clear all, and the trash. Six sites, each found separately, each a chance to miss the seventh. Blank is now recorded. A row holds exactly what is typed, so "there is nothing to send" is visible in the row itself via one predicate, `isSendableQueuedMessage`, which each reader evaluates on data it already has. `queueEmptyEditFamily` and its claim/release lifecycle are deleted. What keeps that safe is a single invariant: a blank row cannot outlive its editor. Leaving the editor settles the row — trimmed if there are words, back to the pre-edit text if there are not — by any exit: blur, Enter, Escape, a pending send, or the remount that a collapsing group causes. So a resting queue never holds a blank row, and `mergeQueuedMessages` refuses one anyway. The drain refuses a blank front row by the same predicate, consuming its epoch so the effect cannot spin, and picks the queue up on the next run end rather than sending the row behind it out of order. --- .../components/Chat/Input/QueuedOutbox.tsx | 90 +++++++++---------- .../__tests__/PendingSteerChips.test.tsx | 39 +++++++- .../Chat/__tests__/useQueueDrain.spec.tsx | 33 +++++++ client/src/hooks/Chat/useQueueDrain.ts | 9 +- client/src/hooks/Chat/useSteering.ts | 18 +++- client/src/store/steer.ts | 10 --- client/src/utils/__tests__/steer.spec.ts | 15 ++++ client/src/utils/steer.ts | 16 ++++ 8 files changed, 162 insertions(+), 68 deletions(-) diff --git a/client/src/components/Chat/Input/QueuedOutbox.tsx b/client/src/components/Chat/Input/QueuedOutbox.tsx index 228a54814b..8e54d3e3fc 100644 --- a/client/src/components/Chat/Input/QueuedOutbox.tsx +++ b/client/src/components/Chat/Input/QueuedOutbox.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Zap, Send, @@ -25,8 +25,8 @@ import { useDefaultToggleEntry, useInterruptToggleEntry, } from './SteerMenu'; -import { queueEmptyEditFamily, queueExpandedFamily } from '~/store/steer'; -import { isMergeableQueuedMessage } from '~/utils'; +import { isMergeableQueuedMessage, isSendableQueuedMessage } from '~/utils'; +import { queueExpandedFamily } from '~/store/steer'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -132,27 +132,9 @@ function QueuedRowBase({ * during the pause is exactly the discoverability gap this button fixes. */ const showEscalate = !isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer)); - /** An emptied editor is the one state where the queue and the screen disagree - * by construction: the write-through refuses blank text, so the row still - * holds the previous words. Sending from there would send what the user just - * deleted, so the senders stand down until the edit resolves either way. */ - const emptyEdit = draft != null && draft.trim().length === 0; - /** Published so the group's shortcut proxy can stand down too: it targets - * this row but lives outside it, and the shortcut is allowed while a - * textarea has focus. */ - const setEmptyEditId = useSetAtom(queueEmptyEditFamily(steering.queueKey)); - const claimEmptyEdit = useCallback( - (isEmpty: boolean) => { - setEmptyEditId((prev) => { - if (isEmpty) { - return message.id; - } - return prev === message.id ? null : prev; - }); - }, - [message.id, setEmptyEditId], - ); - useEffect(() => () => claimEmptyEdit(false), [claimEmptyEdit]); + /** 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. */ @@ -162,6 +144,9 @@ function QueuedRowBase({ } }, [editing]); + const draftRef = useRef(null); + draftRef.current = draft; + const beginEdit = useCallback(() => { originalRef.current = message.text; setDraft(message.text); @@ -180,28 +165,37 @@ function QueuedRowBase({ const editDraft = useCallback( (value: string) => { setDraft(value); - claimEmptyEdit(value.trim().length === 0); steering.updateQueuedText(message.id, value); }, - [claimEmptyEdit, message.id, steering], + [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(() => { - claimEmptyEdit(false); + const typed = draftRef.current; + if (typed == null) { + return; + } + steering.updateQueuedText( + message.id, + typed.trim().length === 0 ? originalRef.current : typed.trim(), + ); setDraft(null); - }, [claimEmptyEdit]); + }, [message.id, steering]); const abandonEdit = useCallback(() => { steering.updateQueuedText(message.id, originalRef.current); - closeEdit(); - }, [closeEdit, message.id, steering]); + setDraft(null); + }, [message.id, steering]); - const emptyEditRef = useRef(false); - emptyEditRef.current = emptyEdit; const closeEditRef = useRef(closeEdit); closeEditRef.current = closeEdit; - const abandonEditRef = useRef(abandonEdit); - abandonEditRef.current = abandonEdit; + /** 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 @@ -209,14 +203,9 @@ function QueuedRowBase({ * closing it alone would leave the queue holding words the screen no longer * shows, and the drain would send those. */ useEffect(() => { - if (!sendPending) { - return; + if (sendPending) { + closeEditRef.current(); } - if (emptyEditRef.current) { - abandonEditRef.current(); - return; - } - closeEditRef.current(); }, [sendPending]); /** An ordinary row is a living draft: it is rewritten in place. A recovered @@ -317,8 +306,8 @@ function QueuedRowBase({