From 027dc4e1bd4d9aaa849abcc6941b00c927da7922 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:52:24 +0200 Subject: [PATCH] feat: show pending steers inside the streaming reply --- client/src/components/Chat/Input/ChatForm.tsx | 12 - .../components/Chat/Input/Composer/Queue.tsx | 11 +- .../components/Chat/Input/InFlightSteers.tsx | 645 ---------- .../src/components/Chat/Input/SteerMenu.tsx | 346 ----- .../Input/__tests__/InFlightSteers.test.tsx | 1114 ----------------- .../Chat/Messages/Content/ContentParts.tsx | 7 + .../Messages/Content/Parts/PendingSteers.tsx | 67 + .../Parts/__tests__/PendingSteers.test.tsx | 58 + client/src/hooks/Chat/index.ts | 1 + client/src/hooks/Chat/useSteerRecovery.ts | 86 ++ client/src/locales/en/translation.json | 3 + 11 files changed, 232 insertions(+), 2118 deletions(-) delete mode 100644 client/src/components/Chat/Input/InFlightSteers.tsx delete mode 100644 client/src/components/Chat/Input/SteerMenu.tsx delete mode 100644 client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx create mode 100644 client/src/components/Chat/Messages/Content/Parts/PendingSteers.tsx create mode 100644 client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx create mode 100644 client/src/hooks/Chat/useSteerRecovery.ts diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index b30e74e42d..2185eddd97 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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({
{/* Primary composer owns the selection popup so split-view doesn't double it. */} {index === 0 && quotesEnabled && } - {/* `relative` anchors the in-flight steer overlay, which floats above - the composer (`bottom-full`) over the bottom of the thread. */}
- {/* 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 && ( boolean; + interface QueueProps { steering: SteeringControls; conversationId: string; diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx deleted file mode 100644 index 998d942074..0000000000 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ /dev/null @@ -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 = []; - const others: NonNullable = []; - 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(store.enableUserMsgMarkdown); - const activeGenerationCreatedAt = useRecoilValue( - store.activeGenerationCreatedAtByConvoId(conversationId), - ); - const activeGenerationProtocolVersion = useRecoilValue( - store.activeGenerationProtocolVersionByConvoId(conversationId), - ); - const [selectedFile, setSelectedFile] = useState | null>(null); - const optionsButtonRef = useRef(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(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) => { - 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 | 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((_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 => { - 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:
+ ); +} + +export default memo(PendingSteers); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx new file mode 100644 index 0000000000..661d1f4ff9 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/PendingSteers.test.tsx @@ -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 }) =>
{steer}
, +})); + +const CONVO_ID = 'convo-1'; + +const pending = (over: Partial = {}): PendingSteer => ({ + steerId: 's1', + text: 'change of plan', + status: 'sending', + createdAt: 1, + ...over, +}); + +function renderPending(steers: PendingSteer[]) { + return render( + set(store.pendingSteersByConvoId(CONVO_ID), steers)}> + + , + ); +} + +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(); + }); +}); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts index 3dc2a8fd65..052f14dee6 100644 --- a/client/src/hooks/Chat/index.ts +++ b/client/src/hooks/Chat/index.ts @@ -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'; diff --git a/client/src/hooks/Chat/useSteerRecovery.ts b/client/src/hooks/Chat/useSteerRecovery.ts new file mode 100644 index 0000000000..e7755efe2f --- /dev/null +++ b/client/src/hooks/Chat/useSteerRecovery.ts @@ -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 }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 3bf145eba1..ee7f17238b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",