diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bdf..0e281c8c2 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -37,13 +37,14 @@ interface Props { onPromptWithStreamingBehavior?: (message: string, behavior: "steer" | "followUp", images?: AttachedImage[]) => void; isStreaming: boolean; model?: { provider: string; modelId: string } | null; - isAutoModelSelection?: boolean; modelNames?: Record; modelList?: { id: string; name: string; provider: string }[]; modelError?: string | null; /** Diagnostics from resolving `enabledModels`, e.g. a pattern that matched nothing. */ modelScopeWarnings?: string[]; onModelChange?: (provider: string, modelId: string) => void; + /** A switch is in flight — shown on the picker so the click never looks ignored. */ + modelSwitching?: boolean; onCompact?: () => void; onAbortCompaction?: () => void; isCompacting?: boolean; @@ -76,6 +77,7 @@ export interface ChatInputHandle { insertIfEmpty: (text: string) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; + restoreSubmission: (text: string, images?: ChatDraftImage[]) => void; } const TOOL_PRESETS = ["off", "default", "full"] as const; @@ -312,7 +314,7 @@ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) { } export const ChatInput = forwardRef(function ChatInput({ - onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, + onSend, onAbort, onSteer, onFollowUp, isStreaming, model, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, modelSwitching, onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, retryInfo, queuedMessages, inputHistory = [], onRecallQueue, @@ -373,6 +375,7 @@ export const ChatInput = forwardRef(function ChatInput({ const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null); const fileIndexFetchingRef = useRef(null); const draftKeyRef = useRef(draftKey); + const pendingRestoreRef = useRef<{ key: string | undefined; text: string } | null>(null); const valueRef = useRef(value); const attachedImagesRef = useRef(attachedImages); const pendingImageCountRef = useRef(0); @@ -436,6 +439,32 @@ export const ChatInput = forwardRef(function ChatInput({ addImages(files: File[]) { processImageFiles(files); }, + // Recovery path for a message that was cleared on submit but never made it + // into the conversation. Never discards what the user typed since: the + // failed text goes in front of it, the same way queued messages are + // recalled. + restoreSubmission(text: string, images?: ChatDraftImage[]) { + if (!text.trim() && !images?.length) return; + // Functional update on purpose. Submitting queues setValue("") and the + // failure can land before React has flushed it, so reading the textarea + // or `value` here sees the text that is about to be wiped — which is how + // insertIfEmpty used to bail out and lose the message. Composing against + // the queued state instead is correct in every ordering. + pendingRestoreRef.current = { key: draftKeyRef.current, text }; + setValue((prev) => [text, prev].filter((t) => t.trim()).join("\n\n")); + setAtQuery(null); + if (images?.length) { + setAttachedImages((prev) => (prev.length ? prev : draftImagesToAttachedImages(images))); + } + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + }); + }, })); const processImageFiles = useCallback(async (files: File[]) => { @@ -495,6 +524,7 @@ export const ChatInput = forwardRef(function ChatInput({ const clearInput = useCallback(() => { setValue(""); setAtQuery(null); + pendingRestoreRef.current = null; setHistoryMenuOpen(false); if (draftKey) clearDraft(draftKey); if (draftKeyRef.current && draftKeyRef.current !== draftKey) clearDraft(draftKeyRef.current); @@ -525,7 +555,16 @@ export const ChatInput = forwardRef(function ChatInput({ const draft = draftKey ? getDraft(draftKey) : null; draftKeyRef.current = draftKey; - setValue(draft?.value ?? ""); + // A send that failed on a brand-new session restores its text under the + // "new:" key, and promoting the session then swaps the key — carry the + // recovered text across instead of resetting the composer to the promoted + // session's (empty) draft. + const pendingRestore = pendingRestoreRef.current; + pendingRestoreRef.current = null; + const carried = pendingRestore && pendingRestore.key === previousDraftKey && previousDraftKey?.startsWith("new:") + ? pendingRestore.text + : null; + setValue(carried ? [carried, draft?.value ?? ""].filter((t) => t.trim()).join("\n\n") : (draft?.value ?? "")); setAtQuery(null); setHistoryMenuOpen(false); setAttachedImages((prev) => { @@ -1856,8 +1895,9 @@ export const ChatInput = forwardRef(function ChatInput({ - + {currentName ?? (modelOptions.length > 0 ? "Select model" : "No models")} + {modelSwitching ? " …" : ""} {modelDropdownOpen && modelDropdownRect && (() => { @@ -1932,10 +1972,16 @@ export const ChatInput = forwardRef(function ChatInput({ return (