From 56ecb6494c21c462a4dcc88c2c510dea93ae0c6f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Jul 2026 20:08:38 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=8B=20feat:=20Add=20Reclaim-Gated=20Contr?= =?UTF-8?q?ols=20to=20In-Flight=20Steers=20(#14321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✋ feat: Add Reclaim-Gated Controls to In-Flight Steers Give a pending steer the same controls as a queued message — edit, convert to queue, and the during-run mode toggle — instead of only a bare cancel. Every re-homing action reclaims the steer from the server queue first and acts only on a confirmed `removed: true`. A steer leaves that queue only by injecting, so a lost race means the words are already in the run: queueing or editing them then would say the same thing twice. - Return a `SteerCancelOutcome` ('reclaimed' | 'applied' | 'failed') from useSteerCancel so callers can distinguish "the words are still mine" from "already injected" and "unknown fate" — the last two only toast. - Extract RowMenu, useDefaultToggleEntry, and the shared button classes into SteerMenu so both during-run surfaces use one implementation. - Offer controls only once `pending`: a `sending` steer has no server id to reclaim with, so its words cannot be held back. - Pin the control cluster visible while its menu is open — the portaled items hold focus outside the subtree, so `focus-within` alone would drop it. * 🩹 fix: Address Codex Findings on Reclaimed-Steer Controls Route a reclaimed steer through the shared conversion, and stop the async reclaim from stranding items or clobbering a composer that moved on. - Queue a reclaimed steer via useSteerConvert instead of enqueue, so it keeps its original id and createdAt. enqueue minted a fresh v4/Date.now() and appended, so a steer accepted BEFORE a later follow-up drained after it — breaking the invariant the leftover-steer path documents. - Submit the item directly when the run ended during the reclaim round-trip: the drain consumes its one-shot signal against an empty queue, so nothing was left to auto-send it. Read run state and conversation from refs, since the reclaim resolves after the bubble unmounts. - Refuse the composer restore when the origin conversation no longer matches or a newer draft is present, and queue the words instead of overwriting them. Neither text is the one to throw away. - Split useSteerReclaim (POST only) out of useSteerCancel, so the menu actions leave the chip alone until the outcome is known while the X stays optimistic. * 🛡️ fix: Harden Reclaimed-Steer Guards Against Stale State Both guards from the previous round read values that had moved on by the time the reclaim resolved. - Compare the origin conversation against a ref, not the closure. The `.then` holds `restoreReclaimedSteer` from the render it was clicked in, so its captured `conversationId` is the OLD chat — the guard compared that against itself and passed, while `methods` (one form, reused across conversations) wrote the steer into the chat now on screen. - Gate the direct send on the drain's own rule. `!isSubmitting` also covers a Stop or an error, so converting and then pressing Stop auto-sent the text past useQueueDrain's completed-or-armed-interrupt-only rule. Capture the run's outcome before the drain consumes the one-shot signal, and send only on a clean completion of THIS conversation. * ♻️ refactor: Re-Arm the Drain Instead of Direct-Sending Reclaimed Steers The direct send was the wrong mechanism: it re-implemented the drain badly, and each round of review found another rule it had skipped. Delete it and let useQueueDrain do the sending — it already owns every one of those rules. - Re-post the spent run-end signal under the conversation instead of calling sendNow. The drain then applies the completed-only rule, FIFO order (an older follow-up is no longer skipped), NEW_CONVO migration, and submits via `ask` — which, unlike the composer's sendNow, does not reset the form and so cannot wipe a draft typed while the reclaim was in flight. - No-op when a signal is still armed: that drain has not run yet and will see the item on its own, so arming a second carrier would send twice. - Watch the parked run-end too, not just the index one, so a run that ended while the user was in another chat is still seen. - Treat staged files, quotes, and skill picks as a draft when deciding whether a restore may overwrite the composer — editToComposer MERGES into them, so restoring over staged context would glue two submissions together. * 🎯 fix: Scope the Re-Arm Suppression to This Conversation The no-double-arm guard treated ANY armed index run-end as proof the drain would see this conversation's newly queued item. The index slot is shared: useQueueDrain parks a foreign signal under its own conversation and then inspects only the active one's queue, so a reclaimed steer sitting behind an unrelated run-end would never be looked at and would strand until sent by hand. Suppress only when the armed index signal belongs to THIS conversation — which is the case where the drain really will see the item. The parked check was already conversation-scoped by its key. * 🧭 fix: Trust the Refs Only While They Describe This Chat useSteering is reused across conversations, so after a navigation its live refs describe the NEW chat while the reclaim's callback still speaks for the old one. Restoring the conversation-identity guard I removed last round, which was wrong precisely because the refs are live but not conversation-scoped. - Skip the re-arm entirely once conversationIdRef no longer matches the steer's conversation. Reading isSubmittingRef there could suppress a needed re-arm, and lastRunEndRef could hold the NEW chat's run-end — parking that under the old conversation would make drainNext (which keys off end.conversationId) drain the wrong queue into the wrong chat. - Assert lastRunEnd.conversationId matches before re-arming, so the invariant is enforced where it is relied on rather than inferred from render order. Nothing is lost by stopping: the item is already queued under its own conversation, and that run's end parks under it and drains on return. * 🗝️ fix: Key the Captured Run-End by Conversation A single run-end slot could only answer for whichever chat was on screen when a reclaim landed, so the guard had to bail on navigation — stranding a steer whose run had already completed, contrary to "queue for after the response". Key the captured run-ends by conversation instead. The stored end always speaks for the chat the words belong to, so navigating away no longer suppresses the re-arm, and another chat's end can never be parked under this one (which would hand drainNext a foreign end.conversationId and drain the wrong queue). - Drop an entry when its conversation starts another run: a superseded end must not authorize a drain of the run now in flight. This replaces the isSubmitting guard, which described the wrong chat after navigation. - Remove conversationIdRef, now that no read depends on where the user is. * 🧹 fix: Close Three Reclaim Races Around Answer Mode and Run End - Refuse the composer restore while answer mode is active. `onSubmit` hands composer text to `answerMode.submitText` before any send/steer routing, so a restored steer would become the tool's answer on the next Enter. Read through a ref: the run can pause on ask_user_question mid-reclaim. - Skip the restore when a terminal conversion already queued the words. The chip stays interactive during the reclaim round-trip, so a run ending or erroring meanwhile converts it — restoring after would leave one copy queued and another in the draft. The queue action needed no guard; the conversion already dedupes by id. - Carry quotes/skill picks on the reclaimed steer itself. The conversion recovers them from the chip, which a competing X can delete mid-round-trip, silently dropping the picks. * 🎛️ feat: Fold Cancel Into an Always-Visible Steer Menu Make the in-flight steer bubble a single, discoverable affordance instead of two hover-hidden ones, matching how Codex/ChatGPT present the same control. - Fold Cancel into the ⋯ menu as an item (X icon), removing the standalone X button. It keeps the optimistic `useSteerCancel` path — no reclaim gate, since cancel drops the words rather than re-homing them. - Show the ⋯ at rest on every pointer instead of hover-gating it. A label-less menu hidden until hover is undiscoverable on desktop and unreachable on touch; always-visible also matches the queued rows' controls and drops the hover/focus/menu-open opacity juggling entirely. * 🪢 feat: Make Cancel and Queued Trash Non-Destructive Both removal actions now hand their text back to the composer instead of dropping it, so a message the user typed is never gone forever. - In-flight Cancel: before cancelling, restore the words to the composer via the gated `restoreReclaimedSteer` (skipped once applied — they are already in the response). The restore refuses on its own rather than clobber a draft, land in another chat, or fight answer mode; the cancel still runs reliably either way, so an unwanted steer stays killable. - Queued Trash: same safety net — thread the gated restore into the queued rows and return the words (with their carried quotes/skills) to the composer, then remove either way. Aligns the two surfaces on one behavior. - Export the shared `RestoreToComposer` type so both surfaces reuse it. Left the reliable-remove path intact (a steer sometimes must be killed before it reaches the model) and did NOT reach for a delete+Undo snackbar, which the shared Toast can't render without a cross-cutting action-button change. * 🔒 fix: Restore Cancelled Steer Text Only on a Reclaimed Outcome The cancel safety net restored the words to the composer synchronously, before the cancel POST resolved. On `applied` (cancel lost the race, steer still injects) or `failed` (POST errored, chip restored), the same text ended up both in the run/bubble and in the composer. Await `cancelSteer`'s outcome and restore ONLY on `reclaimed` — the one result that proves the steer never reached the run. `applied`/`failed` leave the words where the events place them, no composer copy. The gated restore still refuses rather than clobber a draft typed during the round-trip. * 🧵 fix: Never Drop Cancelled Text; Keep the Steer's Submit Time Two follow-ups on the cancel safety net. - Cancel no longer silently drops the words when the reclaim succeeds but the composer refuses the restore (draft typed, answer mode, navigated). The chip is already gone, so queue them like Edit does — never lost, just re-homed — with the same toast. - Preserve the true submission timestamp across submitSteer's chip states. The ACK and failure chips reset createdAt to a LATER Date.now(), so a draft queued during the 202 round-trip could sort ahead of a steer submitted before it and drain out of order. Capture the submit time once and reuse it for all three. * 🚪 fix: Refuse Reclaimed-Steer Restore Into an Unmounted Composer A reclaim/cancel round-trip can resolve after ChatForm unmounts (left the route, closed the pane). Its refs still hold the origin conversation, so `restoreReclaimedSteer` passed its checks, wrote into a dead form, and returned true — making the caller drop the steer instead of queueing it, losing the text. Track mount state and refuse the restore once unmounted, so the caller queues the words (recoil is global, so the queued chip survives the navigation). --- client/src/components/Chat/Input/ChatForm.tsx | 86 ++++- .../components/Chat/Input/InFlightSteers.tsx | 178 ++++++++- .../Chat/Input/PendingSteerChips.tsx | 105 ++---- .../src/components/Chat/Input/SteerMenu.tsx | 77 ++++ .../Input/__tests__/InFlightSteers.test.tsx | 301 +++++++++++++-- .../__tests__/PendingSteerChips.test.tsx | 85 +++++ .../Chat/__tests__/useSteerConvert.spec.tsx | 25 ++ .../hooks/Chat/__tests__/useSteering.spec.tsx | 343 +++++++++++++++++- client/src/hooks/Chat/index.ts | 2 +- client/src/hooks/Chat/useSteerCancel.ts | 52 ++- client/src/hooks/Chat/useSteerConvert.ts | 14 +- client/src/hooks/Chat/useSteering.ts | 114 +++++- client/src/locales/en/translation.json | 4 + client/src/utils/steer.ts | 2 +- 14 files changed, 1240 insertions(+), 148 deletions(-) create mode 100644 client/src/components/Chat/Input/SteerMenu.tsx create mode 100644 client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 8c56e0371e..c738ef8451 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -266,6 +266,83 @@ const ChatForm = memo(function ChatForm({ stopGenerating, }); + /** Read at call time, not captured: a reclaim resolves into the callback from + * the render it was clicked in, so the closure's `conversationId` is the OLD + * chat — comparing it against itself would pass while `methods` (one form, + * reused across conversations) writes into the chat now on screen. */ + const liveConversationIdRef = useRef(conversationId); + liveConversationIdRef.current = conversationId; + /** Same reason: attachments staged after the click must be seen. */ + const liveFilesRef = useRef(files); + liveFilesRef.current = files; + /** Same reason: the run can pause on `ask_user_question` mid-reclaim. */ + const liveAnswerModeRef = useRef(answerMode.active); + liveAnswerModeRef.current = answerMode.active; + /** A reclaim can resolve after this form unmounts (left the route, closed the + * pane). Its refs still hold the origin chat, so the restore would pass its + * checks and write into a dead form — reporting success and making the caller + * drop the steer, losing the text. Track mount so the restore refuses and the + * caller queues it instead. */ + const composerMountedRef = useRef(true); + useEffect( + () => () => { + composerMountedRef.current = false; + }, + [], + ); + + /** A draft is anything the user has staged, not just typed: `editToComposer` + * MERGES the steer's attachments into the composer's file map and its quotes + * and skill picks into their atoms, so restoring over staged context would + * glue the two submissions together. */ + const hasStagedComposerContext = useRecoilCallback( + ({ snapshot }) => + (convoId: string) => + snapshot.getLoadable(store.pendingQuotesByConvoId(convoId)).getValue().length > 0 || + snapshot.getLoadable(store.pendingManualSkillsByConvoId(convoId)).getValue().length > 0, + [], + ); + + /** + * `editToComposer` for a steer whose reclaim was a round-trip: by the time it + * resolves the composer may have moved on. Refuses (returning false, so the + * caller re-homes the words instead of dropping them) rather than overwrite a + * draft the user has since staged, or drop a steer into whatever chat they + * navigated to. + */ + const restoreReclaimedSteer = useCallback( + ( + text: string, + steerFiles: TMessage['files'], + context: QueuedMessageContext, + originConversationId: string, + ): boolean => { + if (!composerMountedRef.current) { + return false; + } + const liveConversationId = liveConversationIdRef.current; + if (originConversationId !== liveConversationId) { + return false; + } + /** Answer mode owns the composer: `onSubmit` hands its text to + * `answerMode.submitText` before any send/steer routing, so restoring + * here would turn the steer into the tool's answer on the next Enter. */ + if (liveAnswerModeRef.current) { + return false; + } + if ( + (methods.getValues('text') ?? '').trim().length > 0 || + (liveFilesRef.current?.size ?? 0) > 0 || + hasStagedComposerContext(liveConversationId) + ) { + return false; + } + editToComposer(text, steerFiles, context); + return true; + }, + [methods, editToComposer, hasStagedComposerContext], + ); + /** ⌘/Ctrl+Enter = the non-default during-run action, ⌥/Alt+Enter = * interrupt & send — the counterpart of Enter's `submitDuringRun`. */ const handleDuringRunModifier = useCallback( @@ -415,7 +492,13 @@ const ChatForm = memo(function ChatForm({
{/* Run-scoped: `enabled` alone is any primary composer on a steerable endpoint, so a chip that outlives the run would strand a bubble. */} - {steering.enabled && isSubmitting && } + {steering.enabled && isSubmitting && ( + + )}
)} {/* WIP */} diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 90f3374a5d..289e458431 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -1,16 +1,30 @@ import { memo, useRef, useMemo, useState, useEffect, useCallback } from 'react'; -import { X, Zap } from 'lucide-react'; -import { useRecoilValue } from 'recoil'; +import { useToastContext } from '@librechat/client'; +import { X, Zap, Clock, Pencil } from 'lucide-react'; +import { useRecoilValue, useRecoilCallback } from 'recoil'; import type { TFile, TMessage } from 'librechat-data-provider'; +import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { PendingSteer } from '~/store/families'; +import type { MenuEntry } from './SteerMenu'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; +import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; -import { useSteerCancel, useLocalize } from '~/hooks'; -import { cn } from '~/utils'; +import { RowMenu, useDefaultToggleEntry } from './SteerMenu'; +import { carriedSteerContext, cn } from '~/utils'; import store from '~/store'; +/** Restores a message's text into the composer, or refuses (false) when the + * composer is occupied / on another chat — see `restoreReclaimedSteer` in + * `ChatForm`. Shared by the in-flight cancel and the queued trash safety net. */ +export type RestoreToComposer = ( + text: string, + files: TMessage['files'], + context: QueuedMessageContext, + originConversationId: string, +) => boolean; + const splitFiles = (files?: TMessage['files']) => { const images: NonNullable = []; const others: NonNullable = []; @@ -33,17 +47,26 @@ const splitFiles = (files?: TMessage['files']) => { * * `sending` is still awaiting its 202 ACK (no server id yet, so nothing to * cancel); `pending` is acknowledged and waiting on the next tool-batch - * boundary. + * boundary. Every control here reclaims the steer from the server queue first, + * so they are offered only once `pending` — while `sending` there is no id to + * reclaim with, and the words cannot be held back. */ const InFlightSteer = memo(function InFlightSteer({ steer, + steering, conversationId, + onRestoreToComposer, }: { steer: PendingSteer; + steering: SteeringControls; conversationId: string; + onRestoreToComposer: RestoreToComposer; }) { const localize = useLocalize(); + const { showToast } = useToastContext(); const cancelSteer = useSteerCancel(conversationId); + const reclaimSteer = useSteerReclaim(conversationId); + const toggleEntry = useDefaultToggleEntry(steering); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const [selectedFile, setSelectedFile] = useState | null>(null); const handlePreviewClose = useCallback((open: boolean) => { @@ -55,6 +78,121 @@ const InFlightSteer = memo(function InFlightSteer({ const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]); const sending = steer.status === 'sending'; + /** Whether the words have already been re-homed by a terminal conversion (a + * run that ended/errored mid-reclaim queues the still-present chip). The + * queue action is safe either way — the conversion dedupes by id — but a + * composer restore would leave one copy queued and another in the draft. */ + const hasSettled = useRecoilCallback( + ({ snapshot }) => + (steerId: string) => + snapshot + .getLoadable(store.appliedSteerIdsByConvoId(conversationId)) + .getValue() + .includes(steerId), + [conversationId], + ); + + /** + * Takes the steer back off the server queue so its words can be re-homed. + * The chip is left alone until the answer is known: only `reclaimed` proves + * the words never entered the run, and the re-homing callers below own the + * removal from there. + */ + const reclaim = useCallback(async (): Promise => { + const outcome = await reclaimSteer(steer); + if (outcome === 'reclaimed') { + return true; + } + showToast({ + message: localize( + outcome === 'applied' ? 'com_ui_steer_already_applied' : 'com_ui_steer_cancel_failed', + ), + status: outcome === 'applied' ? 'info' : 'error', + }); + return false; + }, [reclaimSteer, steer, showToast, localize]); + + const entries: MenuEntry[] = [ + { + key: 'edit', + label: localize('com_ui_edit_message'), + icon:
{others.length > 0 && ( @@ -148,9 +282,13 @@ const InFlightSteer = memo(function InFlightSteer({ * committed, while the user still sees their words land somewhere stable. */ const InFlightSteers = memo(function InFlightSteers({ + steering, conversationId, + onRestoreToComposer, }: { + steering: SteeringControls; conversationId: string; + onRestoreToComposer: RestoreToComposer; }) { const localize = useLocalize(); const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId)); @@ -184,7 +322,13 @@ const InFlightSteers = memo(function InFlightSteers({ className="flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2" > {inFlight.map((steer) => ( - + ))}
); diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx index 7f88cae590..7e4503ac2a 100644 --- a/client/src/components/Chat/Input/PendingSteerChips.tsx +++ b/client/src/components/Chat/Input/PendingSteerChips.tsx @@ -1,68 +1,18 @@ import { memo, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import * as Ariakit from '@ariakit/react'; -import { - X, - Zap, - Send, - Clock, - Pencil, - Trash2, - Paperclip, - RotateCcw, - MoreHorizontal, -} from 'lucide-react'; +import { X, Zap, Send, Clock, Pencil, Trash2, Paperclip, RotateCcw } from 'lucide-react'; import type { TMessage } from 'librechat-data-provider'; import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { PendingSteer, QueuedMessage } from '~/store/families'; +import type { RestoreToComposer } from './InFlightSteers'; +import type { MenuEntry } from './SteerMenu'; +import { RowMenu, useDefaultToggleEntry, ICON_BTN_CLASS, PRIMARY_BTN_CLASS } from './SteerMenu'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; const ROW_CLASS = 'flex w-full items-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary'; -const PRIMARY_BTN_CLASS = - 'flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; -const ICON_BTN_CLASS = - 'shrink-0 rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; -const MENU_CLASS = - 'z-50 min-w-[13rem] rounded-xl border border-border-light bg-surface-secondary p-1.5 text-text-primary shadow-lg outline-none'; -const MENU_ITEM_CLASS = - 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-text-primary data-[active-item]:bg-surface-tertiary aria-disabled:cursor-not-allowed aria-disabled:opacity-50'; - -type MenuEntry = { - key: string; - label: string; - icon: React.ReactNode; - onClick: () => void; -}; - -/** Per-row "…" overflow menu (edit / mode toggle / conversions). */ -function RowMenu({ label, entries }: { label: string; entries: MenuEntry[] }) { - const menu = Ariakit.useMenuStore({ placement: 'top-end' }); - return ( - <> - - - - {entries.map((entry) => ( - { - entry.onClick(); - menu.hide(); - }} - > - {entry.icon} - {entry.label} - - ))} - - - ); -} function AttachmentCount({ count, label }: { count: number; label: string }) { if (count === 0) { @@ -77,44 +27,22 @@ function AttachmentCount({ count, label }: { count: number; label: string }) { ); } -/** - * The overflow item that flips the Enter-during-run default. Shown as the - * OPPOSITE of the current default (the action you would switch to), matching - * the reference UX ("Turn on queueing" while steer is the default). - */ -function useDefaultToggleEntry(steering: SteeringControls): MenuEntry { - const localize = useLocalize(); - return useMemo(() => { - const next = steering.defaultAction === 'steer' ? 'queue' : 'steer'; - return { - key: 'toggle-default', - label: - next === 'queue' - ? localize('com_ui_turn_on_queueing') - : localize('com_ui_turn_on_steering'), - icon: - next === 'queue' ? ( -