From 1da0adc2a411b46ac41f752f5ed1429bd979ad63 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 3 Aug 2026 22:48:11 -0400 Subject: [PATCH 1/7] fix: make model switching reliable and stop losing typed input on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model switching looked like a no-op and needed several tries. Three causes: loadSession cleared the optimistic model unconditionally, so any reload (agent_end, bash settle, reconcile poll) snapped the picker back to the session file, which lags a switch; the dropdown skipped set_model entirely for the entry that already looked active, so a stale label could never be corrected; and nothing was shown between the click and the response, which after the wrapper's 10-minute idle destroy means a cold agent restart. Now the choice is applied optimistically with monotonic switch ids so an out-of-order response cannot win, the override is dropped only once a reload agrees, every click re-issues the idempotent set_model, and the picker is synced from the live agent state (get_state already returned the model; the client discarded it). Failures revert and surface a notice instead of only reaching the console. Typed text vanished whenever a turn failed. ChatInput clears the composer right after a fire-and-forget onSend, and pi runs prompt() fire-and-forget too, so the POST returns 200 and the failure arrives later as prompt_error — a path that restored nothing. pi also writes no session file until the session has an assistant message, so a first-message failure lost the text outright. The in-flight prompt is now kept, staged on prompt_error when the model produced no output, and restored into the composer from loadSession only when the reloaded transcript no longer contains it. handleSend recovers from every error rather than just EventStreamConnectionError, a missing session id throws instead of silently hanging, and steer / follow-up / queued prompts get the same recovery and notice. Co-Authored-By: Claude Opus 5 --- components/ChatInput.tsx | 43 ++++++- components/ChatWindow.tsx | 5 +- hooks/useAgentSession.ts | 254 ++++++++++++++++++++++++++++---------- 3 files changed, 231 insertions(+), 71 deletions(-) diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bdf..f51b5a8f5 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, @@ -436,6 +438,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[]) { + const ta = textareaRef.current; + const current = ta ? ta.value : valueRef.current; + const combined = [text, current].filter((t) => t.trim()).join("\n\n"); + if (combined !== current) { + setValue(combined); + setAtQuery(null); + } + if (images?.length) { + setAttachedImages((prev) => { + if (prev.length) return prev; + return draftImagesToAttachedImages(images); + }); + } + requestAnimationFrame(() => { + if (!ta) return; + ta.focus(); + ta.setSelectionRange(combined.length, combined.length); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + }); + }, })); const processImageFiles = useCallback(async (files: File[]) => { @@ -1856,8 +1884,9 @@ export const ChatInput = forwardRef(function ChatInput({ - + {currentName ?? (modelOptions.length > 0 ? "Select model" : "No models")} + {modelSwitching ? " …" : ""} {modelDropdownOpen && modelDropdownRect && (() => { @@ -1932,10 +1961,16 @@ export const ChatInput = forwardRef(function ChatInput({ return (