diff --git a/client/src/components/Chat/Input/QueuedOutbox.tsx b/client/src/components/Chat/Input/QueuedOutbox.tsx index c7175d639b..5af6b61880 100644 --- a/client/src/components/Chat/Input/QueuedOutbox.tsx +++ b/client/src/components/Chat/Input/QueuedOutbox.tsx @@ -458,6 +458,15 @@ function QueuedOutboxBase({ const [expanded, setExpanded] = useAtom(queueExpandedFamily(steering.queueKey)); const emptyEditId = useAtomValue(queueEmptyEditFamily(steering.queueKey)); 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 emptyEditId != null ? localize('com_ui_queue_edit_empty') : undefined; + })(); /** 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 @@ -475,6 +484,14 @@ function QueuedOutboxBase({ 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 () => { @@ -528,7 +545,10 @@ function QueuedOutboxBase({