From 8f712259ea3c7f5f99345fa10c8f5db1d95eeebd Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 16 Jul 2026 10:27:59 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20refactor:=20Anchor=20In-Flight?= =?UTF-8?q?=20Steers=20Above=20the=20Composer=20(#14308)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💬 refactor: Anchor In-Flight Steers Above the Composer Mid-run steers were rendered in-thread at the tail of the streaming assistant message, at a guessed injection point, then swapped to the persisted STEER part at its real index once the server applied them. In-flight steers now render as message bubbles anchored above the composer, so the thread only ever shows what the server committed: - InFlightSteers: sending/pending steers as left-aligned bubbles with image previews and a cancel affordance, anchored above the composer box - PendingSteerChips: unchanged, still owns the failed/queued control rows - SteerPart: drops the pending/onCancel props, now only ever the server-applied part - useSteerCancel: the optimistic cancel + restore-on-error, lifted out of the deleted PendingSteers slot The steer state machine is untouched: the 202 ACK reconciliation, reconnect reseeding, and queue conversion all key off status, not render location. * 🎨 fix: Match In-Flight Steer Presentation to the Applied Part Codex review on 6a5f36f7ef. All three findings were real, and all three were the same underlying mistake: the anchored bubble hand-rolled presentation instead of reusing the leaves the applied SteerPart uses, so a steer visibly changed on apply. - Images: the message `Image` sets an inline height from the file's dimensions and centers with object-contain, so clipping it into a 56px wrapper showed the blank top of a large element. Use ImagePreview, the composer's fixed-size thumbnail path (also gives click-to-enlarge). - Non-image files: FileContainer always renders a button, so without an onClick the chip was dead. Wire FilePreviewDialog, as SteerPart does. - Markdown: honor enableUserMsgMarkdown so text does not reflow the moment the server injects it. Splits files in a single pass rather than two filters. * 🎨 style: Outline the In-Flight Steer Bubble and Move the Bolt Inline The filled bubble read as a settled message. An outline reads as provisional, which is what an in-flight steer is, and separates it from the composer surface behind it. - Border + bubble keeps the composer's rounded-3xl radius so it reads as anchored to the input rather than floating over it. Border stays NEUTRAL: the failed-steer row already owns a colored (red) border, so a colored outline on the happy path would read as a warning. - The Zap moves inside the bubble, left of the text, where it prefixes the words as a status label instead of competing with cancel for the right edge. items-start pins it to the first line when text wraps. - Cancel drops plain `opacity-0` for `[@media(hover:hover)]:opacity-0`, matching SteerPart's info affordance: a hover-revealed control is unreachable on touch until a first tap (the #14272 pattern). --- client/src/components/Chat/Input/ChatForm.tsx | 332 +++++++++--------- .../components/Chat/Input/InFlightSteers.tsx | 171 +++++++++ .../Chat/Input/PendingSteerChips.tsx | 3 +- .../Input/__tests__/InFlightSteers.test.tsx | 203 +++++++++++ .../Chat/Messages/Content/ContentParts.tsx | 6 - .../Chat/Messages/Content/Parts/SteerPart.tsx | 25 +- .../Parts/__tests__/SteerPart.test.tsx | 54 ++- .../Chat/Messages/Content/PendingSteers.tsx | 96 ----- .../Content/__tests__/PendingSteers.test.tsx | 182 ---------- client/src/hooks/Chat/index.ts | 1 + client/src/hooks/Chat/useSteerCancel.ts | 46 +++ client/src/locales/en/translation.json | 1 + e2e/specs/mock/steering.spec.ts | 29 +- 13 files changed, 659 insertions(+), 490 deletions(-) create mode 100644 client/src/components/Chat/Input/InFlightSteers.tsx create mode 100644 client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx delete mode 100644 client/src/components/Chat/Messages/Content/PendingSteers.tsx delete mode 100644 client/src/components/Chat/Messages/Content/__tests__/PendingSteers.test.tsx create mode 100644 client/src/hooks/Chat/useSteerCancel.ts diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index a37fe87084..8e601f7adf 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -34,6 +34,7 @@ import PendingQuoteChips from './PendingQuoteChips'; import AttachFileChat from './Files/AttachFileChat'; import useSteering from '~/hooks/Chat/useSteering'; import FileFormChat from './Files/FileFormChat'; +import InFlightSteers from './InFlightSteers'; import TextareaHeader from './TextareaHeader'; import PromptsCommand from './PromptsCommand'; import SkillsCommand from './SkillsCommand'; @@ -411,181 +412,186 @@ const ChatForm = memo(function ChatForm({
{/* Primary composer owns the selection popup so split-view doesn't double it. */} {index === 0 && quotesEnabled && } -
- - - - {index === 0 && ( - - )} - -
- - - {quotesEnabled && } - {steering.enabled && ( - - )} - {/* WIP */} - + {steering.enabled && } +
+ - - {endpoint && ( -
-
- { - ref(e); - (textAreaRef as React.MutableRefObject).current = - e; - }} - disabled={disableInputs || isNotAppendable} - onPaste={handlePaste} - onKeyDown={(e) => { - // Answer mode consumes option-navigation keys from the - // empty composer; everything else follows the normal path. - if (answerMode.handleComposerKeyDown(e)) { - return; - } - handleKeyDown(e); - }} - onKeyUp={handleKeyUp} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - id={mainTextareaId} - tabIndex={0} - data-testid="text-input" - rows={1} - onFocus={handleTextareaFocus} - onBlur={handleTextareaBlur} - aria-label={localize('com_ui_message_input')} - onClick={handleFocusOrClick} - style={{ height: 44, overflowY: 'auto' }} - className={cn( - baseClasses, - removeFocusRings, - 'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed', - )} - /> -
-
- -
-
+ + {index === 0 && ( + )} +
-
- -
- = 1 - } - /> -
- - {SpeechToText && ( - + + {quotesEnabled && } + {steering.enabled && ( + )} -
- {isSubmitting && showStopButton && !answerMode.active - ? duringRunSlot - : endpoint && ( - + + {endpoint && ( +
+
+ { + ref(e); + ( + textAreaRef as React.MutableRefObject + ).current = e; + }} + disabled={disableInputs || isNotAppendable} + onPaste={handlePaste} + onKeyDown={(e) => { + // Answer mode consumes option-navigation keys from the + // empty composer; everything else follows the normal path. + if (answerMode.handleComposerKeyDown(e)) { + return; } - /> - )} + handleKeyDown(e); + }} + onKeyUp={handleKeyUp} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + id={mainTextareaId} + tabIndex={0} + data-testid="text-input" + rows={1} + onFocus={handleTextareaFocus} + onBlur={handleTextareaBlur} + aria-label={localize('com_ui_message_input')} + onClick={handleFocusOrClick} + style={{ height: 44, overflowY: 'auto' }} + className={cn( + baseClasses, + removeFocusRings, + 'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed', + )} + /> +
+
+ +
+
+ )} +
+
+ +
+ = 1 + } + /> +
+ + {SpeechToText && ( + + )} +
+ {isSubmitting && showStopButton && !answerMode.active + ? duringRunSlot + : endpoint && ( + + )} +
+ {TextToSpeech && automaticPlayback && }
- {TextToSpeech && automaticPlayback && }
diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx new file mode 100644 index 0000000000..98ede47921 --- /dev/null +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -0,0 +1,171 @@ +import { memo, useMemo, useState, useCallback } from 'react'; +import { X, Zap } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; +import type { TFile, TMessage } from 'librechat-data-provider'; +import type { PendingSteer } from '~/store/families'; +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 ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; +import { useSteerCancel, useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import store from '~/store'; + +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 }; +}; + +/** + * 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. + */ +const InFlightSteer = memo(function InFlightSteer({ + steer, + conversationId, +}: { + steer: PendingSteer; + conversationId: string; +}) { + const localize = useLocalize(); + const cancelSteer = useSteerCancel(conversationId); + const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const [selectedFile, setSelectedFile] = useState | null>(null); + const handlePreviewClose = useCallback((open: boolean) => { + if (!open) { + setSelectedFile(null); + } + }, []); + + const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]); + const sending = steer.status === 'sending'; + + return ( +
+ {(images.length > 0 || others.length > 0) && ( +
+ {others.map((file) => ( + setSelectedFile(file)} + /> + ))} + {images.map((file) => ( +
+ +
+ ))} +
+ )} +
+
+
+ {!sending && ( + /* Hidden-at-rest only on hover-capable pointers: a hover-revealed + * control is unreachable on touch until a first tap. */ + + )} +
+ {others.length > 0 && ( + + )} +
+ ); +}); + +/** + * 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({ + conversationId, +}: { + conversationId: string; +}) { + const localize = useLocalize(); + const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId)); + const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]); + + if (inFlight.length === 0) { + return null; + } + + return ( +
+ {inFlight.map((steer) => ( + + ))} +
+ ); +}); + +export default InFlightSteers; diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx index bce3d644d5..7f88cae590 100644 --- a/client/src/components/Chat/Input/PendingSteerChips.tsx +++ b/client/src/components/Chat/Input/PendingSteerChips.tsx @@ -263,9 +263,10 @@ function FailedSteerRow({ * Stacked rows above the composer for during-run messages, mirroring the * reference UI: each row shows the message, a primary action, delete, and an * overflow menu with Edit message + the default-mode toggle. + * (In-flight steers read as messages, not controls — `InFlightSteers` renders + * them as bubbles anchored above the composer box.) * - Failed steer rows (Zap, red): the POST failed, so the text never entered * the thread — kept recoverable with retry / edit / queue actions. - * (Sending/pending steers render in-thread as user messages instead.) * - Queued rows (Clock): client-side follow-ups auto-sent after the run. */ function PendingSteerChips({ diff --git a/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx new file mode 100644 index 0000000000..59a2c68e6c --- /dev/null +++ b/client/src/components/Chat/Input/__tests__/InFlightSteers.test.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import type { PendingSteer } from '~/store/families'; +import InFlightSteers from '../InFlightSteers'; +import store from '~/store'; + +const mockCancelMutate = jest.fn(); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useSteerCancel: jest.requireActual('~/hooks/Chat/useSteerCancel').default, +})); + +jest.mock('~/data-provider', () => ({ + useCancelSteerMutation: () => ({ mutate: mockCancelMutate }), +})); + +jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({ + __esModule: true, + default: ({ file, onClick }: { file: { filename?: string }; onClick?: () => void }) => ( + + ), +})); + +/** The composer thumbnail path: a fixed-size button painted with a background + * image, not an — assert on the url it was handed. */ +jest.mock('~/components/Chat/Input/Files/ImagePreview', () => ({ + __esModule: true, + default: ({ url, alt }: { url?: string; alt?: string }) => ( + - )}
{(imageFiles.length > 0 || otherFiles.length > 0) && ( diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx index 77196edc36..4c44aeef8a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { TMessage } from 'librechat-data-provider'; import SteerPart from '../SteerPart'; let mockShareContext: { isSharedConvo?: boolean; shareId?: string } = {}; @@ -34,23 +35,28 @@ jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({ jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({ __esModule: true, - default: () => null, + default: ({ file, onClick }: { file: { filename?: string }; onClick?: () => void }) => ( + + ), })); jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({ __esModule: true, - default: () => null, + default: ({ open, fileName }: { open: boolean; fileName: string }) => + open ?
{fileName}
: null, })); jest.mock('~/components/Chat/Messages/Content/Image', () => ({ __esModule: true, - default: () => null, + default: ({ altText }: { altText: string }) => {altText}, })); -function renderPart() { +function renderPart(files?: TMessage['files']) { return render( - + , ); } @@ -90,3 +96,39 @@ describe('SteerPart author label', () => { expect(wrapper.className).toContain('focus-within:opacity-100'); }); }); + +describe('SteerPart presentation', () => { + beforeEach(() => { + mockShareContext = {}; + }); + + it('presents the steer as a user message with an icon', () => { + renderPart(); + expect(screen.getByTestId('user-icon')).toBeInTheDocument(); + expect(screen.getByText('steered words')).toBeInTheDocument(); + }); + + it('anchors the steer for the message-nav rail', () => { + renderPart(); + const part = screen.getByTestId('steer-part'); + expect(part).toHaveAttribute('id', 'steer-s1'); + expect(part).toHaveClass('steer-render'); + }); + + it('renders steer attachments', () => { + renderPart([ + { file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }, + { file_id: 'f2', filename: 'shot.png', type: 'image/png', filepath: '/images/shot.png' }, + ]); + expect(screen.getByTestId('steer-file')).toHaveTextContent('notes.pdf'); + expect(screen.getByTestId('steer-image')).toBeInTheDocument(); + }); + + it('opens the file preview dialog when a non-image steer attachment is clicked', () => { + renderPart([{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }]); + expect(screen.queryByTestId('steer-file-preview')).toBeNull(); + + fireEvent.click(screen.getByTestId('steer-file')); + expect(screen.getByTestId('steer-file-preview')).toHaveTextContent('notes.pdf'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/PendingSteers.tsx b/client/src/components/Chat/Messages/Content/PendingSteers.tsx deleted file mode 100644 index edf781993d..0000000000 --- a/client/src/components/Chat/Messages/Content/PendingSteers.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { memo, useCallback } from 'react'; -import { useRecoilValue, useRecoilCallback } from 'recoil'; -import type { PendingSteer } from '~/store/families'; -import { useCancelSteerMutation } from '~/data-provider'; -import { SteerPart } from './Parts'; -import store from '~/store'; - -/** - * One not-yet-applied steer. Owns the cancel affordance so the mutation hook - * (and its QueryClient requirement) only exists while a steer is actually - * on screen — the parent slot renders on every streaming message. - * - * Cancel is optimistic: the entry leaves the thread immediately; - * `removed: false` needs no handling (the steer already injected or the run - * ended — the events own the outcome). Only a failed POST restores the - * entry, since the server would still inject the supposedly-cancelled words. - */ -const PendingSteerItem = memo(function PendingSteerItem({ - steer, - conversationId, -}: { - steer: PendingSteer; - conversationId: string; -}) { - const cancelMutation = useCancelSteerMutation(); - - const removeEntry = useRecoilCallback( - ({ set }) => - (steerId: string) => { - set(store.pendingSteersByConvoId(conversationId), (prev) => - prev.filter((item) => item.steerId !== steerId), - ); - }, - [conversationId], - ); - const restoreEntry = useRecoilCallback( - ({ set }) => - (entry: PendingSteer) => { - set(store.pendingSteersByConvoId(conversationId), (prev) => - prev.some((item) => item.steerId === entry.steerId) ? prev : [...prev, entry], - ); - }, - [conversationId], - ); - - const cancelSteer = useCallback(() => { - removeEntry(steer.steerId); - cancelMutation.mutate( - { conversationId, steerId: steer.steerId }, - { onError: () => restoreEntry(steer) }, - ); - }, [conversationId, steer, removeEntry, restoreEntry, cancelMutation]); - - return ( - - ); -}); - -/** - * Steers the server hasn't applied yet, rendered in-thread at the end of the - * streaming assistant message — the projected injection point, since the next - * tool-batch boundary is always after everything streamed so far. A submitted - * steer is part of the history the moment it's sent; `on_steer_applied` - * then drops the optimistic entry as the persisted part lands at its - * authoritative index. Failed steers leave the thread for the composer's - * recovery row (retry / edit / queue). - */ -const PendingSteers = memo(function PendingSteers({ - conversationId, -}: { - conversationId?: string | null; -}) { - const convoKey = conversationId ?? ''; - const steers = useRecoilValue(store.pendingSteersByConvoId(convoKey)); - if (steers.length === 0 || !conversationId) { - return null; - } - return ( - <> - {steers.map((steer) => - steer.status === 'failed' ? null : ( - - ), - )} - - ); -}); - -export default PendingSteers; diff --git a/client/src/components/Chat/Messages/Content/__tests__/PendingSteers.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/PendingSteers.test.tsx deleted file mode 100644 index 4d45cafb0e..0000000000 --- a/client/src/components/Chat/Messages/Content/__tests__/PendingSteers.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React from 'react'; -import { RecoilRoot } from 'recoil'; -import { render, screen, fireEvent, act } from '@testing-library/react'; -import type { PendingSteer } from '~/store/families'; -import PendingSteers from '../PendingSteers'; -import store from '~/store'; - -const mockCancelMutate = jest.fn(); - -jest.mock('~/hooks', () => ({ - useLocalize: () => (key: string) => key, -})); - -jest.mock('~/data-provider', () => ({ - useCancelSteerMutation: () => ({ mutate: mockCancelMutate }), -})); - -jest.mock('~/hooks/AuthContext', () => ({ - useAuthContext: () => ({ user: { name: 'Danny', username: 'danny' } }), -})); - -/** Stub the provider-backed leaves — these tests cover the slot's filtering - * and the user-message presentation contract, not icon/markdown internals. */ -jest.mock('~/components/Chat/Messages/MessageIcon', () => ({ - __esModule: true, - default: () =>
, -})); - -jest.mock('~/components/Chat/Messages/ui/MessageTimestamp', () => ({ - __esModule: true, - default: ({ value }: { value?: string | null }) => ( - - ), -})); - -jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({ - __esModule: true, - default: ({ content }: { content: string }) => {content}, -})); - -jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({ - __esModule: true, - default: ({ file, onClick }: { file: { filename?: string }; onClick?: () => void }) => ( - - ), -})); - -jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({ - __esModule: true, - default: ({ open, fileName }: { open: boolean; fileName: string }) => - open ?
{fileName}
: null, -})); - -jest.mock('~/components/Chat/Messages/Content/Image', () => ({ - __esModule: true, - - default: ({ altText }: { altText: string }) => {altText}, -})); - -const CONVO_ID = 'convo-steers'; - -function renderSlot(steers: PendingSteer[], options?: { usernameDisplay?: boolean }) { - return render( - { - set(store.pendingSteersByConvoId(CONVO_ID), steers); - if (options?.usernameDisplay != null) { - set(store.UsernameDisplay, options.usernameDisplay); - } - }} - > - - , - ); -} - -describe('PendingSteers (in-thread optimistic steers)', () => { - it('renders nothing when no steers are pending', () => { - renderSlot([]); - expect(screen.queryByTestId('steer-part')).toBeNull(); - }); - - it('renders sending and pending steers as user-message parts, skipping failed ones', () => { - renderSlot([ - { steerId: 's1', text: 'first correction', status: 'sending', createdAt: 1 }, - { steerId: 's2', text: 'second correction', status: 'pending', createdAt: 2 }, - { steerId: 's3', text: 'never sent', status: 'failed', createdAt: 3 }, - ]); - const parts = screen.getAllByTestId('steer-part'); - expect(parts).toHaveLength(2); - expect(screen.getByText('first correction')).toBeInTheDocument(); - expect(screen.getByText('second correction')).toBeInTheDocument(); - expect(screen.queryByText('never sent')).toBeNull(); - for (const part of parts) { - expect(part).toHaveAttribute('data-steer-pending', 'true'); - } - }); - - it('presents the steer as a user message: icon and author name', () => { - renderSlot([{ steerId: 's1', text: 'like you would say it', status: 'pending', createdAt: 1 }]); - expect(screen.getByTestId('user-icon')).toBeInTheDocument(); - expect(screen.getByText('Danny')).toBeInTheDocument(); - }); - - it('anchors each steer for the message-nav rail', () => { - renderSlot([{ steerId: 's1', text: 'navigable words', status: 'pending', createdAt: 1 }]); - const part = screen.getByTestId('steer-part'); - expect(part).toHaveAttribute('id', 'steer-s1'); - expect(part).toHaveClass('steer-render'); - }); - - it('cancels a pending steer server-side and removes it from the thread', () => { - renderSlot([ - { steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 1 }, - { steerId: 'local-1', text: 'still posting', status: 'sending', createdAt: 2 }, - ]); - // Only the server-acknowledged steer is cancellable — a 'sending' entry - // has no server id yet. - const cancels = screen.getAllByTestId('steer-cancel'); - expect(cancels).toHaveLength(1); - - fireEvent.click(cancels[0]); - expect(mockCancelMutate).toHaveBeenCalledWith( - { conversationId: CONVO_ID, steerId: 's-ack' }, - expect.objectContaining({ onError: expect.any(Function) }), - ); - expect(screen.queryByText('waiting on boundary')).toBeNull(); - expect(screen.getByText('still posting')).toBeInTheDocument(); - }); - - it('restores the entry when the cancel POST fails', () => { - renderSlot([{ steerId: 's-err', text: 'network flake', status: 'pending', createdAt: 1 }]); - fireEvent.click(screen.getByTestId('steer-cancel')); - expect(screen.queryByText('network flake')).toBeNull(); - - const options = mockCancelMutate.mock.calls[0][1] as { onError: () => void }; - act(() => options.onError()); - expect(screen.getByText('network flake')).toBeInTheDocument(); - }); - - it('falls back to the generic user label when username display is off', () => { - renderSlot([{ steerId: 's1', text: 'anonymous words', status: 'pending', createdAt: 1 }], { - usernameDisplay: false, - }); - expect(screen.getByText('com_user_message')).toBeInTheDocument(); - }); - - it('renders steer attachments', () => { - renderSlot([ - { - steerId: 's1', - text: 'see attached', - status: 'pending', - createdAt: 1, - files: [ - { file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }, - { file_id: 'f2', filename: 'shot.png', type: 'image/png', filepath: '/images/shot.png' }, - ], - }, - ]); - expect(screen.getByTestId('steer-file')).toHaveTextContent('notes.pdf'); - expect(screen.getByTestId('steer-image')).toBeInTheDocument(); - }); - - it('opens the file preview dialog when a non-image steer attachment is clicked', () => { - renderSlot([ - { - steerId: 's1', - text: 'see attached', - status: 'pending', - createdAt: 1, - files: [{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }], - }, - ]); - expect(screen.queryByTestId('steer-file-preview')).toBeNull(); - - fireEvent.click(screen.getByTestId('steer-file')); - expect(screen.getByTestId('steer-file-preview')).toHaveTextContent('notes.pdf'); - }); -}); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts index cb10473ac5..2ddeeb2a8d 100644 --- a/client/src/hooks/Chat/index.ts +++ b/client/src/hooks/Chat/index.ts @@ -8,4 +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 useSteerConvert } from './useSteerConvert'; diff --git a/client/src/hooks/Chat/useSteerCancel.ts b/client/src/hooks/Chat/useSteerCancel.ts new file mode 100644 index 0000000000..3c0ec55c6a --- /dev/null +++ b/client/src/hooks/Chat/useSteerCancel.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; +import { useRecoilCallback } from 'recoil'; +import type { PendingSteer } from '~/store/families'; +import { useCancelSteerMutation } from '~/data-provider'; +import store from '~/store'; + +/** + * Cancels a steer still waiting on its injection boundary. Optimistic: the + * entry leaves the chip stack immediately; `removed: false` needs no handling + * (the steer already injected or the run ended — the events own the outcome). + * Only a failed POST restores the entry, since the server would still inject + * the supposedly-cancelled words. + */ +export default function useSteerCancel(conversationId: string) { + const cancelMutation = useCancelSteerMutation(); + + const removeEntry = useRecoilCallback( + ({ set }) => + (steerId: string) => { + set(store.pendingSteersByConvoId(conversationId), (prev) => + prev.filter((item) => item.steerId !== steerId), + ); + }, + [conversationId], + ); + const restoreEntry = useRecoilCallback( + ({ set }) => + (entry: PendingSteer) => { + set(store.pendingSteersByConvoId(conversationId), (prev) => + prev.some((item) => item.steerId === entry.steerId) ? prev : [...prev, entry], + ); + }, + [conversationId], + ); + + return useCallback( + (steer: PendingSteer) => { + removeEntry(steer.steerId); + cancelMutation.mutate( + { conversationId, steerId: steer.steerId }, + { onError: () => restoreEntry(steer) }, + ); + }, + [conversationId, removeEntry, restoreEntry, cancelMutation], + ); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 9ab54e1432..9e22157c90 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1874,6 +1874,7 @@ "com_ui_steer": "Steer", "com_ui_steer_cancel": "Cancel steering message", "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_send": "Steer the current response", diff --git a/e2e/specs/mock/steering.spec.ts b/e2e/specs/mock/steering.spec.ts index 6dd8395a08..fcb5c03734 100644 --- a/e2e/specs/mock/steering.spec.ts +++ b/e2e/specs/mock/steering.spec.ts @@ -25,10 +25,9 @@ const messageInput = (page: Page) => page.getByRole('textbox', { name: 'Message const duringRunSendButton = (page: Page) => page.getByTestId('during-run-send-button'); const queuedRows = (page: Page) => page.getByTestId('queued-message-row'); const messageTurns = (page: Page) => messagesView(page).locator('.message-render'); -const pendingSteerParts = (page: Page) => - messagesView(page).locator('[data-testid="steer-part"][data-steer-pending="true"]'); -const appliedSteerParts = (page: Page) => - messagesView(page).locator('[data-testid="steer-part"]:not([data-steer-pending])'); +/** In-flight steers are anchored above the composer, not in the thread. */ +const inFlightSteers = (page: Page) => page.getByTestId('in-flight-steer'); +const appliedSteerParts = (page: Page) => messagesView(page).getByTestId('steer-part'); function isSteerRequest(response: Response) { return ( @@ -70,13 +69,13 @@ test.describe('mid-run steering and queuing', () => { /** * The applied-steer contract (requires @librechat/agents ≥ 3.2.63, where * top-level `PostToolBatch` hook inputs carry no subagent-scope `agentId`): - * a steer submitted mid-run appears in-thread immediately as an optimistic - * user message, is injected at the next tool-batch boundary — the pending - * marker drops as `on_steer_applied` swaps in the persisted part — and + * a steer submitted mid-run appears immediately as a bubble anchored above + * the composer, is injected at the next tool-batch boundary — the bubble + * drops as `on_steer_applied` lands the persisted part in-thread — and * SURVIVES inside the response after run end, with no degradation to a * queued follow-up turn. */ - test('steers mid-run: pending part appears immediately and applies at the next tool boundary', async ({ + test('steers mid-run: anchored bubble appears immediately and applies at the next tool boundary', async ({ page, }) => { test.setTimeout(150000); @@ -102,16 +101,18 @@ test.describe('mid-run steering and queuing', () => { ]); expect(steerResponse.status()).toBe(202); - // The steer shows in-thread immediately as an optimistic user-style part. - await expect(pendingSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, { + // The steer shows immediately as a bubble anchored above the composer. + await expect(inFlightSteers(page).filter({ hasText: steerText })).toHaveCount(1, { timeout: 10000, }); + await expect(appliedSteerParts(page)).toHaveCount(0); - // Injected at the tool-batch boundary: the optimistic entry becomes the - // persisted part (pending marker drops) while the run is still going. + // Injected at the tool-batch boundary: the anchored bubble gives way to the + // persisted in-thread part while the run is still going. await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1, { timeout: 60000, }); + await expect(inFlightSteers(page)).toHaveCount(0); await expect(messagesView(page).getByRole('button', { name: /remember_fact/ })).toBeVisible({ timeout: 60000, }); @@ -123,7 +124,7 @@ test.describe('mid-run steering and queuing', () => { // its injection point, not a queued follow-up turn (4 turns: the setup // pair plus this pair). await expect(messageTurns(page)).toHaveCount(4); - await expect(pendingSteerParts(page)).toHaveCount(0); + await expect(inFlightSteers(page)).toHaveCount(0); await expect(appliedSteerParts(page).filter({ hasText: steerText })).toHaveCount(1); await expect(queuedRows(page)).toHaveCount(0); }); @@ -148,7 +149,7 @@ test.describe('mid-run steering and queuing', () => { const row = queuedRows(page).filter({ hasText: queueText }); await expect(row).toBeVisible({ timeout: 10000 }); // Queued means NOT injected into the live thread. - await expect(pendingSteerParts(page)).toHaveCount(0); + await expect(inFlightSteers(page)).toHaveCount(0); // Clean completion drains exactly one queued message as a new user turn. await expect(row).toHaveCount(0, { timeout: 60000 });