File size: 10,551 Bytes
3d700dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | import { useEffect, useRef, useCallback, useState } from "react";
import {
useConversationLocalStorageState,
getConversationState,
setConversationState,
} from "#/utils/conversation-local-storage";
import {
focusContentEditableAtEnd,
getTextContent,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Check if a conversation ID is a temporary task ID.
* Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization.
*/
const isTaskId = (id: string): boolean => id.startsWith("task-");
const DRAFT_SAVE_DEBOUNCE_MS = 500;
/**
* 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.
*
* 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,
chatInputRef: React.RefObject<HTMLDivElement | null>,
) => {
// Pass an empty string when conversationId is missing to keep the hook
// call unconditional (rules of hooks). The early-return branches below
// ensure we never actually touch localStorage with an empty key.
const { state, setDraftMessage } = useConversationLocalStorageState(
conversationId ?? "",
);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasRestoredRef = useRef(false);
const [isRestored, setIsRestored] = useState(false);
// Track current conversationId to prevent saving draft to wrong conversation
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<string>("");
// IMPORTANT: This effect must run FIRST when conversation changes.
// It handles three concerns:
// 1. Cleanup: Cancel pending saves from previous conversation
// 2. Task-to-real transition: Preserve draft typed during initialization
// 3. DOM reset: Clear stale content before restoration effect runs
useEffect(() => {
if (!conversationId) {
return;
}
const previousConversationId = currentConversationIdRef.current;
const isInitialMount = isFirstMountRef.current;
currentConversationIdRef.current = conversationId;
isFirstMountRef.current = false;
// --- 1. Cancel pending saves from previous conversation ---
// Prevents draft from being saved to wrong conversation if user switched quickly
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
const element = chatInputRef.current;
// --- 2. Handle task-to-real ID transition (preserve draft during initialization) ---
// When a new V1 conversation initializes, it starts with a temporary "task-xxx" ID
// that transitions to a real conversation ID once ready. Task IDs don't persist
// to localStorage, so any draft typed during this phase would be lost.
// We detect this transition and transfer the draft to the new real ID.
if (
!isInitialMount &&
previousConversationId &&
previousConversationId !== conversationId
) {
const wasTaskId = isTaskId(previousConversationId);
const isNowRealId = !isTaskId(conversationId);
if (wasTaskId && isNowRealId && element) {
const currentText = getTextContent(element).trim();
if (currentText) {
// Transfer draft to the new (real) conversation ID
setConversationState(conversationId, { draftMessage: currentText });
// Keep draft visible in DOM and mark as restored to prevent overwrite
hasRestoredRef.current = true;
setIsRestored(true);
return; // Skip normal cleanup - draft is already in correct state
}
}
}
// --- 3. Clear stale DOM content (will be restored by next effect if draft exists) ---
// This prevents stale drafts from appearing in new conversations due to:
// - Browser form restoration on back/forward navigation
// - React DOM recycling between conversation switches
// The restoration effect will then populate with the correct saved draft
if (element) {
element.textContent = "";
}
// Reset restoration flag so the restoration effect will run for new conversation
hasRestoredRef.current = false;
setIsRestored(false);
}, [conversationId, chatInputRef]);
// Restore draft on mount - uses sessionStorage for the home page (no conversationId)
// and localStorage for active conversations.
useEffect(() => {
if (hasRestoredRef.current) {
return;
}
const element = chatInputRef.current;
if (!element) {
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);
// Only restore if there's a saved draft and the input is empty
if (draftMessage && getTextContent(element).trim() === "") {
element.textContent = draftMessage;
focusContentEditableAtEnd(element);
}
hasRestoredRef.current = true;
setIsRestored(true);
}, [chatInputRef, conversationId]);
// Debounced save function - called from onInput handler
const saveDraft = useCallback(() => {
// 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;
saveTimeoutRef.current = setTimeout(() => {
// Verify we're still on the same conversation before saving
// This prevents saving draft to wrong conversation if user switched quickly
if (capturedConversationId !== currentConversationIdRef.current) {
return;
}
const element = chatInputRef.current;
if (!element) {
return;
}
const text = getTextContent(element).trim();
// Only save if content has changed
if (text !== (state.draftMessage || "")) {
setDraftMessage(text || null);
}
}, DRAFT_SAVE_DEBOUNCE_MS);
}, [chatInputRef, state.draftMessage, setDraftMessage, conversationId]);
// Clear draft - called after message delivery is confirmed
const clearDraft = useCallback(() => {
// 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 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.
//
// We only write text back if the key already exists in sessionStorage.
// saveDraft writes synchronously on every keystroke, so the key is present
// whenever there is unsaved text. If the key is absent it was intentionally
// removed — most importantly by HomeChatLauncher.onSuccess after a
// successful conversation start — and we must not restore it here.
useEffect(
() => () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
if (!currentConversationIdRef.current) {
const text = lastHomeTextRef.current;
try {
if (text) {
if (sessionStorage.getItem(HOME_PROMPT_DRAFT_KEY) !== null) {
sessionStorage.setItem(HOME_PROMPT_DRAFT_KEY, text);
}
} else {
sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY);
}
} catch {
// sessionStorage not available
}
}
},
[],
);
return {
saveDraft,
clearDraft,
isRestored,
hasDraft: !!state.draftMessage,
};
};
|