mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-25 11:47:32 +00:00
* 💬 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).
684 lines
25 KiB
TypeScript
684 lines
25 KiB
TypeScript
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
|
import { useWatch } from 'react-hook-form';
|
|
import { TextareaAutosize } from '@librechat/client';
|
|
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
|
|
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
|
import type { TMessage, TConversation } from 'librechat-data-provider';
|
|
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
|
|
import type { QueuedMessageContext } from '~/hooks/Chat/useSteering';
|
|
import {
|
|
useTextarea,
|
|
useAutoSave,
|
|
useLocalize,
|
|
useRequiresKey,
|
|
useHandleKeyUp,
|
|
useQueryParams,
|
|
useSubmitMessage,
|
|
useFocusChatEffect,
|
|
} from '~/hooks';
|
|
import {
|
|
useChatContext,
|
|
useChatFormContext,
|
|
useAddedChatContext,
|
|
useAssistantsMapContext,
|
|
} from '~/Providers';
|
|
import PendingManualSkillsChips from './PendingManualSkillsChips';
|
|
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
|
|
import AskUserQuestionPopover from './AskUserQuestionPopover';
|
|
import { cn, getModelSpec, removeFocusRings } from '~/utils';
|
|
import DuringRunSendButton from './DuringRunSendButton';
|
|
import { useGetStartupConfig } from '~/data-provider';
|
|
import { mainTextareaId, BadgeItem } from '~/common';
|
|
import PendingSteerChips from './PendingSteerChips';
|
|
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';
|
|
import AudioRecorder from './AudioRecorder';
|
|
import CollapseChat from './CollapseChat';
|
|
import QuoteButton from './QuoteButton';
|
|
import StreamAudio from './StreamAudio';
|
|
import TokenUsage from './TokenUsage';
|
|
import StopButton from './StopButton';
|
|
import SendButton from './SendButton';
|
|
import EditBadges from './EditBadges';
|
|
import BadgeRow from './BadgeRow';
|
|
import Mention from './Mention';
|
|
import store from '~/store';
|
|
|
|
interface ChatFormProps {
|
|
index: number;
|
|
placeholder?: string;
|
|
/** From ChatContext — individual values so memo can compare them */
|
|
files: Map<string, ExtendedFile>;
|
|
setFiles: FileSetter;
|
|
conversation: TConversation | null;
|
|
isSubmitting: boolean;
|
|
filesLoading: boolean;
|
|
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
|
newConversation: ConvoGenerator;
|
|
handleStopGenerating: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
stopGenerating: () => void;
|
|
}
|
|
|
|
const ChatForm = memo(function ChatForm({
|
|
index,
|
|
placeholder,
|
|
files,
|
|
setFiles,
|
|
conversation,
|
|
isSubmitting,
|
|
filesLoading,
|
|
setFilesLoading,
|
|
newConversation,
|
|
handleStopGenerating,
|
|
stopGenerating,
|
|
}: ChatFormProps) {
|
|
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
useFocusChatEffect(textAreaRef);
|
|
const localize = useLocalize();
|
|
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
const [, setIsScrollable] = useState(false);
|
|
const [visualRowCount, setVisualRowCount] = useState(1);
|
|
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
|
|
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
|
|
|
|
const SpeechToText = useRecoilValue(store.speechToText);
|
|
const TextToSpeech = useRecoilValue(store.textToSpeech);
|
|
const chatDirection = useRecoilValue(store.chatDirection);
|
|
const automaticPlayback = useRecoilValue(store.automaticPlayback);
|
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
|
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
|
const isTemporary = useRecoilValue(store.isTemporary);
|
|
|
|
const [badges, setBadges] = useRecoilState(store.chatBadges);
|
|
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
|
|
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
|
const plusPopoverAtom = useMemo(() => store.showPlusPopoverFamily(index), [index]);
|
|
const mentionPopoverAtom = useMemo(() => store.showMentionPopoverFamily(index), [index]);
|
|
|
|
const { requiresKey } = useRequiresKey();
|
|
const methods = useChatFormContext();
|
|
const {
|
|
generateConversation,
|
|
conversation: addedConvo,
|
|
setConversation: setAddedConvo,
|
|
} = useAddedChatContext();
|
|
const assistantMap = useAssistantsMapContext();
|
|
const { data: startupConfig } = useGetStartupConfig();
|
|
|
|
const endpoint = useMemo(
|
|
() => conversation?.endpointType ?? conversation?.endpoint,
|
|
[conversation?.endpointType, conversation?.endpoint],
|
|
);
|
|
const modelSpec = useMemo(
|
|
() => getModelSpec({ specName: conversation?.spec, startupConfig }),
|
|
[conversation?.spec, startupConfig],
|
|
);
|
|
const hideBadgeRow = modelSpec?.hideBadgeRow === true;
|
|
const conversationId = useMemo(
|
|
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
|
[conversation?.conversationId],
|
|
);
|
|
/**
|
|
* The quote feature merges excerpts server-side in `BaseClient.sendMessage`,
|
|
* which the Assistants endpoints bypass — so hide the UI there rather than
|
|
* letting users queue quotes the assistant never receives.
|
|
*/
|
|
const quotesEnabled = useMemo(() => !isAssistantsEndpoint(endpoint), [endpoint]);
|
|
|
|
const isRTL = useMemo(
|
|
() => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false),
|
|
[chatDirection],
|
|
);
|
|
const invalidAssistant = useMemo(
|
|
() =>
|
|
isAssistantsEndpoint(endpoint) &&
|
|
(!(conversation?.assistant_id ?? '') ||
|
|
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
|
|
[conversation?.assistant_id, endpoint, assistantMap],
|
|
);
|
|
const disableInputs = useMemo(
|
|
() => requiresKey || invalidAssistant,
|
|
[requiresKey, invalidAssistant],
|
|
);
|
|
|
|
const handleContainerClick = useCallback(() => {
|
|
/** Check if the device is a touchscreen */
|
|
if (window.matchMedia?.('(pointer: coarse)').matches) {
|
|
return;
|
|
}
|
|
textAreaRef.current?.focus();
|
|
}, []);
|
|
|
|
const handleFocusOrClick = useCallback(() => {
|
|
if (isCollapsed) {
|
|
setIsCollapsed(false);
|
|
}
|
|
}, [isCollapsed]);
|
|
|
|
const handleTextareaFocus = useCallback(() => {
|
|
handleFocusOrClick();
|
|
setIsTextAreaFocused(true);
|
|
}, [handleFocusOrClick]);
|
|
|
|
const handleTextareaBlur = useCallback(() => {
|
|
setIsTextAreaFocused(false);
|
|
}, []);
|
|
|
|
const answerMode = useAskAnswerMode(conversationId);
|
|
|
|
useAutoSave({
|
|
files,
|
|
setFiles,
|
|
textAreaRef,
|
|
conversationId,
|
|
isSubmitting,
|
|
// While a question pause is live the composer is the answer box: drafts
|
|
// swap to the answer's own key, and the conversation draft is restored
|
|
// when the question resolves.
|
|
draftId: answerMode.draftId,
|
|
});
|
|
|
|
const { submitMessage, submitPrompt } = useSubmitMessage();
|
|
|
|
/** Queued/steered sends carry their FULL submission context: explicit
|
|
* (possibly empty) overrides stop `ask` from vacuuming quotes or skill
|
|
* picks the user has staged in the composer for their NEXT message. */
|
|
const sendNow = useCallback(
|
|
(text: string, overrideFiles?: TMessage['files'], context?: QueuedMessageContext) =>
|
|
submitMessage({
|
|
text,
|
|
overrideFiles,
|
|
overrideQuotes: context?.quotes ?? [],
|
|
overrideManualSkills: context?.manualSkills ?? [],
|
|
}),
|
|
[submitMessage],
|
|
);
|
|
/** Chip "Edit message" restore: quote chips + skill picks merge back into
|
|
* their compose-time atoms (the chips above the textarea re-render them). */
|
|
const restoreComposerContext = useRecoilCallback(
|
|
({ set }) =>
|
|
(context?: QueuedMessageContext) => {
|
|
const { quotes, manualSkills } = context ?? {};
|
|
if (quotes != null && quotes.length > 0) {
|
|
set(store.pendingQuotesByConvoId(conversationId), (prev) => [
|
|
...new Set([...prev, ...quotes]),
|
|
]);
|
|
}
|
|
if (manualSkills != null && manualSkills.length > 0) {
|
|
set(store.pendingManualSkillsByConvoId(conversationId), (prev) => [
|
|
...new Set([...prev, ...manualSkills]),
|
|
]);
|
|
}
|
|
},
|
|
[conversationId],
|
|
);
|
|
/** Chip "Edit message": the text replaces the composer draft and the chip's
|
|
* attachments merge back into the composer file map (already uploaded, so
|
|
* they restore as completed entries — same shape as draft recovery). */
|
|
const editToComposer = useCallback(
|
|
(text: string, chipFiles?: TMessage['files'], context?: QueuedMessageContext) => {
|
|
methods.setValue('text', text, { shouldDirty: true });
|
|
if (chipFiles != null && chipFiles.length > 0) {
|
|
setFiles((prev) => {
|
|
const next = new Map(prev);
|
|
for (const file of chipFiles) {
|
|
if (!file.file_id) {
|
|
continue;
|
|
}
|
|
next.set(file.file_id, {
|
|
file_id: file.file_id,
|
|
filename: file.filename,
|
|
filepath: file.filepath,
|
|
type: file.type ?? '',
|
|
height: file.height,
|
|
width: file.width,
|
|
size: file.bytes ?? 0,
|
|
progress: 1,
|
|
attached: true,
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
restoreComposerContext(context);
|
|
textAreaRef.current?.focus();
|
|
},
|
|
[methods, setFiles, restoreComposerContext],
|
|
);
|
|
const steering = useSteering({
|
|
index,
|
|
conversationId,
|
|
conversation,
|
|
isSubmitting,
|
|
answerModeActive: answerMode.active,
|
|
files,
|
|
setFiles,
|
|
filesLoading,
|
|
sendNow,
|
|
stopGenerating,
|
|
});
|
|
|
|
/** ⌘/Ctrl+Enter = the non-default during-run action, ⌥/Alt+Enter =
|
|
* interrupt & send — the counterpart of Enter's `submitDuringRun`. */
|
|
const handleDuringRunModifier = useCallback(
|
|
(kind: 'other' | 'interrupt') => {
|
|
const text = methods.getValues('text');
|
|
let consumed = false;
|
|
if (kind === 'interrupt') {
|
|
consumed = steering.interruptAndSend(text);
|
|
} else if (steering.effectiveAction === 'steer') {
|
|
consumed = steering.queueFromComposer(text);
|
|
} else {
|
|
consumed = steering.steerFromComposer(text);
|
|
}
|
|
if (consumed) {
|
|
methods.reset();
|
|
}
|
|
},
|
|
[methods, steering],
|
|
);
|
|
|
|
const handleKeyUp = useHandleKeyUp({
|
|
index,
|
|
textAreaRef,
|
|
});
|
|
const {
|
|
isNotAppendable,
|
|
handlePaste,
|
|
handleKeyDown,
|
|
handleCompositionStart,
|
|
handleCompositionEnd,
|
|
} = useTextarea({
|
|
textAreaRef,
|
|
submitButtonRef,
|
|
setIsScrollable,
|
|
disabled: disableInputs,
|
|
// The composer IS the free-form answer box while a question pause is live.
|
|
placeholder: answerMode.active
|
|
? (answerMode.otherLabel ?? localize('com_ui_something_else'))
|
|
: placeholder,
|
|
// Enter stays live during a run when it can steer/queue instead of send.
|
|
allowSubmitWhileGenerating: steering.duringRunActive,
|
|
onDuringRunModifier: steering.duringRunActive ? handleDuringRunModifier : undefined,
|
|
});
|
|
|
|
useQueryParams({ textAreaRef });
|
|
|
|
const { ref, ...registerProps } = methods.register('text', {
|
|
required: true,
|
|
onChange: useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
methods.setValue('text', e.target.value, { shouldValidate: true }),
|
|
[methods],
|
|
),
|
|
});
|
|
|
|
const textValue = useWatch({ control: methods.control, name: 'text' });
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
const style = window.getComputedStyle(textAreaRef.current);
|
|
const lineHeight = parseFloat(style.lineHeight);
|
|
setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight));
|
|
}
|
|
}, [textValue]);
|
|
|
|
useEffect(() => {
|
|
if (isEditingBadges && backupBadges.length === 0) {
|
|
setBackupBadges([...badges]);
|
|
}
|
|
}, [isEditingBadges, badges, backupBadges.length]);
|
|
|
|
const handleSaveBadges = useCallback(() => {
|
|
setIsEditingBadges(false);
|
|
setBackupBadges([]);
|
|
}, [setIsEditingBadges, setBackupBadges]);
|
|
|
|
const handleCancelBadges = useCallback(() => {
|
|
if (backupBadges.length > 0) {
|
|
setBadges([...backupBadges]);
|
|
}
|
|
setIsEditingBadges(false);
|
|
setBackupBadges([]);
|
|
}, [backupBadges, setBadges, setIsEditingBadges]);
|
|
|
|
const isMoreThanThreeRows = visualRowCount > 3;
|
|
|
|
/** One button slot while a run is generating: with composer text the send
|
|
* button takes over (Enter steers/queues; hover reveals all actions);
|
|
* clearing the text restores Stop. */
|
|
const duringRunSlot =
|
|
steering.duringRunActive && (textValue?.trim() ?? '') !== '' ? (
|
|
<DuringRunSendButton
|
|
ref={submitButtonRef}
|
|
control={methods.control}
|
|
steering={steering}
|
|
getText={() => methods.getValues('text')}
|
|
onConsumed={() => methods.reset()}
|
|
disabled={filesLoading}
|
|
/>
|
|
) : (
|
|
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
|
);
|
|
|
|
const baseClasses = useMemo(
|
|
() =>
|
|
cn(
|
|
'md:py-3.5 m-0 w-full resize-none py-[13px] placeholder-black/60 bg-transparent dark:placeholder-white/60 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
|
|
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
|
|
isMoreThanThreeRows ? 'pl-5' : 'px-5',
|
|
),
|
|
[isCollapsed, isMoreThanThreeRows],
|
|
);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={methods.handleSubmit((data) => {
|
|
// Answer mode: composer text answers the paused run instead of
|
|
// starting a new turn (submitText resets the composer itself).
|
|
// Dismissing the popover restores normal sends.
|
|
if (answerMode.active && answerMode.submitText(data.text)) {
|
|
return;
|
|
}
|
|
// During a run, a submit steers or queues per the effective action
|
|
// instead of starting a new turn (which would be dropped anyway).
|
|
if (steering.duringRunActive) {
|
|
if (steering.submitDuringRun(data.text)) {
|
|
methods.reset();
|
|
}
|
|
return;
|
|
}
|
|
return submitMessage(data);
|
|
})}
|
|
className={cn(
|
|
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
|
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
|
centerFormOnLanding &&
|
|
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
|
!isSubmitting &&
|
|
conversation?.messages?.length === 0
|
|
? 'transition-all duration-200 sm:mb-28'
|
|
: 'sm:mb-10',
|
|
)}
|
|
>
|
|
<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="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}
|
|
/>
|
|
<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}
|
|
/>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
});
|
|
ChatForm.displayName = 'ChatForm';
|
|
|
|
/**
|
|
* Wrapper that subscribes to ChatContext and passes stable individual values
|
|
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
|
|
* streaming chunk — it only re-renders when the specific values it uses change.
|
|
*/
|
|
function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) {
|
|
const {
|
|
files,
|
|
setFiles,
|
|
conversation,
|
|
isSubmitting,
|
|
filesLoading,
|
|
setFilesLoading,
|
|
newConversation,
|
|
handleStopGenerating,
|
|
stopGenerating,
|
|
} = useChatContext();
|
|
|
|
/**
|
|
* Stabilize conversation reference: only update when rendering-relevant fields change,
|
|
* not on every metadata update (e.g., title generation during streaming).
|
|
*/
|
|
const hasMessages = (conversation?.messages?.length ?? 0) > 0;
|
|
const stableConversation = useMemo(
|
|
() => conversation,
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[
|
|
conversation?.conversationId,
|
|
conversation?.endpoint,
|
|
conversation?.endpointType,
|
|
conversation?.agent_id,
|
|
conversation?.assistant_id,
|
|
conversation?.spec,
|
|
conversation?.useResponsesApi,
|
|
conversation?.model,
|
|
conversation?.maxContextTokens,
|
|
hasMessages,
|
|
],
|
|
);
|
|
|
|
/** Stabilize function refs so they never trigger ChatForm re-renders */
|
|
const handleStopRef = useRef(handleStopGenerating);
|
|
handleStopRef.current = handleStopGenerating;
|
|
const stableHandleStop = useCallback(
|
|
(e: React.MouseEvent<HTMLButtonElement>) => handleStopRef.current(e),
|
|
[],
|
|
);
|
|
|
|
const newConvoRef = useRef(newConversation);
|
|
newConvoRef.current = newConversation;
|
|
const stableNewConversation: ConvoGenerator = useCallback(
|
|
(...args: Parameters<ConvoGenerator>): ReturnType<ConvoGenerator> =>
|
|
newConvoRef.current(...args),
|
|
[],
|
|
);
|
|
|
|
const stopRef = useRef(stopGenerating);
|
|
stopRef.current = stopGenerating;
|
|
const stableStop = useCallback(() => {
|
|
void stopRef.current();
|
|
}, []);
|
|
|
|
return (
|
|
<ChatForm
|
|
index={index}
|
|
placeholder={placeholder}
|
|
files={files}
|
|
setFiles={setFiles}
|
|
conversation={stableConversation}
|
|
isSubmitting={isSubmitting}
|
|
filesLoading={filesLoading}
|
|
setFilesLoading={setFilesLoading}
|
|
newConversation={stableNewConversation}
|
|
handleStopGenerating={stableHandleStop}
|
|
stopGenerating={stableStop}
|
|
/>
|
|
);
|
|
}
|
|
|
|
ChatFormWrapper.displayName = 'ChatFormWrapper';
|
|
|
|
export default ChatFormWrapper;
|