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: ,
+ 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: ,
+ 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: ,
+ 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 (
{!sending && (
- /* Hidden-at-rest only on hover-capable pointers: a hover-revealed
- * control is unreachable on touch until a first tap. */
- 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"
- >
-
-
+ /* 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). */
+
+
+
)}
{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' ? (
-
- ) : (
-
- ),
- 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({
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}
>
@@ -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}
/>
))}
diff --git a/client/src/components/Chat/Input/SteerMenu.tsx b/client/src/components/Chat/Input/SteerMenu.tsx
new file mode 100644
index 0000000000..a5ba0c6fe9
--- /dev/null
+++ b/client/src/components/Chat/Input/SteerMenu.tsx
@@ -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 (
+ <>
+
+
+
+
+ {entries.map((entry) => (
+ {
+ entry.onClick();
+ menu.hide();
+ }}
+ >
+ {entry.icon}
+ {entry.label}
+
+ ))}
+
+ >
+ );
+}
+
+/**
+ * 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' ? (
+
+ ) : (
+
+ ),
+ onClick: () => steering.setDefaultAction(next),
+ };
+ }, [steering, localize]);
+}
diff --git a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
index cd4c3695cf..7edf51b876 100644
--- a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
+++ b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx
@@ -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(
-
+
,
);
}
+/** 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', () => {
]);
}}
>
-
+
,
);
expect(screen.getByTestId('in-flight-steers').scrollTop).toBe(600);
diff --git a/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx
new file mode 100644
index 0000000000..e3ebc72ec2
--- /dev/null
+++ b/client/src/components/Chat/Input/__tests__/PendingSteerChips.test.tsx
@@ -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(
+ {
+ set(store.queuedMessagesByConvoId(CONVO_ID), queued);
+ }}
+ >
+
+ ,
+ );
+}
+
+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');
+ });
+});
diff --git a/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx b/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx
index 97e9cb7acd..da8edcdd7a 100644
--- a/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/useSteerConvert.spec.tsx
@@ -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(() => {
diff --git a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
index f38b9116bc..8fa7a22e45 100644
--- a/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
+++ b/client/src/hooks/Chat/__tests__/useSteering.spec.tsx
@@ -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 }) => (
+ {children}
+ );
+ 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 conversation’s 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 }) => (
+ {children}
+ );
+ 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 chat’s 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 conversation’s 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 });
diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts
index 2ddeeb2a8d..3dc2a8fd65 100644
--- a/client/src/hooks/Chat/index.ts
+++ b/client/src/hooks/Chat/index.ts
@@ -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';
diff --git a/client/src/hooks/Chat/useSteerCancel.ts b/client/src/hooks/Chat/useSteerCancel.ts
index 396fc6ee1f..85cb1f879b 100644
--- a/client/src/hooks/Chat/useSteerCancel.ts
+++ b/client/src/hooks/Chat/useSteerCancel.ts
@@ -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 => {
+ 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 => {
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],
);
}
diff --git a/client/src/hooks/Chat/useSteerConvert.ts b/client/src/hooks/Chat/useSteerConvert.ts
index 1c01a2140e..2e4912f076 100644
--- a/client/src/hooks/Chat/useSteerConvert.ts
+++ b/client/src/hooks/Chat/useSteerConvert.ts
@@ -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;
diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts
index dab5eda06a..daab543e8e 100644
--- a/client/src/hooks/Chat/useSteering.ts
+++ b/client/src/hooks/Chat/useSteering.ts
@@ -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(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>(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,
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 9e22157c90..907147c5ce 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/client/src/utils/steer.ts b/client/src/utils/steer.ts
index 8478195a1c..329b3eecfc 100644
--- a/client/src/utils/steer.ts
+++ b/client/src/utils/steer.ts
@@ -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