mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
💬 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:
parent
6f21be73a9
commit
8f712259ea
13 changed files with 659 additions and 490 deletions
|
|
@ -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>
|
||||
|
|
|
|||
171
client/src/components/Chat/Input/InFlightSteers.tsx
Normal file
171
client/src/components/Chat/Input/InFlightSteers.tsx
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
46
client/src/hooks/Chat/useSteerCancel.ts
Normal file
46
client/src/hooks/Chat/useSteerCancel.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue