From 27ff4540329fdfc526297b8ec824e694698d2c64 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 20 May 2026 08:48:35 -0600 Subject: [PATCH 1/3] Persist home page prompt draft to sessionStorage Saves the chat input text on the 'Let's Start Building' home page to sessionStorage (debounced) so it survives navigation and failed conversation starts. - Restored when navigating away and back to the home page - Restored on page refresh within the same browser session - Cleared only on successful conversation creation (navigate away) - Discarded automatically when the browser tab is closed useDraftPersistence: when conversationId is undefined (home page context), save/restore/clear via sessionStorage[HOME_PROMPT_DRAFT_KEY] instead of the existing no-op path. Exports HOME_PROMPT_DRAFT_KEY constant. HomeChatLauncher: remove the sessionStorage key in onSuccess before navigating to the new conversation, so a fresh home page starts clean. Co-authored-by: openhands --- .../features/home/home-chat-launcher.tsx | 6 ++ src/hooks/chat/use-draft-persistence.ts | 76 +++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 9b4f569ad..d6bfa7f39 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -5,6 +5,7 @@ import { CustomChatInput } from "#/components/features/chat/custom-chat-input"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { HOME_PROMPT_DRAFT_KEY } from "#/hooks/chat/use-draft-persistence"; import { useNavigation } from "#/context/navigation-context"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; import { Branch, GitRepository } from "#/types/git"; @@ -76,6 +77,11 @@ export function HomeChatLauncher() { createConversation(variables, { onSuccess: (data) => { toast.dismiss(toastId); + try { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } catch { + // sessionStorage not available + } navigate(`/conversations/${data.conversation_id}`); }, onError: (error) => { diff --git a/src/hooks/chat/use-draft-persistence.ts b/src/hooks/chat/use-draft-persistence.ts index a39fa7005..9e23fbf4b 100644 --- a/src/hooks/chat/use-draft-persistence.ts +++ b/src/hooks/chat/use-draft-persistence.ts @@ -15,12 +15,19 @@ const isTaskId = (id: string): boolean => id.startsWith("task-"); const DRAFT_SAVE_DEBOUNCE_MS = 500; /** - * Hook for persisting draft messages to localStorage. + * sessionStorage key used to persist the home-page prompt draft. + * Cleared on successful conversation creation; survives navigation within the session. + */ +export const HOME_PROMPT_DRAFT_KEY = "oh:home-prompt-draft"; + +/** + * Hook for persisting draft messages. * Handles debounced saving on input, restoration on mount, and clearing on confirmed delivery. * - * `conversationId` may be undefined when the chat input renders on the home - * page (no conversation exists yet). In that case the hook short-circuits: - * no localStorage reads/writes happen and the returned callbacks are no-ops. + * When `conversationId` is defined, the draft is persisted to localStorage + * under the conversation's key. When `conversationId` is undefined (home page), + * the draft is persisted to sessionStorage under `HOME_PROMPT_DRAFT_KEY` so it + * survives navigation within the session but is discarded on tab close. */ export const useDraftPersistence = ( conversationId: string | null | undefined, @@ -104,11 +111,9 @@ export const useDraftPersistence = ( setIsRestored(false); }, [conversationId, chatInputRef]); - // Restore draft from localStorage - reads directly to avoid state sync timing issues + // Restore draft on mount - uses sessionStorage for the home page (no conversationId) + // and localStorage for active conversations. useEffect(() => { - if (!conversationId) { - return; - } if (hasRestoredRef.current) { return; } @@ -118,6 +123,27 @@ export const useDraftPersistence = ( return; } + if (!conversationId) { + // Home page: restore from sessionStorage + try { + const draft = sessionStorage.getItem(HOME_PROMPT_DRAFT_KEY); + if (draft && getTextContent(element).trim() === "") { + element.textContent = draft; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } catch { + // sessionStorage not available + } + hasRestoredRef.current = true; + setIsRestored(true); + return; + } + // Read directly from localStorage to avoid stale state from useConversationLocalStorageState // The hook's state may not have synced yet after conversationId change const { draftMessage } = getConversationState(conversationId); @@ -140,14 +166,30 @@ export const useDraftPersistence = ( // Debounced save function - called from onInput handler const saveDraft = useCallback(() => { - if (!conversationId) { - return; - } // Clear any pending save if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } + if (!conversationId) { + // Home page: save to sessionStorage + saveTimeoutRef.current = setTimeout(() => { + const element = chatInputRef.current; + if (!element) return; + const text = getTextContent(element).trim(); + try { + if (text) { + sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); + } else { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } + } catch { + // sessionStorage not available + } + }, DRAFT_SAVE_DEBOUNCE_MS); + return; + } + // Capture the conversationId at the time of input const capturedConversationId = conversationId; @@ -173,14 +215,20 @@ export const useDraftPersistence = ( // Clear draft - called after message delivery is confirmed const clearDraft = useCallback(() => { - if (!conversationId) { - return; - } // Cancel any pending save if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } + if (!conversationId) { + // Home page: clear sessionStorage + try { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } catch { + // sessionStorage not available + } + return; + } setDraftMessage(null); }, [conversationId, setDraftMessage]); From f748617e69c3bafc69dbd153c6e49fc2a37d9fdb Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 20 May 2026 09:01:40 -0600 Subject: [PATCH 2/3] fix: flush home-page draft to sessionStorage on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation debounced sessionStorage writes (500ms), which meant text typed and then quickly navigated away from — before the debounce fired — was never persisted. The unmount cleanup cancelled the pending timer, leaving sessionStorage with the stale value from the last full save, which was then incorrectly restored on the next visit. Fix: in the unmount cleanup, synchronously flush the current input text to sessionStorage when conversationId is undefined (home-page context). The debounce still handles the in-flight saves; the flush ensures nothing is lost when the component tears down with a pending timer. Co-authored-by: openhands --- src/hooks/chat/use-draft-persistence.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/hooks/chat/use-draft-persistence.ts b/src/hooks/chat/use-draft-persistence.ts index 9e23fbf4b..02879ffe6 100644 --- a/src/hooks/chat/use-draft-persistence.ts +++ b/src/hooks/chat/use-draft-persistence.ts @@ -232,12 +232,30 @@ export const useDraftPersistence = ( setDraftMessage(null); }, [conversationId, setDraftMessage]); - // Cleanup timeout on unmount + // Cleanup on unmount: cancel any pending debounce timer and, for the home + // page (no conversationId), flush the current input text to sessionStorage + // immediately so text typed within the debounce window isn't lost on + // navigation. useEffect( () => () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } + if (!currentConversationIdRef.current) { + const element = chatInputRef.current; + if (element) { + const text = getTextContent(element).trim(); + try { + if (text) { + sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); + } else { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } + } catch { + // sessionStorage not available + } + } + } }, [], ); From 7416c1eef51c40eb4ba5eda212dc43e8257387a3 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 20 May 2026 09:10:56 -0600 Subject: [PATCH 3/3] fix: save home-page draft synchronously; use ref not DOM in flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused the wrong prompt to be restored after in-app navigation: 1. React 18 clears ref.current during the synchronous commit phase, before async useEffect cleanup functions run. The flush-on-unmount was reading chatInputRef.current — which is null at that point — so it never saved the updated text, leaving sessionStorage with the stale value. 2. The 500 ms debounce in saveDraft left a window where text typed and then navigated away from quickly would also not reach sessionStorage. Fix: - saveDraft (home-page path): write synchronously on every onInput event; no debounce needed for a sessionStorage write of a few hundred chars. Track the written text in lastHomeTextRef. - Restoration: seed lastHomeTextRef with the restored draft so an immediate navigate-away (without typing) still preserves it. - Unmount flush: read from lastHomeTextRef (always valid) instead of chatInputRef (null by cleanup time). Co-authored-by: openhands --- src/hooks/chat/use-draft-persistence.ts | 45 ++++++++++++++----------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/hooks/chat/use-draft-persistence.ts b/src/hooks/chat/use-draft-persistence.ts index 02879ffe6..da5fcbfcd 100644 --- a/src/hooks/chat/use-draft-persistence.ts +++ b/src/hooks/chat/use-draft-persistence.ts @@ -47,6 +47,11 @@ export const useDraftPersistence = ( const currentConversationIdRef = useRef(conversationId); // Track if this is the first mount to handle initial cleanup const isFirstMountRef = useRef(true); + // Tracks the latest home-page text so the unmount flush can use it safely. + // chatInputRef.current is null by the time async useEffect cleanup runs in + // React 18 (refs are cleared during the synchronous commit phase, before + // passive effects fire), so we can't read from the DOM there. + const lastHomeTextRef = useRef(""); // IMPORTANT: This effect must run FIRST when conversation changes. // It handles three concerns: @@ -135,6 +140,9 @@ export const useDraftPersistence = ( range.collapse(false); selection?.removeAllRanges(); selection?.addRange(range); + // Seed lastHomeTextRef so an unmount flush without any typing still + // preserves the restored text rather than clearing sessionStorage. + lastHomeTextRef.current = draft; } } catch { // sessionStorage not available @@ -172,11 +180,12 @@ export const useDraftPersistence = ( } if (!conversationId) { - // Home page: save to sessionStorage - saveTimeoutRef.current = setTimeout(() => { - const element = chatInputRef.current; - if (!element) return; + // Home page: write to sessionStorage synchronously so there is no + // debounce window in which navigation can discard the latest text. + const element = chatInputRef.current; + if (element) { const text = getTextContent(element).trim(); + lastHomeTextRef.current = text; try { if (text) { sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); @@ -186,7 +195,7 @@ export const useDraftPersistence = ( } catch { // sessionStorage not available } - }, DRAFT_SAVE_DEBOUNCE_MS); + } return; } @@ -233,27 +242,25 @@ export const useDraftPersistence = ( }, [conversationId, setDraftMessage]); // Cleanup on unmount: cancel any pending debounce timer and, for the home - // page (no conversationId), flush the current input text to sessionStorage - // immediately so text typed within the debounce window isn't lost on - // navigation. + // page, flush the last-tracked text to sessionStorage. We read from + // lastHomeTextRef rather than chatInputRef because React clears ref.current + // during the synchronous commit phase — before async useEffect cleanups run + // — so the DOM ref is null by the time this function executes. useEffect( () => () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } if (!currentConversationIdRef.current) { - const element = chatInputRef.current; - if (element) { - const text = getTextContent(element).trim(); - try { - if (text) { - sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); - } else { - sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); - } - } catch { - // sessionStorage not available + const text = lastHomeTextRef.current; + try { + if (text) { + sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); + } else { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); } + } catch { + // sessionStorage not available } } },