feat: show pending steers inside the streaming reply

This commit is contained in:
Marco Beretta 2026-07-26 14:52:24 +02:00
parent 0616aa4bf2
commit 027dc4e1bd
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
11 changed files with 232 additions and 2118 deletions

View file

@ -33,7 +33,6 @@ import useDictation from '~/hooks/Input/useDictation';
import { useGetStartupConfig } from '~/data-provider';
import useSteering from '~/hooks/Chat/useSteering';
import { BadgeRowProvider } from '~/Providers';
import InFlightSteers from './InFlightSteers';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import SkillsCommand from './SkillsCommand';
@ -516,18 +515,7 @@ const ChatForm = memo(function ChatForm({
<div className="relative flex h-full min-w-0 flex-1 items-stretch md:flex-col">
{/* Primary composer owns the selection popup so split-view doesn't double it. */}
{index === 0 && quotesEnabled && <QuoteButton conversationId={conversationId} />}
{/* `relative` anchors the in-flight steer overlay, which floats above
the composer (`bottom-full`) over the bottom of the thread. */}
<div className="relative 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
steering={steering}
conversationId={conversationId}
onRestoreToComposer={restoreReclaimedSteer}
/>
)}
{steering.enabled && (
<Queue
steering={steering}

View file

@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil';
import { X, Clock, Pencil } from 'lucide-react';
import type { TMessage } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { RestoreToComposer } from '../InFlightSteers';
import type { QueuedMessage } from '~/store/families';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -11,6 +10,16 @@ import store from '~/store';
const ICON_BTN =
'shrink-0 rounded-full p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
/** Restores a message's text into the composer, or refuses (false) when the
* composer is occupied / on another chat see `restoreReclaimedSteer` in
* `ChatForm`. Used by the queue rail's edit/trash actions. */
export type RestoreToComposer = (
text: string,
files: TMessage['files'],
context: QueuedMessageContext,
originConversationId: string,
) => boolean;
interface QueueProps {
steering: SteeringControls;
conversationId: string;

View file

@ -1,645 +0,0 @@
import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { useSetAtom, useAtomValue } from 'jotai';
import { useToastContext } from '@librechat/client';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
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 {
RowMenu,
EscalateNowButton,
useDefaultToggleEntry,
useInterruptToggleEntry,
} from './SteerMenu';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
import { supportsGenerationProtocolV2, useArmSteerMutation } from '~/data-provider';
import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
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 { 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']> = [];
for (const file of files ?? []) {
(file.type?.startsWith('image/') === true ? images : others).push(file);
}
return { images, others };
};
/** Collapsed preview height (px) for a long steer before "Show more". Matched
* to the JS overflow check below so the toggle appears exactly when clipped;
* the tolerance absorbs the trailing markdown margin so content that fits but
* for its own bottom margin does not trip a pointless toggle. */
const STEER_COLLAPSED_MAX_HEIGHT = 128;
const STEER_OVERFLOW_TOLERANCE = 8;
/** Axios has no default request timeout. Bound the UI lock while preserving an
* honest unknown outcome; the idempotent arm may still complete server-side. */
const ARM_CONFIRM_TIMEOUT_MS = 10_000;
type ArmFailure = {
name?: string;
response?: { data?: { code?: string } };
};
/** Only a failure without an HTTP response leaves the server-side outcome
* unknown. An HTTP rejection is a known response and must not replay the arm. */
const isAmbiguousArmFailure = (error: unknown): boolean => {
const failure = error as ArmFailure | null | undefined;
return failure?.name !== 'AbortError' && failure?.response == null;
};
const armFailureCode = (error: unknown): string | undefined =>
(error as ArmFailure | null | undefined)?.response?.data?.code;
/**
* One steer on its way into the run, anchored above the composer as a message
* bubble rather than a control chip the words are already part of the
* conversation, they just have no in-thread index yet. It leaves on
* `on_steer_applied`, when the persisted STEER part lands at its authoritative
* position in the response.
*
* Text and attachments render through the same leaves as the applied
* `SteerPart` (markdown toggle, file preview) so the words don't reformat the
* moment the server injects them.
*
* `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. 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,
interruptPending,
onRestoreToComposer,
}: {
steer: PendingSteer;
steering: SteeringControls;
conversationId: string;
interruptPending: boolean;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const cancelSteer = useSteerCancel(conversationId);
const reclaimSteer = useSteerReclaim(conversationId);
const toggleEntry = useDefaultToggleEntry(steering);
const interruptToggle = useInterruptToggleEntry();
const enableUserMsgMarkdown = useRecoilValue<boolean>(store.enableUserMsgMarkdown);
const activeGenerationCreatedAt = useRecoilValue(
store.activeGenerationCreatedAtByConvoId(conversationId),
);
const activeGenerationProtocolVersion = useRecoilValue(
store.activeGenerationProtocolVersionByConvoId(conversationId),
);
const [selectedFile, setSelectedFile] = useState<Partial<TFile> | null>(null);
const optionsButtonRef = useRef<HTMLButtonElement>(null);
const [escalationAnnouncement, setEscalationAnnouncement] = useState('');
const handlePreviewClose = useCallback((open: boolean) => {
if (!open) {
setSelectedFile(null);
}
}, []);
const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]);
const sending = steer.status === 'sending';
const preempting = steer.preempt === true;
/** Long steers (several paragraphs) collapse to a preview so the stack stays
* scannable; the toggle is offered only once the content actually overflows
* the cap. `scrollHeight` reports the full height even while clamped, so the
* same check holds whether expanded or not, and the observer re-measures on
* the width reflows that change wrapped-line count. */
const contentRef = useRef<HTMLDivElement>(null);
const contentId = useId();
const [expanded, setExpanded] = useState(false);
const [overflowing, setOverflowing] = useState(false);
useEffect(() => {
const el = contentRef.current;
if (el == null) {
return;
}
const measure = () =>
setOverflowing(el.scrollHeight - STEER_COLLAPSED_MAX_HEIGHT > STEER_OVERFLOW_TOLERANCE);
measure();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
/** Relabels the chip in place once the server confirms the durable arm
* same steerId, same position, only the `preempt` flag flips. */
const markSteerPreempt = useRecoilCallback(
({ set }) =>
(steerId: string, revision: number) =>
set(store.pendingSteersByConvoId(conversationId), (prev) =>
prev.map((item) =>
item.steerId === steerId && revision >= (item.preemptRevision ?? 0)
? { ...item, preempt: true, preemptRevision: revision }
: item,
),
),
[conversationId],
);
const { mutateAsync: armSteer } = useArmSteerMutation();
const setEscalating = useSetAtom(escalatingSteerFamily(conversationId));
/**
* Escalate this waiting steer to an interrupt: one idempotent server op
* flips `preempt` on the EXISTING queued item, so its FIFO position, id, and
* timestamp survive and there is no reclaim window to race. A transport
* failure is retried once because the first request may have committed even
* though its response was lost. Every "too late" interleaving (drained,
* cancelled, run ended or replaced) is the same honest `armed: false`, and
* the chip is only relabelled on a confirmed durable arm. The escalating
* flag flips synchronously, before the request: the chip-derived gate cannot
* see this arm until the response lands, and the other escalation controls
* advertise "one interrupt at a time".
*/
const escalate = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const generationCreatedAt =
steer.generationCreatedAt ?? activeGenerationCreatedAt ?? undefined;
if (generationCreatedAt == null) {
return;
}
const trigger = event.currentTarget;
setEscalationAnnouncement('');
setEscalating(true);
const params = {
conversationId,
steerId: steer.steerId,
...(generationCreatedAt != null && { generationCreatedAt }),
};
const requestArm = async () => {
let firstResponseWasLost = false;
let acceptingRetry = true;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const firstAttempt = armSteer(params);
const attempts =
activeGenerationProtocolVersion === 2
? firstAttempt.catch((error) => {
/** If the overall confirmation window already closed, do not let a
* very late rejection launch a detached retry behind the user's
* back. The first request itself may still have committed. */
if (!acceptingRetry) {
throw error;
}
if (!isAmbiguousArmFailure(error)) {
throw error;
}
firstResponseWasLost = true;
return armSteer(params);
})
: firstAttempt;
const response = await Promise.race([
attempts,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error('Steer arm confirmation timed out')),
ARM_CONFIRM_TIMEOUT_MS,
);
}),
]);
const responseSupportsNegotiatedProtocol =
activeGenerationProtocolVersion === 1 || supportsGenerationProtocolV2(response);
if (responseSupportsNegotiatedProtocol && response.armed === true) {
/** The successful state removes the arm button. Move focus to the
* stable options control only if the user has not moved elsewhere
* while the request was pending. */
if (document.activeElement === trigger) {
optionsButtonRef.current?.focus();
}
setEscalationAnnouncement(localize('com_ui_steer_in_flight_preempt'));
markSteerPreempt(steer.steerId, response.preemptRevision ?? 0);
return;
}
if (!responseSupportsNegotiatedProtocol) {
showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' });
return;
}
/** Once a response was lost, a later `armed: false` cannot prove the
* first request did not commit: the steer may have drained or the job
* may have paused between attempts. Keep the chip event-driven and
* report the result as unknown instead of claiming a lost race. */
if (firstResponseWasLost) {
showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' });
return;
}
/* `armed: false` is deliberately ambiguous injected, cancelled,
* re-homed, or run over so the message only says the escalation
* lost, and the chip defers to the events for what happened. */
showToast({
message: localize(
response.code === 'PREEMPT_UNSUPPORTED'
? 'com_ui_steer_preempt_unsupported'
: 'com_ui_steer_arm_lost_race',
),
status: 'info',
});
} catch (error) {
const ambiguous = isAmbiguousArmFailure(error);
if (ambiguous) {
showToast({
message: localize('com_ui_steer_arm_unconfirmed'),
status: 'warning',
});
return;
}
showToast({
message: localize(
armFailureCode(error) === 'PREEMPT_UNSUPPORTED'
? 'com_ui_steer_preempt_unsupported'
: 'com_ui_steer_arm_lost_race',
),
status: 'info',
});
} finally {
acceptingRetry = false;
clearTimeout(timeout);
}
};
void requestArm().finally(() => setEscalating(false));
},
[
armSteer,
conversationId,
steer.steerId,
steer.generationCreatedAt,
activeGenerationCreatedAt,
activeGenerationProtocolVersion,
setEscalating,
markSteerPreempt,
showToast,
localize,
],
);
/**
* 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;
}
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' });
});
},
},
{
/* 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;
}
// useSteerReclaim has tombstoned both ids and removed any terminal
// recovery copy, so exactly one client destination is restored here.
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' });
}
});
},
},
{
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);
}
});
},
},
];
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
return (
<div
role="listitem"
data-testid="in-flight-steer"
data-steer-status={steer.status}
data-steer-preempt={preempting ? 'true' : undefined}
/* pointer-events-auto: the overlay container disables events so wheeling
* over the gaps reaches the messages behind; each bubble re-enables them
* for its own controls and internal scroll. */
/* `max-w-full`: the overlay stacks its bubbles with `items-start`, so each
* one is sized by its content a single unbroken word made the bubble as
* wide as the word and it ran off the composer, with nothing narrow
* enough for `break-words` to wrap against. */
className="group pointer-events-auto flex min-w-0 max-w-full flex-col items-start gap-1.5"
>
{(images.length > 0 || others.length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{others.map((file) => (
<FileContainer
key={file.file_id}
file={file as TFile}
onClick={() => setSelectedFile(file)}
/>
))}
{images.map((file) => (
<div
key={file.file_id}
className="overflow-hidden rounded-xl border border-border-light"
>
<ImagePreview
url={file.preview ?? file.filepath}
alt={file.filename ?? localize('com_ui_attached_image')}
/>
</div>
))}
</div>
)}
{/* items-start so the sticky controls have room to travel — see below. */}
<div className="flex max-w-full items-start gap-1.5">
<div
className={cn(
/* Outlined, not just filled: an in-flight steer is provisional
* the fill alone reads as a settled message. */
'flex min-w-0 items-start gap-2 rounded-3xl border border-border-medium',
'bg-surface-secondary py-2 pl-3 pr-4 text-sm text-text-primary',
sending && 'opacity-70',
)}
>
{preempting ? (
<ZapOff className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
) : (
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
)}
<span className="sr-only">
{localize(preempting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_in_flight')}
</span>
<div className="flex min-w-0 flex-col items-start gap-1">
<div
ref={contentRef}
id={contentId}
className={cn('relative w-full', !expanded && 'overflow-hidden')}
style={!expanded ? { maxHeight: STEER_COLLAPSED_MAX_HEIGHT } : undefined}
>
<div
className={cn(
'markdown prose message-content dark:prose-invert light min-w-0 break-words',
'dark:text-gray-20',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{/* No code execution: this bubble sits outside MessageContext, so
* Run Code would fire with no message/part to target. */}
{enableUserMsgMarkdown ? (
<MarkdownLite content={steer.text} codeExecution={false} />
) : (
steer.text
)}
</div>
{!expanded && overflowing && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-surface-secondary to-transparent"
aria-hidden="true"
/>
)}
</div>
{overflowing && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={contentId}
className="inline-flex items-center gap-1 rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
>
{expanded ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
{expanded ? localize('com_ui_show_less') : localize('com_ui_show_more')}
</button>
)}
</div>
</div>
{!sending && (
/* 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). `sticky` keeps it in view while the
* user scrolls through a tall, expanded steer (the stack scrolls once
* it passes 35vh). */
<div
data-testid="steer-controls"
className="sticky top-2 flex shrink-0 items-center gap-1"
>
{!preempting && (
<EscalateNowButton
surface="bubble"
messageText={steer.text}
disabled={
interruptPending || steering.pausedOnApproval || !steering.duringRunActive
}
onClick={escalate}
/>
)}
<RowMenu
label={localize('com_ui_more_options')}
entries={entries}
preferences={preferences}
buttonRef={optionsButtonRef}
/>
</div>
)}
</div>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{escalationAnnouncement}
</span>
{others.length > 0 && (
<FilePreviewDialog
open={selectedFile !== null}
onOpenChange={handlePreviewClose}
fileName={selectedFile?.filename ?? ''}
fileId={selectedFile?.file_id}
filePath={selectedFile?.filepath}
fileType={selectedFile?.type ?? undefined}
fileSize={(selectedFile as TFile | null)?.bytes}
/>
)}
</div>
);
});
/**
* Steers the server hasn't applied yet, stacked directly above the composer.
* Anchoring them here (instead of guessing an in-thread injection point on the
* streaming message) keeps the thread showing only what the server actually
* 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));
const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]);
/** Mirrors `PendingSteerChips`: while one interrupt is unresolved, every
* other escalation control disables rather than arming a second seal. The
* escalating flag covers an arm request's round trip, before its chip
* relabels for the chip-derived check to see. */
const escalating = useAtomValue(escalatingSteerFamily(conversationId));
const interruptPending = useMemo(
() => escalating || inFlight.some((steer) => steer.preempt === true),
[escalating, inFlight],
);
const setOverlayHeight = useSetAtom(steerOverlayHeightFamily(conversationId));
/** Steers append newest-last, so an overflowing stack would sit scrolled to
* the oldest the steer just submitted (and its cancel) would be below the
* fold and read as dropped. Keyed on the newest id, not every render. */
const listRef = useRef<HTMLDivElement>(null);
const newestId = inFlight[inFlight.length - 1]?.steerId;
useEffect(() => {
const list = listRef.current;
if (list != null) {
list.scrollTop = list.scrollHeight;
}
}, [newestId]);
/** The overlay is pulled out of flow (absolute), so the messages no longer
* shrink to fit it. Publish its rendered height so the message scroll area
* can reserve an equal band of bottom padding keeping the newest message
* clear of the overlay at rest while older ones scroll behind it. */
useEffect(() => {
const list = listRef.current;
if (list == null) {
setOverlayHeight(0);
return;
}
const publish = () => setOverlayHeight(list.offsetHeight);
publish();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(publish);
observer.observe(list);
return () => observer.disconnect();
}, [setOverlayHeight, inFlight.length]);
/** Drop the reserved band when the overlay leaves (run ends while steers are
* still in flight, or conversation switch) the measure effect above only
* resets when it re-runs, which unmount does not do. */
useEffect(() => () => setOverlayHeight(0), [setOverlayHeight]);
if (inFlight.length === 0) {
return null;
}
return (
<div
ref={listRef}
role="list"
aria-label={localize('com_ui_steer_in_flight')}
data-testid="in-flight-steers"
/* Floats above the composer over the bottom of the thread instead of
* displacing it, so scrolling up reveals the messages behind. Capped: a
* steer runs to 16k chars and a run takes up to 10 of them; unbounded it
* would cover the whole thread. pointer-events-none lets wheeling over
* the gaps reach those messages (each bubble opts back in). */
className="pointer-events-none absolute inset-x-0 bottom-full 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}
steering={steering}
conversationId={conversationId}
interruptPending={interruptPending}
onRestoreToComposer={onRestoreToComposer}
/>
))}
</div>
);
});
export default InFlightSteers;

View file

@ -1,346 +0,0 @@
import { Fragment, useEffect, useId, useMemo, useState, useSyncExternalStore } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { Zap, ZapOff, Clock, ArrowUp, CircleHelp, MoreHorizontal } from 'lucide-react';
import type { Ref } from 'react';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/** 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';
const ESCALATION_MESSAGE_LABEL_MAX_LENGTH = 80;
const activeEscalateListeners = new Set<() => void>();
let hoveredEscalateTarget: string | null = null;
let focusedEscalateTarget: string | null = null;
function getActiveEscalateTarget() {
return focusedEscalateTarget ?? hoveredEscalateTarget;
}
function subscribeToActiveEscalateTarget(listener: () => void) {
activeEscalateListeners.add(listener);
return () => activeEscalateListeners.delete(listener);
}
function updateActiveEscalateTarget(kind: 'hover' | 'focus', targetId: string, active: boolean) {
const previous = getActiveEscalateTarget();
if (kind === 'hover') {
if (active) {
hoveredEscalateTarget = targetId;
} else if (hoveredEscalateTarget === targetId) {
hoveredEscalateTarget = null;
}
} else {
if (active) {
focusedEscalateTarget = targetId;
} else if (focusedEscalateTarget === targetId) {
focusedEscalateTarget = null;
}
}
if (previous !== getActiveEscalateTarget()) {
activeEscalateListeners.forEach((listener) => listener());
}
}
function clearActiveEscalateTarget(targetId: string) {
const previous = getActiveEscalateTarget();
if (hoveredEscalateTarget === targetId) {
hoveredEscalateTarget = null;
}
if (focusedEscalateTarget === targetId) {
focusedEscalateTarget = null;
}
if (previous !== getActiveEscalateTarget()) {
activeEscalateListeners.forEach((listener) => listener());
}
}
export type MenuEntry = {
key: string;
label: string;
icon: React.ReactNode;
onClick: () => void;
disabled?: boolean;
/** Localized description announced on the action and exposed by a help disclosure. */
info?: string;
};
/**
* Per-row "…" overflow menu: message actions first, then a visually separated
* "Preferences" section for the sticky mode toggles, so one-off actions and
* persistent behavior changes never read as the same kind of choice.
*/
export function RowMenu({
label,
entries,
preferences,
buttonRef,
}: {
label: string;
entries: MenuEntry[];
preferences?: MenuEntry[];
buttonRef?: Ref<HTMLButtonElement>;
}) {
const localize = useLocalize();
const menu = Ariakit.useMenuStore({ placement: 'top-end' });
const descriptionPrefix = useId();
const preferencesLabelId = `${descriptionPrefix}-preferences`;
const [expandedInfoKey, setExpandedInfoKey] = useState<string | null>(null);
const renderEntry = (entry: MenuEntry) => {
const descriptionId = entry.info == null ? undefined : `${descriptionPrefix}-${entry.key}`;
const infoExpanded = entry.info != null && expandedInfoKey === entry.key;
const action = (
<Ariakit.MenuItem
className={MENU_ITEM_CLASS}
disabled={entry.disabled === true}
accessibleWhenDisabled
aria-describedby={descriptionId}
onClick={() => {
entry.onClick();
setExpandedInfoKey(null);
menu.hide();
}}
>
{entry.icon}
{entry.label}
</Ariakit.MenuItem>
);
if (entry.info == null || descriptionId == null) {
return <Fragment key={entry.key}>{action}</Fragment>;
}
return (
<div key={entry.key} role="none">
<div role="none" className="flex items-center gap-0.5">
<div role="none" className="min-w-0 flex-1">
{action}
</div>
<Ariakit.MenuItem
aria-label={`${localize('com_ui_more_info')}: ${entry.label}`}
aria-expanded={infoExpanded}
aria-controls={descriptionId}
hideOnClick={false}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy data-[active-item]:bg-surface-tertiary"
onClick={() =>
setExpandedInfoKey((current) => (current === entry.key ? null : entry.key))
}
>
<CircleHelp className="h-3.5 w-3.5" aria-hidden="true" />
</Ariakit.MenuItem>
</div>
<div
id={descriptionId}
className={cn(
infoExpanded
? 'mx-2 mb-1 rounded-lg bg-surface-tertiary px-2 py-1.5 text-xs leading-relaxed text-text-secondary'
: 'sr-only',
)}
>
{entry.info}
</div>
</div>
);
};
return (
<>
<Ariakit.MenuButton
ref={buttonRef}
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(renderEntry)}
{preferences != null && preferences.length > 0 && (
<>
<div role="separator" className="mx-2 my-1 border-t border-border-light" />
<div role="group" aria-labelledby={preferencesLabelId}>
<div
id={preferencesLabelId}
className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-text-secondary"
>
{localize('com_ui_preferences')}
</div>
{preferences.map(renderEntry)}
</div>
</>
)}
</Ariakit.Menu>
</>
);
}
/**
* The always-visible escalation control on a waiting message: interrupt &
* steer it now, at the next safe token boundary. The tooltip teaches this
* action's OWN shortcut (registry-aware, so a rebinding shows correctly).
* Hover/focus marks this exact button as the active shortcut target; without
* an active row the shortcut retains its newest-waiting-message fallback.
*/
export function EscalateNowButton({
surface,
disabled,
messageText,
onClick,
}: {
surface: 'bubble' | 'queued';
disabled: boolean;
messageText: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const localize = useLocalize();
const chord = useShortcutDisplay('escalateSteer');
const ariaKey = useShortcutAriaKey('escalateSteer');
const targetId = useId();
const activeTarget = useSyncExternalStore(
subscribeToActiveEscalateTarget,
getActiveEscalateTarget,
() => null,
);
const isActive = !disabled && activeTarget === targetId;
const label = localize('com_ui_interrupt_steer_now');
const normalizedMessageLabel = messageText.trim().replace(/\s+/g, ' ');
const messageCharacters = Array.from(normalizedMessageLabel);
const messageLabel =
messageCharacters.length > ESCALATION_MESSAGE_LABEL_MAX_LENGTH
? `${messageCharacters
.slice(0, ESCALATION_MESSAGE_LABEL_MAX_LENGTH - 1)
.join('')
.trimEnd()}`
: normalizedMessageLabel;
const accessibleLabel = messageLabel.length > 0 ? `${label}: ${messageLabel}` : label;
useEffect(() => {
if (disabled) {
clearActiveEscalateTarget(targetId);
}
return () => clearActiveEscalateTarget(targetId);
}, [disabled, targetId]);
return (
<>
{/* Thin divider binds the arrow to the message on its left, so it can
* never read as part of the bare-glyph menu control beside it the
* send-now belongs to THIS chip, and the pairing repeats cleanly when
* several messages stack. */}
<span aria-hidden="true" className="h-[18px] w-px shrink-0 bg-border-medium" />
<Ariakit.TooltipProvider placement="top" timeout={300}>
<Ariakit.TooltipAnchor
render={
<button
type="button"
aria-label={accessibleLabel}
aria-keyshortcuts={isActive ? ariaKey : undefined}
data-escalate-steer={surface}
data-escalate-steer-active={isActive ? 'true' : undefined}
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
disabled={disabled}
onPointerEnter={() =>
!disabled && updateActiveEscalateTarget('hover', targetId, true)
}
onPointerLeave={() => updateActiveEscalateTarget('hover', targetId, false)}
onFocus={() => !disabled && updateActiveEscalateTarget('focus', targetId, true)}
onBlur={() => updateActiveEscalateTarget('focus', targetId, false)}
onClick={onClick}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
'bg-text-primary text-surface-primary transition-opacity hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
'disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35',
)}
>
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.4} aria-hidden="true" />
</button>
}
/>
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
{chord && isActive ? `${label} · ${chord}` : label}
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
</>
);
}
/**
* 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" />
),
info: localize('com_nav_info_during_run_action'),
onClick: () => steering.setDefaultAction(next),
};
}, [steering, localize]);
}
/**
* The overflow item that flips whether a default steer interrupts generation
* (`steerInterruptsByDefault`). Worded as the mode you would switch to, like
* the queue/steer toggle above.
*/
export function useInterruptToggleEntry(): MenuEntry {
const localize = useLocalize();
const [interrupts, setInterrupts] = useRecoilState(store.steerInterruptsByDefault);
const [defaultAction, setDefaultAction] = useRecoilState(store.duringRunDefaultAction);
const interruptsByDefault = defaultAction === 'steer' && interrupts;
return useMemo(
() => ({
key: 'toggle-interrupt',
label: interruptsByDefault
? localize('com_ui_wait_for_tool_steps')
: localize('com_ui_always_interrupt'),
icon: interruptsByDefault ? (
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
) : (
<ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />
),
info: localize(
!interruptsByDefault && defaultAction === 'queue'
? 'com_ui_steer_interrupts_enable_info'
: 'com_ui_steer_interrupts_default_info',
),
onClick: () => {
if (interruptsByDefault) {
setInterrupts(false);
return;
}
if (defaultAction === 'queue') {
setDefaultAction('steer');
}
setInterrupts(true);
},
}),
[defaultAction, interruptsByDefault, localize, setDefaultAction, setInterrupts],
);
}

View file

@ -14,6 +14,7 @@ import { EditTextPart, EmptyText, AgentUpdate } from './Parts';
import { lastVisibleContentIdx } from '~/utils/activityLabels';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import PendingSteers from './Parts/PendingSteers';
import ApprovalProvider from './ApprovalContext';
import MemoryArtifacts from './MemoryArtifacts';
import ToolCallGroup from './ToolCallGroup';
@ -458,6 +459,9 @@ const ContentParts = memo(function ContentParts({
renderPart={renderPart}
renderResumeAttribution={renderResumeAttribution}
/>
{isLast && isSubmitting && conversationId != null && (
<PendingSteers conversationId={conversationId} />
)}
</ApprovalProvider>
);
}
@ -509,6 +513,9 @@ const ContentParts = memo(function ContentParts({
);
return nodes;
})}
{isLast && isSubmitting && conversationId != null && (
<PendingSteers conversationId={conversationId} />
)}
</SearchContext.Provider>
</ApprovalProvider>
);

View file

@ -0,0 +1,67 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import useSteerRecovery from '~/hooks/Chat/useSteerRecovery';
import { useLocalize } from '~/hooks';
import SteerPart from './SteerPart';
import { cn } from '~/utils';
import store from '~/store';
const ACTION_CLASS =
'rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
/**
* Steers that have not been confirmed by the server yet, rendered at the tail
* of the streaming reply the place the words will land instead of in a
* floating overlay over the composer. Confirmation swaps them for the real
* `ContentTypes.STEER` part (`useResumableSSE` removes the pending entry), so
* the row's whole job is to hold the position and admit it is provisional.
*/
function PendingSteers({ conversationId }: { conversationId: string }) {
const localize = useLocalize();
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const { retry, sendAsNew } = useSteerRecovery(conversationId);
if (steers.length === 0) {
return null;
}
return (
<div role="list" aria-label={localize('com_ui_steer_in_flight')} data-testid="pending-steers">
{steers.map((steer) => (
<div
key={steer.steerId}
role="listitem"
className={cn(steer.status !== 'failed' && 'opacity-60')}
>
<SteerPart
steer={steer.text}
files={steer.files}
steerId={steer.steerId}
createdAt={steer.createdAt}
/>
{steer.status === 'failed' ? (
<div className="-mt-2 mb-2 flex items-center gap-3 pl-9 text-xs">
<span className="text-red-500">{localize('com_ui_steer_failed_inline')}</span>
<button type="button" onClick={() => retry(steer.steerId)} className={ACTION_CLASS}>
{localize('com_ui_retry')}
</button>
<button
type="button"
onClick={() => sendAsNew(steer.steerId)}
className={ACTION_CLASS}
>
{localize('com_ui_send_as_new')}
</button>
</div>
) : (
<div className="-mt-2 mb-2 pl-9 text-xs text-text-secondary">
{localize('com_ui_sending')}
</div>
)}
</div>
))}
</div>
);
}
export default memo(PendingSteers);

View file

@ -0,0 +1,58 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import type { PendingSteer } from '~/store/families';
import PendingSteers from '../PendingSteers';
import store from '~/store';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/hooks/Chat/useSteerRecovery', () => ({
__esModule: true,
default: () => ({ retry: jest.fn(), sendAsNew: jest.fn(), remove: jest.fn() }),
}));
jest.mock('../SteerPart', () => ({
__esModule: true,
default: ({ steer }: { steer: string }) => <div data-testid="steer-part">{steer}</div>,
}));
const CONVO_ID = 'convo-1';
const pending = (over: Partial<PendingSteer> = {}): PendingSteer => ({
steerId: 's1',
text: 'change of plan',
status: 'sending',
createdAt: 1,
...over,
});
function renderPending(steers: PendingSteer[]) {
return render(
<RecoilRoot initializeState={({ set }) => set(store.pendingSteersByConvoId(CONVO_ID), steers)}>
<PendingSteers conversationId={CONVO_ID} />
</RecoilRoot>,
);
}
describe('PendingSteers', () => {
it('renders nothing with no pending steers', () => {
const { container } = renderPending([]);
expect(container).toBeEmptyDOMElement();
});
it('renders a dimmed steer part with sending status', () => {
renderPending([pending()]);
expect(screen.getByTestId('steer-part')).toHaveTextContent('change of plan');
expect(screen.getByText('com_ui_sending')).toBeInTheDocument();
});
it('offers retry and send-as-new on failure', () => {
renderPending([pending({ status: 'failed' })]);
expect(screen.getByText('com_ui_steer_failed_inline')).toBeInTheDocument();
expect(screen.getByText('com_ui_retry')).toBeInTheDocument();
expect(screen.getByText('com_ui_send_as_new')).toBeInTheDocument();
});
});

View file

@ -10,3 +10,4 @@ export { default as useQueueDrain } from './useQueueDrain';
export { default as useSteering } from './useSteering';
export { default as useSteerCancel, useSteerReclaim } from './useSteerCancel';
export { default as useSteerConvert } from './useSteerConvert';
export { default as useSteerRecovery } from './useSteerRecovery';

View file

@ -0,0 +1,86 @@
import { useRecoilCallback } from 'recoil';
import type { PendingSteer } from '~/store/families';
import { useSteerMessageMutation } from '~/data-provider';
import store from '~/store';
/**
* Retry or re-route a pending steer from OUTSIDE the composer. The thread's
* pending block needs these two actions without dragging the whole
* `SteeringControls` object through the message tree.
*/
export default function useSteerRecovery(conversationId: string) {
const { mutate: steerMessage } = useSteerMessageMutation();
const markSending = useRecoilCallback(
({ set }) =>
(steerId: string, status: PendingSteer['status']) => {
set(store.pendingSteersByConvoId(conversationId), (prev) =>
prev.map((steer) => (steer.steerId === steerId ? { ...steer, status } : steer)),
);
},
[conversationId],
);
const retry = useRecoilCallback(
({ snapshot }) =>
(steerId: string) => {
const steers = snapshot
.getLoadable(store.pendingSteersByConvoId(conversationId))
.getValue();
const steer = steers.find((item) => item.steerId === steerId);
if (!steer) {
return;
}
markSending(steerId, 'sending');
steerMessage(
{ conversationId, text: steer.text, files: steer.files },
{
onSuccess: () => markSending(steerId, 'pending'),
onError: () => markSending(steerId, 'failed'),
},
);
},
[conversationId, steerMessage, markSending],
);
/** Move a failed steer into the queue: it sends when the reply finishes. */
const sendAsNew = useRecoilCallback(
({ snapshot, set }) =>
(steerId: string) => {
const steers = snapshot
.getLoadable(store.pendingSteersByConvoId(conversationId))
.getValue();
const steer = steers.find((item) => item.steerId === steerId);
if (!steer) {
return;
}
set(store.pendingSteersByConvoId(conversationId), (prev) =>
prev.filter((item) => item.steerId !== steerId),
);
set(store.queuedMessagesByConvoId(conversationId), (prev) => [
...prev,
{
id: steer.steerId,
text: steer.text,
createdAt: steer.createdAt,
files: steer.files,
quotes: steer.quotes,
manualSkills: steer.manualSkills,
},
]);
},
[conversationId],
);
const remove = useRecoilCallback(
({ set }) =>
(steerId: string) => {
set(store.pendingSteersByConvoId(conversationId), (prev) =>
prev.filter((item) => item.steerId !== steerId),
);
},
[conversationId],
);
return { retry, sendAsNew, remove };
}

View file

@ -1747,8 +1747,10 @@
"com_ui_select_search_model": "Search model by name",
"com_ui_select_search_provider": "Search provider by name",
"com_ui_select_search_region": "Search region by name",
"com_ui_send_as_new": "Send as new message",
"com_ui_send_now": "Send now",
"com_ui_send_now_paused": "Available after you respond to the pending request",
"com_ui_sending": "Sending...",
"com_ui_set": "Set",
"com_ui_settings_label_2fa": "Two-factor authentication",
"com_ui_settings_label_agent_api_keys": "Agent API keys",
@ -1964,6 +1966,7 @@
"com_ui_steer_delivery_unconfirmed": "Delivery unconfirmed",
"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_failed_inline": "Couldn't add to this reply",
"com_ui_steer_in_flight": "Steering",
"com_ui_steer_in_flight_preempt": "Interrupting",
"com_ui_steer_interrupts_default": "Steering interrupts generation",