Skip to content
56 changes: 51 additions & 5 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -312,7 +314,7 @@ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) {
}

export const ChatInput = forwardRef<ChatInputHandle, Props>(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,
Expand Down Expand Up @@ -373,6 +375,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null);
const fileIndexFetchingRef = useRef<string | null>(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);
Expand Down Expand Up @@ -436,6 +439,32 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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[]) => {
Expand Down Expand Up @@ -495,6 +524,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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);
Expand Down Expand Up @@ -525,7 +555,16 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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:<cwd>" 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) => {
Expand Down Expand Up @@ -1856,8 +1895,9 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0, opacity: modelSwitching ? 0.6 : 1 }}>
{currentName ?? (modelOptions.length > 0 ? "Select model" : "No models")}
{modelSwitching ? " …" : ""}
</span>
</button>
{modelDropdownOpen && modelDropdownRect && (() => {
Expand Down Expand Up @@ -1932,10 +1972,16 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
return (
<button
key={`${opt.provider}:${opt.modelId}`}
// Always re-issue the switch, even for the
// entry that looks active: set_model is
// idempotent, and skipping it made a stale
// label impossible to recover from — the user
// clicked the model they wanted and nothing
// happened.
onClick={() => {
setModelDropdownOpen(false);
setModelFilter("");
if (!isActive || isAutoModelSelection) onModelChange(opt.provider, opt.modelId);
onModelChange(opt.provider, opt.modelId);
}}
style={{
display: "flex", alignItems: "center", gap: 8,
Expand Down
5 changes: 2 additions & 3 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
loading, error, messages, entryIds, streamState,
agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel,
retryInfo, contextUsage, forkingEntryId,
isCompacting, compactError, compactResult, displayModel: displayModelValue, sessionStats,
isCompacting, compactError, compactResult, displayModel: displayModelValue, modelSwitching, sessionStats,
slashCommands, slashCommandsLoading, queuedMessages,
notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput,
isAutoModelSelection,
agentPhase,
isNew,
sessionIdRef, messagesEndRef, scrollContainerRef,
Expand Down Expand Up @@ -350,12 +349,12 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
onPromptWithStreamingBehavior={agentRunning ? handlePromptWithStreamingBehavior : undefined}
isStreaming={sessionBusy}
model={displayModelValue}
isAutoModelSelection={isAutoModelSelection}
modelNames={modelNames}
modelList={modelList}
modelError={modelError}
modelScopeWarnings={modelScopeWarnings}
onModelChange={handleModelChange}
modelSwitching={modelSwitching}
onCompact={session || isNew ? handleCompact : undefined}
onAbortCompaction={handleAbortCompaction}
isCompacting={isCompacting}
Expand Down
Loading