theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

useDraft.ts (3880B)


      1 import { useState, useEffect, useRef, useCallback } from "react";
      2 
      3 export const DRAFT_KEY_PREFIX = "theo-draft:";
      4 
      5 const DEBOUNCE_MS = 300;
      6 
      7 function storageKey(sessionId: string | null): string {
      8   return `${DRAFT_KEY_PREFIX}${sessionId ?? "new"}`;
      9 }
     10 
     11 /**
     12  * Read draft from localStorage. Returns empty string if none exists.
     13  */
     14 function readDraft(sessionId: string | null): string {
     15   try {
     16     return localStorage.getItem(storageKey(sessionId)) ?? "";
     17   } catch {
     18     return "";
     19   }
     20 }
     21 
     22 /**
     23  * Write draft to localStorage.
     24  */
     25 function writeDraft(sessionId: string | null, text: string): void {
     26   try {
     27     if (text) {
     28       localStorage.setItem(storageKey(sessionId), text);
     29     } else {
     30       localStorage.removeItem(storageKey(sessionId));
     31     }
     32   } catch {
     33     // localStorage full or unavailable — silently ignore
     34   }
     35 }
     36 
     37 /**
     38  * Remove draft from localStorage for a given session.
     39  */
     40 export function clearDraft(sessionId: string | null): void {
     41   try {
     42     localStorage.removeItem(storageKey(sessionId));
     43   } catch {
     44     // ignore
     45   }
     46 }
     47 
     48 /**
     49  * Migrate a draft from one key to another (e.g. "new" → session ID).
     50  * Removes the source key after copying.
     51  */
     52 export function migrateDraft(
     53   fromSessionId: string | null,
     54   toSessionId: string,
     55 ): void {
     56   const text = readDraft(fromSessionId);
     57   if (!text) return;
     58   writeDraft(toSessionId, text);
     59   clearDraft(fromSessionId);
     60 }
     61 
     62 /**
     63  * Hook that syncs a text draft to localStorage, keyed by session ID.
     64  *
     65  * - Returns `[text, setText]` where setText updates both React state and
     66  *   debounced localStorage writes.
     67  * - When sessionId changes, loads the draft for the new session.
     68  * - Uses a 300ms debounce to avoid thrashing storage on every keystroke.
     69  */
     70 export function useDraft(
     71   sessionId: string | null,
     72 ): [string, (text: string) => void, () => void] {
     73   const [text, setTextState] = useState(() => readDraft(sessionId));
     74   const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
     75   const sessionIdRef = useRef(sessionId);
     76   const justSentRef = useRef(false);
     77 
     78   // When sessionId changes, flush pending save and load new draft.
     79   // Skip if markSent() was just called — the user sent a message so
     80   // the draft is stale (migrateDraft may have copied it to the new key).
     81   useEffect(() => {
     82     if (sessionIdRef.current === sessionId) return;
     83 
     84     // Flush any pending save for the old session
     85     if (timerRef.current) {
     86       clearTimeout(timerRef.current);
     87       timerRef.current = null;
     88     }
     89 
     90     sessionIdRef.current = sessionId;
     91 
     92     if (justSentRef.current) {
     93       justSentRef.current = false;
     94       setTextState("");
     95       clearDraft(sessionId);
     96       return;
     97     }
     98 
     99     setTextState(readDraft(sessionId));
    100   }, [sessionId]);
    101 
    102   const setText = useCallback(
    103     (newText: string) => {
    104       setTextState(newText);
    105 
    106       if (timerRef.current) {
    107         clearTimeout(timerRef.current);
    108       }
    109 
    110       timerRef.current = setTimeout(() => {
    111         writeDraft(sessionIdRef.current, newText);
    112         timerRef.current = null;
    113       }, DEBOUNCE_MS);
    114     },
    115     [],
    116   );
    117 
    118   // Signal that a message was just sent — suppresses the next sessionId-change
    119   // draft restore so the cleared text doesn't reappear.
    120   const markSent = useCallback(() => {
    121     justSentRef.current = true;
    122     // Also clear the pending debounce timer so it doesn't re-save the old text
    123     if (timerRef.current) {
    124       clearTimeout(timerRef.current);
    125       timerRef.current = null;
    126     }
    127   }, []);
    128 
    129   // Flush on unmount
    130   useEffect(() => {
    131     return () => {
    132       if (timerRef.current) {
    133         clearTimeout(timerRef.current);
    134         // We can't reliably read the latest text state in a cleanup effect,
    135         // but the debounce timer would have already fired for any value
    136         // that was set >300ms ago. This is acceptable for drafts.
    137       }
    138     };
    139   }, []);
    140 
    141   return [text, setText, markSent];
    142 }