diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index c70ce37a..2473decc 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -6,6 +6,7 @@ import { useActiveBackend } from "#/contexts/active-backend-context"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces"; import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { HOME_PROMPT_DRAFT_KEY } from "#/hooks/chat/use-draft-persistence"; import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { useConversationStore } from "#/stores/conversation-store"; import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; @@ -99,6 +100,11 @@ export function HomeChatLauncher() { createConversation(variables, { onSuccess: async (data) => { toast.dismiss(toastId); + try { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } catch { + // sessionStorage not available + } const targetConversationId = data.conversation_id; const isTaskConversation = targetConversationId.startsWith("task-"); diff --git a/src/hooks/chat/use-draft-persistence.ts b/src/hooks/chat/use-draft-persistence.ts index d0d40f99..034232c8 100644 --- a/src/hooks/chat/use-draft-persistence.ts +++ b/src/hooks/chat/use-draft-persistence.ts @@ -18,12 +18,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, @@ -43,6 +50,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: @@ -107,11 +119,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; } @@ -121,6 +131,30 @@ 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); + // 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 + } + 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); @@ -137,14 +171,31 @@ 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: 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); + } else { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } + } catch { + // sessionStorage not available + } + } + return; + } + // Capture the conversationId at the time of input const capturedConversationId = conversationId; @@ -170,23 +221,45 @@ 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]); - // Cleanup timeout on unmount + // Cleanup on unmount: cancel any pending debounce timer and, for the home + // 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 text = lastHomeTextRef.current; + try { + if (text) { + sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text); + } else { + sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY); + } + } catch { + // sessionStorage not available + } + } }, [], );