feat: Add Reclaim-Gated Controls to In-Flight Steers (#14321)

*  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).
This commit is contained in:
Danny Avila 2026-07-20 20:08:38 -04:00 committed by GitHub
parent 5cf849d8d5
commit 56ecb6494c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1240 additions and 148 deletions

View file

@ -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({
<div className="flex w-full flex-col">
{/* 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 && <InFlightSteers conversationId={conversationId} />}
{steering.enabled && isSubmitting && (
<InFlightSteers
steering={steering}
conversationId={conversationId}
onRestoreToComposer={restoreReclaimedSteer}
/>
)}
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
<Mention
index={index}
@ -460,6 +543,7 @@ const ChatForm = memo(function ChatForm({
conversationId={conversationId}
steering={steering}
onEditToComposer={editToComposer}
onRestoreToComposer={restoreReclaimedSteer}
/>
)}
{/* WIP */}

View file

@ -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<TMessage['files']> = [];
const others: NonNullable<TMessage['files']> = [];
@ -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<boolean>(store.enableUserMsgMarkdown);
const [selectedFile, setSelectedFile] = useState<Partial<TFile> | 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<boolean> => {
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: <Pencil className="h-4 w-4" aria-hidden="true" />,
onClick: () => {
void reclaim().then((reclaimed) => {
if (!reclaimed) {
return;
}
if (hasSettled(steer.steerId)) {
/* The run ended while the reclaim was in flight and its terminal
* conversion already queued these words. */
showToast({ message: localize('com_ui_steer_run_ended_queued'), status: 'info' });
return;
}
const restored = onRestoreToComposer(
steer.text,
steer.files,
carriedSteerContext(steer),
conversationId,
);
if (restored) {
steering.removeSteer(steer.steerId);
return;
}
/* The composer moved on while the reclaim was in flight. The words
* are already off the server, so queue them rather than overwrite a
* newer draft neither text is the one to throw away. */
steering.queueReclaimedSteer(steer);
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
});
},
},
{
key: 'queue',
label: localize('com_ui_convert_to_queue'),
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
onClick: () => {
void reclaim().then((reclaimed) => {
if (reclaimed) {
steering.queueReclaimedSteer(steer);
}
});
},
},
{
/* Non-destructive, but only when it is safe: cancel reliably first (the
* optimistic hook removes the chip and restores it if the server would
* still inject), then hand the words back to the composer ONLY on a
* `reclaimed` outcome. On `applied` (cancel lost the race) or `failed`
* the steer may still reach the run, so restoring would duplicate the
* text in the response, or beside the restored bubble. The gated
* restore also refuses rather than clobber a draft typed meanwhile. */
key: 'cancel',
label: localize('com_ui_steer_cancel'),
icon: <X className="h-4 w-4" aria-hidden="true" />,
onClick: () => {
void cancelSteer(steer).then((outcome) => {
if (outcome !== 'reclaimed') {
return;
}
const restored = onRestoreToComposer(
steer.text,
steer.files,
carriedSteerContext(steer),
conversationId,
);
if (!restored) {
/* Reclaimed, but the composer moved on (draft typed, answer mode,
* navigated). The chip is already gone, so queue the words as Edit
* does rather than drop them never lost, just re-homed. */
steering.queueReclaimedSteer(steer);
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
}
});
},
},
toggleEntry,
];
return (
<div
role="listitem"
@ -113,17 +251,13 @@ const InFlightSteer = memo(function InFlightSteer({
</div>
</div>
{!sending && (
/* Hidden-at-rest only on hover-capable pointers: a hover-revealed
* control is unreachable on touch until a first tap. */
<button
type="button"
aria-label={localize('com_ui_steer_cancel')}
onClick={() => cancelSteer(steer)}
data-testid="steer-cancel"
className="shrink-0 rounded-full p-1 text-text-secondary transition-opacity duration-200 hover:bg-surface-tertiary hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
/* One always-visible affordance: a label-less menu hidden until hover
* is undiscoverable, and edit/queue/cancel all live inside it now, so
* the menu shows at rest on every pointer (matching the always-on
* controls on the queued rows). */
<div data-testid="steer-controls" className="flex shrink-0 items-center">
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
</div>
)}
</div>
{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) => (
<InFlightSteer key={steer.steerId} steer={steer} conversationId={conversationId} />
<InFlightSteer
key={steer.steerId}
steer={steer}
steering={steering}
conversationId={conversationId}
onRestoreToComposer={onRestoreToComposer}
/>
))}
</div>
);

View file

@ -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 (
<>
<Ariakit.MenuButton store={menu} aria-label={label} className={ICON_BTN_CLASS}>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
<Ariakit.Menu store={menu} portal gutter={6} className={MENU_CLASS}>
{entries.map((entry) => (
<Ariakit.MenuItem
key={entry.key}
className={MENU_ITEM_CLASS}
onClick={() => {
entry.onClick();
menu.hide();
}}
>
{entry.icon}
{entry.label}
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</>
);
}
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' ? (
<Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />
) : (
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
),
onClick: () => steering.setDefaultAction(next),
};
}, [steering, localize]);
}
function QueuedRow({
message,
steering,
conversationId,
onEditToComposer,
onRestoreToComposer,
}: {
message: QueuedMessage;
steering: SteeringControls;
conversationId: string;
onEditToComposer: (
text: string,
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const toggleEntry = useDefaultToggleEntry(steering);
@ -170,7 +98,18 @@ function QueuedRow({
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
onClick={() => steering.removeQueued(message.id)}
onClick={() => {
/* Same safety net as the in-flight cancel: return the words to the
* composer when it is free (the gated restore refuses rather than
* clobber a draft), then remove either way. */
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
steering.removeQueued(message.id);
}}
className={ICON_BTN_CLASS}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
@ -273,6 +212,7 @@ function PendingSteerChips({
conversationId,
steering,
onEditToComposer,
onRestoreToComposer,
}: {
conversationId: string;
steering: SteeringControls;
@ -281,6 +221,7 @@ function PendingSteerChips({
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
@ -311,7 +252,9 @@ function PendingSteerChips({
key={message.id}
message={message}
steering={steering}
conversationId={conversationId}
onEditToComposer={onEditToComposer}
onRestoreToComposer={onRestoreToComposer}
/>
))}
</div>

View file

@ -0,0 +1,77 @@
import { useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { Zap, Clock, MoreHorizontal } from 'lucide-react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { useLocalize } from '~/hooks';
/** Shared row/bubble affordances for the during-run surfaces: the in-flight
* steer bubbles (`InFlightSteers`) and the queued/failed rows
* (`PendingSteerChips`) offer the same actions, so they share one menu. */
export 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';
export 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 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';
export type MenuEntry = {
key: string;
label: string;
icon: React.ReactNode;
onClick: () => void;
};
/** Per-row "…" overflow menu (edit / mode toggle / conversions). */
export function RowMenu({ label, entries }: { label: string; entries: MenuEntry[] }) {
const menu = Ariakit.useMenuStore({ placement: 'top-end' });
return (
<>
<Ariakit.MenuButton store={menu} aria-label={label} className={ICON_BTN_CLASS}>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
<Ariakit.Menu store={menu} portal gutter={6} className={MENU_CLASS}>
{entries.map((entry) => (
<Ariakit.MenuItem
key={entry.key}
className={MENU_ITEM_CLASS}
onClick={() => {
entry.onClick();
menu.hide();
}}
>
{entry.icon}
{entry.label}
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</>
);
}
/**
* 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).
*/
export 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' ? (
<Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />
) : (
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
),
onClick: () => steering.setDefaultAction(next),
};
}, [steering, localize]);
}

View file

@ -1,19 +1,30 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen, fireEvent, act } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { PendingSteer } from '~/store/families';
import InFlightSteers from '../InFlightSteers';
import store from '~/store';
const mockCancelMutate = jest.fn();
const mockCancelMutateAsync = jest.fn();
const mockShowToast = jest.fn();
const mockQueueReclaimedSteer = jest.fn();
const mockRemoveSteer = jest.fn();
const mockSetDefaultAction = jest.fn();
const mockRestoreToComposer = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useSteerCancel: jest.requireActual('~/hooks/Chat/useSteerCancel').default,
useSteerReclaim: jest.requireActual('~/hooks/Chat/useSteerCancel').useSteerReclaim,
}));
jest.mock('@librechat/client', () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock('~/data-provider', () => ({
useCancelSteerMutation: () => ({ mutate: mockCancelMutate }),
useCancelSteerMutation: () => ({ mutateAsync: mockCancelMutateAsync }),
}));
jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({
@ -51,9 +62,21 @@ jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
const CONVO_ID = 'convo-in-flight';
const steeringStub = (defaultAction: 'steer' | 'queue' = 'steer') =>
({
defaultAction,
removeSteer: mockRemoveSteer,
setDefaultAction: mockSetDefaultAction,
queueReclaimedSteer: mockQueueReclaimedSteer,
}) as unknown as SteeringControls;
function renderSteers(
steers: PendingSteer[],
options?: { enableUserMsgMarkdown?: boolean; appliedSteerIds?: string[] },
options?: {
enableUserMsgMarkdown?: boolean;
appliedSteerIds?: string[];
defaultAction?: 'steer' | 'queue';
},
) {
return render(
<RecoilRoot
@ -67,12 +90,32 @@ function renderSteers(
}
}}
>
<InFlightSteers conversationId={CONVO_ID} />
<InFlightSteers
conversationId={CONVO_ID}
steering={steeringStub(options?.defaultAction)}
onRestoreToComposer={mockRestoreToComposer}
/>
</RecoilRoot>,
);
}
/** Opens a bubble's "" menu and clicks one of its items, flushing the reclaim
* round-trip the action awaits before it re-homes the text. */
async function clickMenuItem(label: string) {
fireEvent.click(screen.getByLabelText('com_ui_more_options'));
const item = await screen.findByText(label);
await act(async () => {
fireEvent.click(item);
});
}
describe('InFlightSteers', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCancelMutateAsync.mockResolvedValue({ removed: true });
mockRestoreToComposer.mockReturnValue(true);
});
it('renders nothing when no steer is in flight', () => {
renderSteers([]);
expect(screen.queryByTestId('in-flight-steers')).toBeNull();
@ -95,65 +138,255 @@ describe('InFlightSteers', () => {
expect(screen.queryByTestId('in-flight-steers')).toBeNull();
});
it('keeps cancel reachable on touch, hover-revealed on hover-capable pointers', () => {
it('shows the menu at rest on every pointer, without hover-gating', () => {
renderSteers([
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 1 },
]);
// A plain `opacity-0` reveal would make the bubble hover-dependent, so on
// touch the X would need a first tap to appear (see the #14272 pattern).
const cancel = screen.getByTestId('steer-cancel');
expect(cancel.className).toContain('[@media(hover:hover)]:opacity-0');
expect(cancel.className).toContain('group-hover:opacity-100');
expect(cancel.className).toContain('focus-visible:opacity-100');
// The menu is the single control now (Cancel folded in), so a label-less ⋯
// hidden until hover would be undiscoverable — and unreachable on touch,
// where there is no hover. It must be visible at rest.
const controls = screen.getByTestId('steer-controls');
expect(controls.className).not.toContain('opacity-0');
expect(screen.getByLabelText('com_ui_more_options')).toBeInTheDocument();
});
it('only offers cancel once the steer is acknowledged', () => {
it('only offers the menu once the steer is acknowledged', () => {
renderSteers([
{ steerId: 'local-1', text: 'still posting', status: 'sending', createdAt: 1 },
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 2 },
]);
// A 'sending' entry has no server id yet, so there is nothing to cancel.
expect(screen.getAllByTestId('steer-cancel')).toHaveLength(1);
// A 'sending' entry has no server id yet, so there is nothing to act on —
// cancel and the re-homing actions all need to reclaim it first.
expect(screen.getAllByLabelText('com_ui_more_options')).toHaveLength(1);
});
it('cancels a pending steer server-side and drops the bubble', () => {
it('cancels a pending steer from the menu and drops the bubble', async () => {
renderSteers([
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 1 },
]);
fireEvent.click(screen.getByTestId('steer-cancel'));
await clickMenuItem('com_ui_steer_cancel');
expect(mockCancelMutate).toHaveBeenCalledWith(
{ conversationId: CONVO_ID, steerId: 's-ack' },
expect.objectContaining({ onError: expect.any(Function) }),
);
expect(mockCancelMutateAsync).toHaveBeenCalledWith({
conversationId: CONVO_ID,
steerId: 's-ack',
});
await act(async () => {});
expect(screen.queryByText('waiting on boundary')).toBeNull();
});
it('restores the bubble when the cancel POST fails', () => {
renderSteers([{ steerId: 's-err', text: 'network flake', status: 'pending', createdAt: 1 }]);
fireEvent.click(screen.getByTestId('steer-cancel'));
expect(screen.queryByText('network flake')).toBeNull();
it('hands the words back to the composer once the cancel reclaims them', async () => {
// Cancel is non-destructive: on a `reclaimed` outcome (removed:true) the
// steer never reached the run, so its words return to the composer (the
// gated restore refuses on its own when the composer is occupied).
mockCancelMutateAsync.mockResolvedValue({ removed: true });
renderSteers([{ steerId: 's-ack', text: 'second thoughts', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_steer_cancel');
await act(async () => {});
expect(mockRestoreToComposer).toHaveBeenCalledWith('second thoughts', undefined, {}, CONVO_ID);
});
const options = mockCancelMutate.mock.calls[0][1] as { onError: () => void };
act(() => options.onError());
it('queues the words when cancel reclaims but the composer refuses the restore', async () => {
// Reclaimed (removed:true) yet the composer moved on, so the gated restore
// refuses. The chip is already gone — queue the words like Edit rather than
// drop them.
mockCancelMutateAsync.mockResolvedValue({ removed: true });
mockRestoreToComposer.mockReturnValue(false);
const steer: PendingSteer = {
steerId: 's-ack',
text: 'keep me',
status: 'pending',
createdAt: 1,
};
renderSteers([steer]);
await clickMenuItem('com_ui_steer_cancel');
await act(async () => {});
expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(steer);
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_edit_queued' }),
);
});
it('does not restore when the cancel loses its race (steer already reached the run)', async () => {
// removed:false → the steer will still inject; restoring would put the same
// words in the composer alongside the copy in the response.
mockCancelMutateAsync.mockResolvedValue({ removed: false });
renderSteers([{ steerId: 's-ack', text: 'too late', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_steer_cancel');
await act(async () => {});
expect(mockRestoreToComposer).not.toHaveBeenCalled();
// The chip still left optimistically; the events own the outcome.
expect(mockCancelMutateAsync).toHaveBeenCalled();
});
it('does not restore when the cancel POST fails', async () => {
// The POST failed, so the server may still inject it and the bubble is
// restored — restoring to the composer too would duplicate the words.
mockCancelMutateAsync.mockRejectedValue(new Error('network'));
renderSteers([{ steerId: 's-ack', text: 'unknown fate', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_steer_cancel');
await act(async () => {});
expect(mockRestoreToComposer).not.toHaveBeenCalled();
});
it('restores the bubble when the cancel POST fails', async () => {
mockCancelMutateAsync.mockRejectedValue(new Error('network'));
renderSteers([{ steerId: 's-err', text: 'network flake', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_steer_cancel');
// Optimistic remove, then the reject restores it.
await act(async () => {});
expect(screen.getByText('network flake')).toBeInTheDocument();
});
it('does not restore a steer that settled while the cancel POST was in flight', () => {
it('does not restore a steer that settled while the cancel POST was in flight', async () => {
// The run's final event converted this steer to a queued follow-up, which
// stamps its id into the applied set. Restoring it on a failed cancel would
// strand a stale entry that the NEXT run — a queue drain auto-sends one —
// renders as an in-flight bubble beside that queued copy.
mockCancelMutateAsync.mockRejectedValue(new Error('network'));
renderSteers(
[{ steerId: 's-settled', text: 'already queued', status: 'pending', createdAt: 1 }],
{ appliedSteerIds: ['s-settled'] },
);
fireEvent.click(screen.getByTestId('steer-cancel'));
await clickMenuItem('com_ui_steer_cancel');
await act(async () => {});
expect(screen.queryByText('already queued')).toBeNull();
});
const options = mockCancelMutate.mock.calls[0][1] as { onError: () => void };
act(() => options.onError());
expect(screen.queryByText('already queued')).toBeNull();
it('reclaims a pending steer before queueing it for after the response', async () => {
const steer: PendingSteer = {
steerId: 's-ack',
text: 'do this after',
status: 'pending',
createdAt: 1,
};
renderSteers([steer]);
await clickMenuItem('com_ui_convert_to_queue');
// Reclaim first: the server would otherwise still inject the steer, and the
// queued copy would say the same words a second time.
expect(mockCancelMutateAsync).toHaveBeenCalledWith({
conversationId: CONVO_ID,
steerId: 's-ack',
});
// Routed through the shared conversion, which preserves the steer's id and
// createdAt so it drains ahead of a follow-up queued after it.
expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(steer);
});
it('hands the whole steer to the conversion so attachments and context survive', async () => {
const steer: PendingSteer = {
steerId: 's-ack',
text: 'see notes',
status: 'pending',
createdAt: 1,
files: [{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }],
quotes: ['quoted line'],
manualSkills: ['skill-1'],
};
renderSteers([steer]);
await clickMenuItem('com_ui_convert_to_queue');
expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(steer);
});
it('reclaims a pending steer before editing it back into the composer', async () => {
const files = [{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }];
renderSteers([
{
steerId: 's-ack',
text: 'reword this',
status: 'pending',
createdAt: 1,
files,
quotes: ['quoted line'],
},
]);
await clickMenuItem('com_ui_edit_message');
// The origin conversation rides along so a restore cannot land in whatever
// chat the user navigated to while the reclaim was in flight.
expect(mockRestoreToComposer).toHaveBeenCalledWith(
'reword this',
files,
{ quotes: ['quoted line'] },
CONVO_ID,
);
expect(mockRemoveSteer).toHaveBeenCalledWith('s-ack');
});
it('queues a reclaimed steer instead of overwriting a composer that moved on', async () => {
// The reclaim is a round-trip: the user can type a new draft (or navigate)
// before it resolves. The words are already off the server, so neither the
// steer nor the newer draft is the one to throw away.
mockRestoreToComposer.mockReturnValue(false);
const steer: PendingSteer = {
steerId: 's-ack',
text: 'reword this',
status: 'pending',
createdAt: 1,
};
renderSteers([steer]);
await clickMenuItem('com_ui_edit_message');
expect(mockQueueReclaimedSteer).toHaveBeenCalledWith(steer);
expect(mockRemoveSteer).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_edit_queued' }),
);
});
it('does not restore a steer a terminal conversion already queued', async () => {
// The chip stays interactive during the reclaim round-trip, so a run that
// ends or errors meanwhile converts it to a queued follow-up (stamping the
// applied set). Restoring afterwards would leave one copy queued and
// another in the composer draft.
renderSteers([{ steerId: 's-ack', text: 'already queued', status: 'pending', createdAt: 1 }], {
appliedSteerIds: ['s-ack'],
});
await clickMenuItem('com_ui_edit_message');
expect(mockRestoreToComposer).not.toHaveBeenCalled();
expect(mockQueueReclaimedSteer).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_run_ended_queued' }),
);
});
it('never re-homes a steer the server already applied', async () => {
// `removed: false` means the cancel lost its race to the injection
// boundary: the words are in the run, so queueing them would send twice.
mockCancelMutateAsync.mockResolvedValue({ removed: false });
renderSteers([{ steerId: 's-ack', text: 'too late', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_convert_to_queue');
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_already_applied' }),
);
expect(mockQueueReclaimedSteer).not.toHaveBeenCalled();
});
it('never re-homes a steer whose cancel failed', async () => {
// The POST failed, so the server may still inject it — its fate is unknown,
// so the bubble stays and the text must not also land in the composer.
mockCancelMutateAsync.mockRejectedValue(new Error('network'));
renderSteers([{ steerId: 's-ack', text: 'unknown fate', status: 'pending', createdAt: 1 }]);
await clickMenuItem('com_ui_edit_message');
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'com_ui_steer_cancel_failed', status: 'error' }),
);
expect(mockRestoreToComposer).not.toHaveBeenCalled();
expect(mockQueueReclaimedSteer).not.toHaveBeenCalled();
// The menu actions leave the chip alone until the outcome is known.
expect(screen.getByText('unknown fate')).toBeInTheDocument();
});
it('offers the mode toggle as the action the user would switch to', async () => {
renderSteers([{ steerId: 's-ack', text: 'waiting', status: 'pending', createdAt: 1 }], {
defaultAction: 'steer',
});
await clickMenuItem('com_ui_turn_on_queueing');
expect(mockSetDefaultAction).toHaveBeenCalledWith('queue');
});
it('renders images through the composer thumbnail path, not the full-size message image', () => {
@ -248,7 +481,11 @@ describe('InFlightSteers', () => {
]);
}}
>
<InFlightSteers conversationId={CONVO_ID} />
<InFlightSteers
conversationId={CONVO_ID}
steering={steeringStub()}
onRestoreToComposer={mockRestoreToComposer}
/>
</RecoilRoot>,
);
expect(screen.getByTestId('in-flight-steers').scrollTop).toBe(600);

View file

@ -0,0 +1,85 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen, fireEvent } from '@testing-library/react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { QueuedMessage } from '~/store/families';
import PendingSteerChips from '../PendingSteerChips';
import store from '~/store';
const mockRemoveQueued = jest.fn();
const mockRestoreToComposer = jest.fn(() => true);
const mockEditToComposer = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
const CONVO_ID = 'convo-q';
const steeringStub = () =>
({
queueKey: CONVO_ID,
defaultAction: 'steer',
duringRunActive: false,
canSteer: false,
removeQueued: mockRemoveQueued,
sendQueuedNow: jest.fn(),
setDefaultAction: jest.fn(),
}) as unknown as SteeringControls;
function renderChips(queued: QueuedMessage[]) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.queuedMessagesByConvoId(CONVO_ID), queued);
}}
>
<PendingSteerChips
conversationId={CONVO_ID}
steering={steeringStub()}
onEditToComposer={mockEditToComposer}
onRestoreToComposer={mockRestoreToComposer}
/>
</RecoilRoot>,
);
}
describe('PendingSteerChips — queued trash', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns the words to the composer before removing a queued message', () => {
// The trash is non-destructive: it hands the text (and its carried context)
// to the gated restore first, so the words are not gone forever.
renderChips([
{
id: 'q1',
text: 'later thought',
createdAt: 1,
quotes: ['a quote'],
manualSkills: ['a-skill'],
},
]);
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRestoreToComposer).toHaveBeenCalledWith(
'later thought',
undefined,
{ quotes: ['a quote'], manualSkills: ['a-skill'] },
CONVO_ID,
);
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('still removes the message even when the composer refuses the restore', () => {
// Occupied composer / other chat: the gated restore returns false, but the
// trash must reliably remove either way.
mockRestoreToComposer.mockReturnValueOnce(false);
renderChips([{ id: 'q2', text: 'drop me', createdAt: 1 }]);
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRestoreToComposer).toHaveBeenCalled();
expect(mockRemoveQueued).toHaveBeenCalledWith('q2');
});
});

View file

@ -72,6 +72,31 @@ describe('useSteerConvert', () => {
]);
});
it('falls back to context carried on the steer when its chip is already gone', () => {
// A reclaimed steer stays interactive during its cancel round-trip, so a
// competing X can delete the chip before the conversion runs. The steer
// carries its own picks so they survive that race.
const { result } = setup();
act(() => {
result.current.convert(CONVO_ID, [
{
steerId: 'reclaimed',
text: 'carried',
createdAt: 1,
quotes: ['carried quote'],
manualSkills: ['carried-skill'],
},
]);
});
expect(result.current.queue).toEqual([
expect.objectContaining({
id: 'reclaimed',
quotes: ['carried quote'],
manualSkills: ['carried-skill'],
}),
]);
});
it('adds no context fields when no local chip matches (fresh reload)', () => {
const { result } = setup();
act(() => {

View file

@ -1,6 +1,6 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil';
import { RecoilRoot, useRecoilValue, useSetRecoilState, type MutableSnapshot } from 'recoil';
import { Constants, ContentTypes, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import useSteering from '../useSteering';
@ -114,6 +114,326 @@ describe('useSteering', () => {
});
});
describe('queueReclaimedSteer', () => {
const reclaimed = {
steerId: 's-reclaimed',
text: 'reclaimed words',
status: 'pending' as const,
createdAt: 1_000,
};
/** The run-end signal `useQueueDrain` consumes; seeded here to stand for a
* run that already finished by the time a reclaim resolved. */
const runEnd = (outcome: 'completed' | 'aborted' | 'error', conversationId = CONVO_ID) => ({
conversationId,
outcome,
endedAt: 2_000,
});
function setupWithQueue(
params: HookParams = {},
initialize?: (snapshot: MutableSnapshot) => void,
) {
const sendNow = jest.fn();
const stopGenerating = jest.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={initialize}>{children}</RecoilRoot>
);
const rendered = renderHook(
() => ({
steering: useSteering({
index: 0,
conversationId: CONVO_ID,
conversation: agentsConversation,
isSubmitting: true,
answerModeActive: false,
sendNow,
stopGenerating,
...params,
}),
queue: useQueue(CONVO_ID),
/** What `useQueueDrain` watches: re-posting it is how this hook asks
* the drain to reconsider a queue it already passed over. */
parkedRunEnd: useRecoilValue(store.pendingRunEndByConvoId(CONVO_ID)),
/** Stands in for the drain CONSUMING a signal it has acted on. */
consumeIndexSignal: useSetRecoilState(store.runEndByIndex(0)),
consumeParkedSignal: useSetRecoilState(store.pendingRunEndByConvoId(CONVO_ID)),
}),
{ wrapper },
);
return { ...rendered, sendNow };
}
it('keeps the steer ahead of a follow-up queued after it', () => {
// The steer was accepted BEFORE the follow-up, so it must drain first.
// Minting a fresh id/createdAt here would sort it last.
const { result } = setupWithQueue();
act(() => {
result.current.steering.enqueue('queued later', {});
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.queue.map((item) => item.text)).toEqual([
'reclaimed words',
'queued later',
]);
// The original identity survives, which is what the ordering rests on.
expect(result.current.queue[0].id).toBe('s-reclaimed');
expect(result.current.queue[0].createdAt).toBe(1_000);
});
it('leaves the item to the drain while the run is still going', () => {
const { result, sendNow } = setupWithQueue({ isSubmitting: true });
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(sendNow).not.toHaveBeenCalled();
// The run's own end is still ahead of this item and will drain it, so
// nothing needs re-arming.
expect(result.current.parkedRunEnd).toBeNull();
expect(result.current.queue).toHaveLength(1);
});
it('re-arms the drain when the run completed cleanly while the reclaim was in flight', () => {
// The drain already consumed its one-shot signal against an empty queue,
// so re-post it: the DRAIN sends (FIFO, via `ask`, which does not reset
// the composer), never this hook.
const { result, sendNow } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.runEndByIndex(0), runEnd('completed'));
});
act(() => {
// The drain ran against an empty queue and consumed the signal — the
// outcome was already captured during render.
result.current.consumeIndexSignal(null);
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.parkedRunEnd).toMatchObject({
conversationId: CONVO_ID,
outcome: 'completed',
});
// The item stays queued for the drain to pick up in its turn.
expect(result.current.queue.map((item) => item.id)).toEqual(['s-reclaimed']);
expect(sendNow).not.toHaveBeenCalled();
});
it('re-arms from a run-end parked while the user was in another chat', () => {
// The run finished with this conversation off-screen, so its signal was
// parked rather than delivered on the index. Without watching the parked
// carrier too, the outcome would never be seen and the item would strand.
const { result } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.pendingRunEndByConvoId(CONVO_ID), runEnd('completed'));
});
act(() => {
result.current.consumeParkedSignal(null);
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.parkedRunEnd).toMatchObject({ outcome: 'completed' });
expect(result.current.queue.map((item) => item.id)).toEqual(['s-reclaimed']);
});
it.each(['aborted', 'error'] as const)(
'leaves the item for manual send when the run %s',
(outcome) => {
// The drain auto-sends only on a clean completion: a Stop or an error
// means the user is taking over, so nothing may smuggle the text out.
const { result, sendNow } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.runEndByIndex(0), runEnd(outcome));
});
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(sendNow).not.toHaveBeenCalled();
expect(result.current.parkedRunEnd).toBeNull();
expect(result.current.queue.map((item) => item.id)).toEqual(['s-reclaimed']);
},
);
it('leaves the item for manual send when the completed run was another chat', () => {
const { result, sendNow } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.runEndByIndex(0), runEnd('completed', 'convo-elsewhere'));
});
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(sendNow).not.toHaveBeenCalled();
expect(result.current.parkedRunEnd).toBeNull();
expect(result.current.queue).toHaveLength(1);
});
it('keeps older queued follow-ups ahead of the reclaimed steer', () => {
// The drain sends ONE item per run end, FIFO. Re-arming (rather than
// sending here) is what keeps an older follow-up from being skipped.
const { result } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.runEndByIndex(0), runEnd('completed'));
set(store.queuedMessagesByConvoId(CONVO_ID), [
{ id: 'older', text: 'queued first', createdAt: 500 },
]);
});
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.queue.map((item) => item.id)).toEqual(['older', 's-reclaimed']);
});
it('does not re-arm while this conversations run-end is still unconsumed', () => {
// The drain has not run yet, so it will see this item on its own. Arming
// a second carrier would drain twice and send two messages.
const { result } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.pendingRunEndByConvoId(CONVO_ID), runEnd('completed'));
set(store.runEndByIndex(0), runEnd('completed'));
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
// Untouched: the already-armed signal drains it.
expect(result.current.parkedRunEnd).toMatchObject({ outcome: 'completed' });
expect(result.current.queue.map((item) => item.id)).toEqual(['s-reclaimed']);
});
/** Renders the hook so the chat it points at can change under it, the way
* ChatForm reuses it when the user navigates. */
function setupNavigable(
initialProps: { convoId: string; isSubmitting: boolean },
initialize?: (snapshot: MutableSnapshot) => void,
) {
const sendNow = jest.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={initialize}>{children}</RecoilRoot>
);
const rendered = renderHook(
({ convoId, isSubmitting }: { convoId: string; isSubmitting: boolean }) => ({
steering: useSteering({
index: 0,
conversationId: convoId,
conversation: agentsConversation,
isSubmitting,
answerModeActive: false,
sendNow,
stopGenerating: jest.fn(),
}),
parkedHere: useRecoilValue(store.pendingRunEndByConvoId(CONVO_ID)),
queueHere: useQueue(CONVO_ID),
/** Stands in for the drain CONSUMING a signal it has acted on. */
consumeIndexSignal: useSetRecoilState(store.runEndByIndex(0)),
}),
{ wrapper, initialProps },
);
return { ...rendered, sendNow };
}
it('still re-arms the origin chat after the user navigates away', () => {
// Navigating away does not make the words any less owed a send: the run
// they belong to completed, so its queue must still drain on return.
const { result, rerender, sendNow } = setupNavigable(
{ convoId: CONVO_ID, isSubmitting: false },
({ set }) => {
set(store.runEndByIndex(0), runEnd('completed'));
},
);
// Captured while still on this chat, resolving after the user left.
const queueReclaimed = result.current.steering.queueReclaimedSteer;
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
rerender({ convoId: 'convo-elsewhere', isSubmitting: false });
});
act(() => {
queueReclaimed(reclaimed);
});
expect(result.current.parkedHere).toMatchObject({
conversationId: CONVO_ID,
outcome: 'completed',
});
expect(result.current.queueHere.map((item) => item.id)).toEqual(['s-reclaimed']);
expect(sendNow).not.toHaveBeenCalled();
});
it('never parks another chats run-end under this conversation', () => {
// The run-end is keyed by conversation, so the new chat's end can never
// be mistaken for this one's. Parking it here would give `drainNext` a
// foreign `end.conversationId` and drain the wrong queue into this chat.
const { result, rerender } = setupNavigable({ convoId: CONVO_ID, isSubmitting: true });
const queueReclaimed = result.current.steering.queueReclaimedSteer;
act(() => {
// The user leaves for a chat whose own run then completes.
rerender({ convoId: 'convo-elsewhere', isSubmitting: false });
});
act(() => {
result.current.consumeIndexSignal(runEnd('completed', 'convo-elsewhere'));
});
act(() => {
queueReclaimed(reclaimed);
});
expect(result.current.parkedHere).toBeNull();
expect(result.current.queueHere.map((item) => item.id)).toEqual(['s-reclaimed']);
});
it('does not re-arm from the end of an earlier run of the same chat', () => {
// A stale end must not authorize a drain: this chat's NEXT run is what
// owns the item, and its own end will drain it.
const { result, rerender } = setupNavigable(
{ convoId: CONVO_ID, isSubmitting: false },
({ set }) => {
set(store.runEndByIndex(0), runEnd('completed'));
},
);
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
// A new run starts on this same chat, superseding that end.
rerender({ convoId: CONVO_ID, isSubmitting: true });
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.parkedHere).toBeNull();
expect(result.current.queueHere.map((item) => item.id)).toEqual(['s-reclaimed']);
});
it('re-arms even when another conversations run-end occupies the index slot', () => {
// The index slot is shared. The drain parks a foreign signal under ITS
// conversation and then only inspects the active one's queue, so treating
// it as proof of an upcoming drain would strand this item.
const { result } = setupWithQueue({ isSubmitting: false }, ({ set }) => {
set(store.runEndByIndex(0), runEnd('completed'));
});
act(() => {
result.current.consumeIndexSignal(null);
});
act(() => {
// A later run on the shared index slot, belonging to a different chat.
result.current.consumeIndexSignal(runEnd('completed', 'convo-elsewhere'));
});
act(() => {
result.current.steering.queueReclaimedSteer(reclaimed);
});
expect(result.current.parkedRunEnd).toMatchObject({
conversationId: CONVO_ID,
outcome: 'completed',
});
expect(result.current.queue.map((item) => item.id)).toEqual(['s-reclaimed']);
});
});
describe('submitDuringRun', () => {
it('routes to the steer POST with an optimistic sending chip', () => {
const { result } = setup();
@ -244,6 +564,27 @@ describe('useSteering', () => {
]);
});
it('carries the submit time through the ACK so the pending chip is not re-timestamped', () => {
// A draft queued during the 202 round-trip must not drain ahead of a
// steer submitted before it — so the ACK'd chip keeps its SUBMIT time,
// not the (later) ACK time.
const now = jest.spyOn(Date, 'now').mockReturnValueOnce(1_000).mockReturnValue(9_000);
try {
mockMutate.mockImplementation((_params, { onSuccess }) => {
onSuccess({ steerId: 'srv-t', status: 'queued', position: 1, conversationId: CONVO_ID });
});
const { result } = setupWithState();
act(() => {
result.current.steering.submitSteer('submitted first');
});
expect(result.current.chips).toEqual([
expect.objectContaining({ steerId: 'srv-t', status: 'pending', createdAt: 1_000 }),
]);
} finally {
now.mockRestore();
}
});
it('does not duplicate a chip already reseeded under the server id (SSE reconnect)', () => {
mockMutate.mockImplementation((_params, { onSuccess }) => {
onSuccess({ steerId: 'srv-3', status: 'queued', position: 1, conversationId: CONVO_ID });

View file

@ -8,5 +8,5 @@ export { default as useIdChangeEffect } from './useIdChangeEffect';
export { default as useFocusChatEffect } from './useFocusChatEffect';
export { default as useQueueDrain } from './useQueueDrain';
export { default as useSteering } from './useSteering';
export { default as useSteerCancel } from './useSteerCancel';
export { default as useSteerCancel, useSteerReclaim } from './useSteerCancel';
export { default as useSteerConvert } from './useSteerConvert';

View file

@ -4,6 +4,43 @@ import type { PendingSteer } from '~/store/families';
import { useCancelSteerMutation } from '~/data-provider';
import store from '~/store';
/**
* `reclaimed` the cancel beat the boundary; the words never entered the run.
* `applied` the steer already injected (or the run ended): the events own it.
* `failed` the POST failed, so the entry is restored and the server may still
* inject it.
*/
export type SteerCancelOutcome = 'reclaimed' | 'applied' | 'failed';
/**
* Asks the server to drop a steer before its injection boundary, touching no
* chip state the caller owns what happens to the words.
*
* A steer leaves the server queue only by injecting, so only `reclaimed` proves
* the words never entered the run and are still the client's to re-home. Giving
* an `applied` steer a second life (queueing it, editing it back into the
* composer) would say the same thing twice; on `failed` the server may still
* inject it, so its fate is unknown and it must be left alone.
*/
export function useSteerReclaim(conversationId: string) {
const cancelMutation = useCancelSteerMutation();
return useCallback(
async (steer: PendingSteer): Promise<SteerCancelOutcome> => {
try {
const { removed } = await cancelMutation.mutateAsync({
conversationId,
steerId: steer.steerId,
});
return removed === true ? 'reclaimed' : 'applied';
} catch {
return 'failed';
}
},
[conversationId, cancelMutation],
);
}
/**
* Cancels a steer still waiting on its injection boundary. Optimistic: the
* entry leaves the chip stack immediately; `removed: false` needs no handling
@ -12,7 +49,7 @@ import store from '~/store';
* the supposedly-cancelled words.
*/
export default function useSteerCancel(conversationId: string) {
const cancelMutation = useCancelSteerMutation();
const reclaim = useSteerReclaim(conversationId);
const removeEntry = useRecoilCallback(
({ set }) =>
@ -44,13 +81,14 @@ export default function useSteerCancel(conversationId: string) {
);
return useCallback(
(steer: PendingSteer) => {
async (steer: PendingSteer): Promise<SteerCancelOutcome> => {
removeEntry(steer.steerId);
cancelMutation.mutate(
{ conversationId, steerId: steer.steerId },
{ onError: () => restoreEntry(steer) },
);
const outcome = await reclaim(steer);
if (outcome === 'failed') {
restoreEntry(steer);
}
return outcome;
},
[conversationId, removeEntry, restoreEntry, cancelMutation],
[reclaim, removeEntry, restoreEntry],
);
}

View file

@ -2,10 +2,15 @@ import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import type { TPendingSteer } from 'librechat-data-provider';
import type { QueuedMessage } from '~/store/families';
import type { SteerCarriedContext } from '~/utils';
import { appendAppliedSteerIds, carriedSteerContext } from '~/utils';
import { fetchStreamStatus } from '~/data-provider';
import store from '~/store';
/** A server-reported steer, or a local one that carries its own client-only
* context (quotes / skill picks) because its chip may already be gone. */
type ConvertibleSteer = TPendingSteer & SteerCarriedContext;
interface SteerConvertOptions {
/** Live-delivered terminal steers also have a parked server copy (the
* terminal drain parks before knowing the final reached a subscriber);
@ -35,7 +40,7 @@ interface SteerConvertOptions {
export default function useSteerConvert() {
const convert = useRecoilCallback(
({ snapshot, set }) =>
(conversationId: string, steers: TPendingSteer[]) => {
(conversationId: string, steers: ConvertibleSteer[]) => {
if (steers.length === 0) {
return;
}
@ -76,7 +81,10 @@ export default function useSteerConvert() {
text: steer.text,
createdAt: steer.createdAt ?? Date.now(),
...(steer.files && steer.files.length > 0 && { files: steer.files }),
...carriedSteerContext(chipById.get(steer.steerId)),
// The chip is the usual source, but a reclaimed steer may have
// lost its chip to a competing cancel mid-round-trip — it carries
// the context itself so the picks survive either way.
...carriedSteerContext(chipById.get(steer.steerId) ?? steer),
}));
if (fresh.length === 0) {
return prev;
@ -96,7 +104,7 @@ export default function useSteerConvert() {
);
return useCallback(
(conversationId: string, steers: TPendingSteer[], options?: SteerConvertOptions) => {
(conversationId: string, steers: ConvertibleSteer[], options?: SteerConvertOptions) => {
convert(conversationId, steers);
if (options?.claimParked !== true || steers.length === 0) {
return;

View file

@ -4,7 +4,7 @@ import { useToastContext } from '@librechat/client';
import { useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil';
import { Constants, ContentTypes, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage, TConversation, TMessageContentParts } from 'librechat-data-provider';
import type { PendingSteer, QueuedMessage } from '~/store/families';
import type { RunEnd, PendingSteer, QueuedMessage } from '~/store/families';
import type { ExtendedFile, FileSetter } from '~/common';
import {
useGetMessagesByConvoId,
@ -12,6 +12,7 @@ import {
useMarkFilesUsageMutation,
} from '~/data-provider';
import { carriedSteerContext, clearAllDrafts } from '~/utils';
import useSteerConvert from '~/hooks/Chat/useSteerConvert';
import { useSetFilesToDelete } from '~/hooks/Files';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
@ -116,6 +117,7 @@ export default function useSteering({
const localize = useLocalize();
const { showToast } = useToastContext();
const setFilesToDelete = useSetFilesToDelete();
const convertSteersToQueued = useSteerConvert();
const steerMutation = useSteerMessageMutation();
const markFilesUsage = useMarkFilesUsageMutation();
const defaultAction = useRecoilValue<DuringRunAction>(store.duringRunDefaultAction);
@ -150,6 +152,31 @@ export default function useSteering({
const isSubmittingRef = useRef(isSubmitting);
isSubmittingRef.current = isSubmitting;
/**
* How each conversation's last run ended, kept because `useQueueDrain`
* CONSUMES the one-shot signal by the time a reclaim resolves it is already
* gone. Every subscriber renders before the drain's effect nulls it, so the
* outcome is captured first. Both carriers are watched: the index signal, and
* the copy parked under the conversation when the run ended while the user
* was looking elsewhere.
*
* Keyed by conversation because this hook is REUSED across chats: a single
* slot would answer for whichever chat is on screen when the reclaim lands,
* not the one the words belong to. An entry is dropped once that conversation
* starts another run an older end no longer describes what is happening.
*/
const runEnd = useRecoilValue(store.runEndByIndex(index));
const parkedRunEnd = useRecoilValue(store.pendingRunEndByConvoId(queueKey));
const runEndsRef = useRef<Map<string, RunEnd>>(new Map());
const observedRunEnd = [runEnd, parkedRunEnd].find(
(end) => end != null && end.conversationId === conversationId,
);
if (observedRunEnd != null) {
runEndsRef.current.set(conversationId, observedRunEnd);
} else if (isSubmitting) {
runEndsRef.current.delete(conversationId);
}
const upsertSteerChip = useRecoilCallback(
({ set }) =>
(convoId: string, steer: PendingSteer) => {
@ -378,6 +405,30 @@ export default function useSteering({
[queueKey],
);
/**
* Re-posts a spent run-end signal so the drain wakes and reconsiders the
* queue. No-op while a signal for THIS conversation is still armed: that
* drain has not run yet and will see the item on its own, so arming a second
* carrier would drain twice and send two messages.
*
* A signal for a DIFFERENT conversation is not that proof. The index slot is
* shared, and the drain parks a foreign signal under its own conversation and
* then only inspects the active one's queue this item would never be looked
* at. Park ours alongside it.
*/
const rearmDrain = useRecoilCallback(
({ snapshot, set }) =>
(convoId: string, end: RunEnd) => {
const indexArmed = snapshot.getLoadable(store.runEndByIndex(index)).getValue();
const parkedArmed = snapshot.getLoadable(store.pendingRunEndByConvoId(convoId)).getValue();
if (indexArmed?.conversationId === convoId || parkedArmed != null) {
return;
}
set(store.pendingRunEndByConvoId(convoId), end);
},
[index],
);
const armDrainAfterAbort = useRecoilCallback(
({ set }) =>
() => {
@ -402,11 +453,16 @@ export default function useSteering({
* leftover report) can restore the queued item's full context. */
const carried = carriedSteerContext(context);
const localId = `local-${v4()}`;
/** The true submission time, carried through the ACK and failure chips so
* a later conversion sorts this steer by when it was SENT, not when its
* 202 landed otherwise a draft queued during the round-trip would drain
* ahead of a steer submitted before it. */
const createdAt = Date.now();
upsertSteerChip(conversationId, {
steerId: localId,
text: trimmed,
status: 'sending',
createdAt: Date.now(),
createdAt,
...(files && { files }),
...carried,
});
@ -418,7 +474,7 @@ export default function useSteering({
steerId: response.steerId,
text: trimmed,
status: 'pending',
createdAt: Date.now(),
createdAt,
...(files && { files }),
...carried,
});
@ -464,7 +520,7 @@ export default function useSteering({
steerId: localId,
text: trimmed,
status: 'failed',
createdAt: Date.now(),
createdAt,
...(files && { files }),
...carried,
});
@ -541,6 +597,55 @@ export default function useSteering({
[conversationId, replaceSteerChip],
);
/**
* Re-homes a steer the client just reclaimed from the server queue (see
* `useSteerReclaim`) as a queued follow-up.
*
* Routed through the shared conversion rather than `enqueue` so it obeys the
* same invariant as the leftover-steer path: the item keeps its ORIGINAL id
* and `createdAt`, so a steer accepted before a later follow-up still drains
* ahead of it a fresh `Date.now()` would sort it last.
*
* The reclaim is a round-trip, so the run can end while it is in flight and
* the drain can consume its one-shot run-end signal against an empty queue,
* leaving nothing to auto-send this item. Rather than send it here, re-post
* that spent signal so `useQueueDrain` runs again and decides: it owns the
* completed-only rule, FIFO order, `NEW_CONVO` migration, and submitting 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).
*
* Parked under the conversation rather than the index, so a run that ended
* while the user was elsewhere still drains when they come back.
*/
const queueReclaimedSteer = useCallback(
(steer: PendingSteer) => {
convertSteersToQueued(conversationId, [
{
steerId: steer.steerId,
text: steer.text,
createdAt: steer.createdAt,
...(steer.files && steer.files.length > 0 && { files: steer.files }),
/** Carried on the steer itself: the conversion normally recovers this
* from the chip, which a competing cancel can remove mid-reclaim. */
...carriedSteerContext(steer),
},
]);
/**
* Read by conversation, so it describes THIS steer's run even once the
* hook has moved to another chat the user navigating away does not make
* their words any less owed a send. No entry means that run is still going
* (or has started another): its own end is ahead of this item and drains
* it, so there is nothing to re-arm.
*/
const lastRunEnd = runEndsRef.current.get(conversationId);
if (lastRunEnd == null || lastRunEnd.outcome !== 'completed') {
return;
}
rearmDrain(conversationId, lastRunEnd);
},
[conversationId, convertSteersToQueued, rearmDrain],
);
/** Convert a failed/unsent steer chip into a queued follow-up. */
const convertSteerToQueue = useCallback(
(
@ -659,6 +764,7 @@ export default function useSteering({
retrySteer,
removeSteer,
convertSteerToQueue,
queueReclaimedSteer,
enqueue,
removeQueued,
sendQueuedNow,

View file

@ -1872,11 +1872,15 @@
"com_ui_stateful_sessions": "Stateful code sessions",
"com_ui_status_prefix": "Status:",
"com_ui_steer": "Steer",
"com_ui_steer_already_applied": "That steering message already reached the agent, so it was left in the response",
"com_ui_steer_cancel": "Cancel steering message",
"com_ui_steer_cancel_failed": "Could not cancel the steering message — it may still reach the agent",
"com_ui_steer_edit_queued": "Your composer already has a draft, so that steering message was queued for after the response instead",
"com_ui_steer_failed": "Steering failed",
"com_ui_steer_in_flight": "Steering",
"com_ui_steer_paused_queued": "The agent is waiting for your review — your message was queued instead",
"com_ui_steer_retry": "Retry steering",
"com_ui_steer_run_ended_queued": "The response ended, so that steering message is queued as a follow-up",
"com_ui_steer_send": "Steer the current response",
"com_ui_steered_info": "You added this message while the response was generating, so it was inserted into the response at this point.",
"com_ui_stop": "Stop",

View file

@ -96,7 +96,7 @@ export function appendAppliedSteerIds(prev: string[], steerIds: string[]): strin
return [...prev, ...fresh].slice(-APPLIED_STEER_IDS_CAP);
}
type SteerCarriedContext = { quotes?: string[]; manualSkills?: string[] };
export type SteerCarriedContext = { quotes?: string[]; manualSkills?: string[] };
/** Quotes/skill picks are client-only (a steer never sends them to the
* server); chip mints, reseeds, and queued conversions carry them from the