Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/features/home/home-chat-launcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
103 changes: 88 additions & 15 deletions src/hooks/chat/use-draft-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,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<string>("");

// IMPORTANT: This effect must run FIRST when conversation changes.
// It handles three concerns:
Expand Down Expand Up @@ -104,11 +116,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;
}
Expand All @@ -118,6 +128,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);
Expand All @@ -140,14 +174,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;

Expand All @@ -173,23 +224,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
}
}
},
[],
);
Expand Down
Loading