💬 refactor: Anchor In-Flight Steers Above the Composer (#14308)

* 💬 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).
This commit is contained in:
Danny Avila 2026-07-16 10:27:59 -04:00 committed by GitHub
parent 6f21be73a9
commit 8f712259ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 659 additions and 490 deletions

View file

@ -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({
<div className="relative flex h-full 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} />}
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
<Mention
index={index}
popoverAtom={plusPopoverAtom}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
<Mention
index={index}
popoverAtom={mentionPopoverAtom}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
{index === 0 && (
<AskUserQuestionPopover conversationId={conversationId} textAreaRef={textAreaRef} />
)}
<SkillsCommand
index={index}
textAreaRef={textAreaRef}
conversationId={conversationId}
agentId={conversation?.agent_id}
/>
<div
onClick={handleContainerClick}
className={cn(
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
isTemporary
? 'border-violet-800/60 bg-violet-950/10'
: 'border-border-light bg-surface-chat',
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<PendingManualSkillsChips conversationId={conversationId} />
{quotesEnabled && <PendingQuoteChips conversationId={conversationId} />}
{steering.enabled && (
<PendingSteerChips
conversationId={conversationId}
steering={steering}
onEditToComposer={editToComposer}
/>
)}
{/* WIP */}
<EditBadges
isEditingChatBadges={isEditingBadges}
handleCancelBadges={handleCancelBadges}
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
<div className="flex w-full flex-col">
{steering.enabled && <InFlightSteers conversationId={conversationId} />}
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
<Mention
index={index}
popoverAtom={plusPopoverAtom}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
<FileFormChat
conversation={conversation}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
<Mention
index={index}
popoverAtom={mentionPopoverAtom}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<div
className="relative flex-1"
style={
isCollapsed
? {
WebkitMaskImage: 'linear-gradient(to bottom, black 60%, transparent 90%)',
maskImage: 'linear-gradient(to bottom, black 60%, transparent 90%)',
}
: undefined
}
>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).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',
)}
/>
</div>
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows}
setIsCollapsed={setIsCollapsed}
/>
</div>
</div>
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
{index === 0 && (
<AskUserQuestionPopover conversationId={conversationId} textAreaRef={textAreaRef} />
)}
<SkillsCommand
index={index}
textAreaRef={textAreaRef}
conversationId={conversationId}
agentId={conversation?.agent_id}
/>
<div
onClick={handleContainerClick}
className={cn(
'@container items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
isTemporary
? 'border-violet-800/60 bg-violet-950/10'
: 'border-border-light bg-surface-chat',
)}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat
conversation={conversation}
disableInputs={disableInputs}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
/>
</div>
<BadgeRow
showEphemeralBadges={
!!endpoint &&
!hideBadgeRow &&
!isAgentsEndpoint(endpoint) &&
!isAssistantsEndpoint(endpoint)
}
isSubmitting={isSubmitting}
conversationId={conversationId}
specName={conversation?.spec}
onChange={setBadges}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
}
/>
<div className="mx-auto flex" />
<TokenUsage index={index} conversation={conversation} isSubmitting={isSubmitting} />
{SpeechToText && (
<AudioRecorder
methods={methods}
ask={submitMessage}
disabled={disableInputs || isNotAppendable}
isSubmitting={isSubmitting}
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<PendingManualSkillsChips conversationId={conversationId} />
{quotesEnabled && <PendingQuoteChips conversationId={conversationId} />}
{steering.enabled && (
<PendingSteerChips
conversationId={conversationId}
steering={steering}
onEditToComposer={editToComposer}
/>
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{isSubmitting && showStopButton && !answerMode.active
? duringRunSlot
: endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={
filesLoading ||
disableInputs ||
isNotAppendable ||
(isSubmitting && !answerMode.active)
{/* WIP */}
<EditBadges
isEditingChatBadges={isEditingBadges}
handleCancelBadges={handleCancelBadges}
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat
conversation={conversation}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
/>
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<div
className="relative flex-1"
style={
isCollapsed
? {
WebkitMaskImage:
'linear-gradient(to bottom, black 60%, transparent 90%)',
maskImage: 'linear-gradient(to bottom, black 60%, transparent 90%)',
}
: undefined
}
>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(
textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>
).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',
)}
/>
</div>
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows}
setIsCollapsed={setIsCollapsed}
/>
</div>
</div>
)}
<div
className={cn(
'@container items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat
conversation={conversation}
disableInputs={disableInputs}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
/>
</div>
<BadgeRow
showEphemeralBadges={
!!endpoint &&
!hideBadgeRow &&
!isAgentsEndpoint(endpoint) &&
!isAssistantsEndpoint(endpoint)
}
isSubmitting={isSubmitting}
conversationId={conversationId}
specName={conversation?.spec}
onChange={setBadges}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
}
/>
<div className="mx-auto flex" />
<TokenUsage index={index} conversation={conversation} isSubmitting={isSubmitting} />
{SpeechToText && (
<AudioRecorder
methods={methods}
ask={submitMessage}
disabled={disableInputs || isNotAppendable}
isSubmitting={isSubmitting}
/>
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{isSubmitting && showStopButton && !answerMode.active
? duringRunSlot
: endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={
filesLoading ||
disableInputs ||
isNotAppendable ||
(isSubmitting && !answerMode.active)
}
/>
)}
</div>
</div>
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
</div>
</div>

View file

@ -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<TMessage['files']> = [];
const others: NonNullable<TMessage['files']> = [];
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<boolean>(store.enableUserMsgMarkdown);
const [selectedFile, setSelectedFile] = useState<Partial<TFile> | 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 (
<div
role="listitem"
data-testid="in-flight-steer"
data-steer-status={steer.status}
className="group flex 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>
)}
<div className="flex max-w-full items-center 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',
)}
>
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_steer_in_flight')}</span>
<div
className={cn(
'markdown prose message-content dark:prose-invert light min-w-0 break-words',
'dark:text-gray-20',
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
)}
>
{enableUserMsgMarkdown ? <MarkdownLite content={steer.text} /> : steer.text}
</div>
</div>
{!sending && (
/* Hidden-at-rest only on hover-capable pointers: a hover-revealed
* control is unreachable on touch until a first tap. */
<button
type="button"
aria-label={localize('com_ui_steer_cancel')}
onClick={() => cancelSteer(steer)}
data-testid="steer-cancel"
className="shrink-0 rounded-full p-1 text-text-secondary transition-opacity duration-200 hover:bg-surface-tertiary hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</div>
{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({
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 (
<div
role="list"
aria-label={localize('com_ui_steer_in_flight')}
data-testid="in-flight-steers"
className="flex flex-col items-start gap-2 px-2 pb-2"
>
{inFlight.map((steer) => (
<InFlightSteer key={steer.steerId} steer={steer} conversationId={conversationId} />
))}
</div>
);
});
export default InFlightSteers;

View file

@ -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({

View file

@ -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 }) => (
<button type="button" data-testid="steer-file" onClick={onClick}>
{file.filename}
</button>
),
}));
/** The composer thumbnail path: a fixed-size button painted with a background
* image, not an <img> assert on the url it was handed. */
jest.mock('~/components/Chat/Input/Files/ImagePreview', () => ({
__esModule: true,
default: ({ url, alt }: { url?: string; alt?: string }) => (
<button type="button" data-testid="steer-image" data-url={url} aria-label={alt} />
),
}));
jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({
__esModule: true,
default: ({ open, fileName }: { open: boolean; fileName: string }) =>
open ? <div data-testid="steer-file-preview">{fileName}</div> : null,
}));
jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => (
<span data-testid="steer-markdown">{content}</span>
),
}));
const CONVO_ID = 'convo-in-flight';
function renderSteers(steers: PendingSteer[], options?: { enableUserMsgMarkdown?: boolean }) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.pendingSteersByConvoId(CONVO_ID), steers);
if (options?.enableUserMsgMarkdown != null) {
set(store.enableUserMsgMarkdown, options.enableUserMsgMarkdown);
}
}}
>
<InFlightSteers conversationId={CONVO_ID} />
</RecoilRoot>,
);
}
describe('InFlightSteers', () => {
it('renders nothing when no steer is in flight', () => {
renderSteers([]);
expect(screen.queryByTestId('in-flight-steers')).toBeNull();
});
it('anchors sending and pending steers above the composer, not in-thread', () => {
renderSteers([
{ steerId: 's1', text: 'first correction', status: 'sending', createdAt: 1 },
{ steerId: 's2', text: 'second correction', status: 'pending', createdAt: 2 },
]);
expect(screen.getAllByTestId('in-flight-steer')).toHaveLength(2);
expect(screen.getByText('first correction')).toBeInTheDocument();
expect(screen.getByText('second correction')).toBeInTheDocument();
// The in-thread SteerPart is reserved for server-applied steers.
expect(screen.queryByTestId('steer-part')).toBeNull();
});
it('leaves failed steers to the composer recovery rows', () => {
renderSteers([{ steerId: 's3', text: 'never sent', status: 'failed', createdAt: 1 }]);
expect(screen.queryByTestId('in-flight-steers')).toBeNull();
});
it('keeps cancel reachable on touch, hover-revealed on hover-capable pointers', () => {
renderSteers([
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 1 },
]);
// A plain `opacity-0` reveal would make the bubble hover-dependent, so on
// touch the X would need a first tap to appear (see the #14272 pattern).
const cancel = screen.getByTestId('steer-cancel');
expect(cancel.className).toContain('[@media(hover:hover)]:opacity-0');
expect(cancel.className).toContain('group-hover:opacity-100');
expect(cancel.className).toContain('focus-visible:opacity-100');
});
it('only offers cancel once the steer is acknowledged', () => {
renderSteers([
{ steerId: 'local-1', text: 'still posting', status: 'sending', createdAt: 1 },
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 2 },
]);
// A 'sending' entry has no server id yet, so there is nothing to cancel.
expect(screen.getAllByTestId('steer-cancel')).toHaveLength(1);
});
it('cancels a pending steer server-side and drops the bubble', () => {
renderSteers([
{ steerId: 's-ack', text: 'waiting on boundary', status: 'pending', createdAt: 1 },
]);
fireEvent.click(screen.getByTestId('steer-cancel'));
expect(mockCancelMutate).toHaveBeenCalledWith(
{ conversationId: CONVO_ID, steerId: 's-ack' },
expect.objectContaining({ onError: expect.any(Function) }),
);
expect(screen.queryByText('waiting on boundary')).toBeNull();
});
it('restores the bubble when the cancel POST fails', () => {
renderSteers([{ steerId: 's-err', text: 'network flake', status: 'pending', createdAt: 1 }]);
fireEvent.click(screen.getByTestId('steer-cancel'));
expect(screen.queryByText('network flake')).toBeNull();
const options = mockCancelMutate.mock.calls[0][1] as { onError: () => void };
act(() => options.onError());
expect(screen.getByText('network flake')).toBeInTheDocument();
});
it('renders images through the composer thumbnail path, not the full-size message image', () => {
renderSteers([
{
steerId: 's1',
text: 'see attached',
status: 'pending',
createdAt: 1,
files: [
{ file_id: 'f2', filename: 'shot.png', type: 'image/png', filepath: '/images/shot.png' },
],
},
]);
// The message `Image` reserves height from the file's dimensions, so it
// cannot be clipped down to a thumbnail; ImagePreview is fixed-size.
expect(screen.getByTestId('steer-image')).toHaveAttribute('data-url', '/images/shot.png');
});
it('prefers the local preview url for an image that is still uploading', () => {
renderSteers([
{
steerId: 's1',
text: 'see attached',
status: 'pending',
createdAt: 1,
files: [
{
file_id: 'f2',
filename: 'shot.png',
type: 'image/png',
preview: 'blob:local-preview',
filepath: '/images/shot.png',
},
],
},
]);
expect(screen.getByTestId('steer-image')).toHaveAttribute('data-url', 'blob:local-preview');
});
it('keeps non-image attachments previewable while the steer waits', () => {
renderSteers([
{
steerId: 's1',
text: 'see attached',
status: 'pending',
createdAt: 1,
files: [{ file_id: 'f1', filename: 'notes.pdf', type: 'application/pdf' }],
},
]);
expect(screen.getByTestId('steer-file')).toHaveTextContent('notes.pdf');
expect(screen.queryByTestId('steer-file-preview')).toBeNull();
fireEvent.click(screen.getByTestId('steer-file'));
expect(screen.getByTestId('steer-file-preview')).toHaveTextContent('notes.pdf');
});
it('renders markdown the same way the applied part will, so text does not reflow on apply', () => {
renderSteers([{ steerId: 's1', text: '**bold** steer', status: 'pending', createdAt: 1 }], {
enableUserMsgMarkdown: true,
});
expect(screen.getByTestId('steer-markdown')).toHaveTextContent('**bold** steer');
});
it('renders raw text when user-message markdown is off', () => {
renderSteers([{ steerId: 's1', text: '**bold** steer', status: 'pending', createdAt: 1 }], {
enableUserMsgMarkdown: false,
});
expect(screen.queryByTestId('steer-markdown')).toBeNull();
expect(screen.getByText('**bold** steer')).toBeInTheDocument();
});
});

View file

@ -14,7 +14,6 @@ import PendingSkillCall from './Parts/PendingSkillCall';
import { EditTextPart, EmptyText } from './Parts';
import ApprovalProvider from './ApprovalContext';
import MemoryArtifacts from './MemoryArtifacts';
import PendingSteers from './PendingSteers';
import ToolCallGroup from './ToolCallGroup';
import Container from './Container';
import Part from './Part';
@ -150,9 +149,6 @@ const ContentParts = memo(function ContentParts({
}: ContentPartsProps) {
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
/** In-thread slot for not-yet-applied steers, only on the live streaming
* message everywhere else the persisted STEER parts are the record. */
const showPendingSteers = !isCreatedByUser && isLatestMessage === true && effectiveIsSubmitting;
const toolGroupExpansionRef = useRef(new Map<string, ToolCallGroupExpansionState>());
const fallbackScopeRef = useRef({ messageId, scope: 0 });
if (fallbackScopeRef.current.messageId !== messageId) {
@ -390,7 +386,6 @@ const ContentParts = memo(function ContentParts({
isSubmitting={effectiveIsSubmitting}
renderPart={renderPart}
/>
{showPendingSteers && <PendingSteers conversationId={conversationId} />}
</ApprovalProvider>
);
}
@ -426,7 +421,6 @@ const ContentParts = memo(function ContentParts({
/>
);
})}
{showPendingSteers && <PendingSteers conversationId={conversationId} />}
</SearchContext.Provider>
</ApprovalProvider>
);

View file

@ -1,5 +1,4 @@
import { memo, useMemo, useState, useCallback } from 'react';
import { X } from 'lucide-react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { InfoHoverCard, ESide } from '@librechat/client';
@ -25,26 +24,21 @@ const USER_ICON: TMessageIcon = { isCreatedByUser: true };
* assistant response same icon, author header, and text presentation as any
* user turn, placed where the words enter the run so the visible order equals
* what the next turn replays (`ContentTypes.STEER` splits back into a
* HumanMessage server-side). Renders identically as the optimistic entry
* (`pending`, before the server applies it), as the persisted part live and
* on reload, and in shared/search views.
* HumanMessage server-side). Only the server-applied part renders here, at its
* authoritative index; a steer still in flight lives in the composer's chip
* stack. Renders identically live, on reload, and in shared/search views.
*/
const SteerPart = memo(function SteerPart({
steer,
files,
steerId,
createdAt,
pending = false,
onCancel,
}: {
steer: string;
files?: TMessage['files'];
/** Anchors the part for the message-nav rail (`#steer-<id>` rib target). */
steerId?: string;
createdAt?: number;
pending?: boolean;
/** Cancels a steer still waiting on its injection boundary (pending only). */
onCancel?: () => void;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
@ -91,10 +85,8 @@ const SteerPart = memo(function SteerPart({
/* Outdented past the response's icon column so the steer sits flush
* with top-level message rows it reads as a regular user message. */
'steer-render group relative my-4 -ml-9 flex w-[calc(100%+2.25rem)] gap-3',
pending && 'opacity-80',
)}
data-testid="steer-part"
data-steer-pending={pending || undefined}
>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
@ -115,17 +107,6 @@ const SteerPart = memo(function SteerPart({
<InfoHoverCard side={ESide.Top} text={localize('com_ui_steered_info')} />
</span>
<MessageTimestamp value={timestamp} />
{onCancel != null && (
<button
type="button"
onClick={onCancel}
aria-label={localize('com_ui_steer_cancel')}
data-testid="steer-cancel"
className="ml-2 rounded-full p-0.5 align-middle text-text-secondary opacity-0 transition-opacity duration-200 hover:bg-surface-tertiary hover:text-text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</h2>
<div className="flex flex-col items-start gap-2">
{(imageFiles.length > 0 || otherFiles.length > 0) && (

View file

@ -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 }) => (
<button type="button" data-testid="steer-file" onClick={onClick}>
{file.filename}
</button>
),
}));
jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({
__esModule: true,
default: () => null,
default: ({ open, fileName }: { open: boolean; fileName: string }) =>
open ? <div data-testid="steer-file-preview">{fileName}</div> : null,
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: () => null,
default: ({ altText }: { altText: string }) => <img alt={altText} data-testid="steer-image" />,
}));
function renderPart() {
function renderPart(files?: TMessage['files']) {
return render(
<RecoilRoot>
<SteerPart steer="steered words" steerId="s1" createdAt={1} />
<SteerPart steer="steered words" steerId="s1" createdAt={1} files={files} />
</RecoilRoot>,
);
}
@ -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');
});
});

View file

@ -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 (
<SteerPart
steer={steer.text}
files={steer.files}
steerId={steer.steerId}
createdAt={steer.createdAt}
pending
onCancel={steer.status === 'pending' ? cancelSteer : undefined}
/>
);
});
/**
* 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 : (
<PendingSteerItem key={steer.steerId} steer={steer} conversationId={conversationId} />
),
)}
</>
);
});
export default PendingSteers;

View file

@ -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: () => <div data-testid="user-icon" />,
}));
jest.mock('~/components/Chat/Messages/ui/MessageTimestamp', () => ({
__esModule: true,
default: ({ value }: { value?: string | null }) => (
<time data-testid="steer-timestamp">{value}</time>
),
}));
jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <span>{content}</span>,
}));
jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({
__esModule: true,
default: ({ file, onClick }: { file: { filename?: string }; onClick?: () => void }) => (
<button type="button" data-testid="steer-file" onClick={onClick}>
{file.filename}
</button>
),
}));
jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({
__esModule: true,
default: ({ open, fileName }: { open: boolean; fileName: string }) =>
open ? <div data-testid="steer-file-preview">{fileName}</div> : null,
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: ({ altText }: { altText: string }) => <img alt={altText} data-testid="steer-image" />,
}));
const CONVO_ID = 'convo-steers';
function renderSlot(steers: PendingSteer[], options?: { usernameDisplay?: boolean }) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.pendingSteersByConvoId(CONVO_ID), steers);
if (options?.usernameDisplay != null) {
set(store.UsernameDisplay, options.usernameDisplay);
}
}}
>
<PendingSteers conversationId={CONVO_ID} />
</RecoilRoot>,
);
}
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');
});
});

View file

@ -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';

View file

@ -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],
);
}

View file

@ -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",

View file

@ -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 });