blob: 6c4e33128aabcf63f6453f47b69b4eb49b7c916f [file]
/**
* Composer draft-persistence hook (issue #1044).
*
* Owns the per-session unsent-draft store that used to live inline in
* `composer.tsx`: a bounded Map keyed by `draftKey`, the active key, and the
* `hasDraftText` flag that gates the send button. The pure store operations
* (remember / read, with the 120k-char and 32-entry bounds) stay in
* `composer-helpers.ts` — this hook is the React seam that wires them to the
* uncontrolled textarea.
*
* The swap effect preserves the exact pre-extraction semantics: when the host
* switches `draftKey` (e.g. the first send in the home composer creates a
* session and the surface re-keys), the textarea value is remembered under
* the OLD key, the draft for the NEW key is swapped in, and the caret lands
* at the end. `onDraftKeyChange` lets the composer reset sibling state
* machines (prompt-history navigation) at the same moment without this hook
* depending on them.
*/
import { useEffect, useRef, useState, type RefObject } from 'react';
import { readComposerDraft, rememberComposerDraft } from './composer-helpers.js';
export interface ComposerDraftApi {
/** True while the active draft holds non-whitespace text (gates Send). */
hasDraftText: boolean;
/**
* Persist the current (or given) textarea value under the active draft key
* and refresh `hasDraftText`.
*/
saveCurrentDraft(value?: string): void;
/**
* Clear the draft stored under an explicit key. A successful send clears
* the key it was submitted from (which may no longer be the active key
* after a new-session swap) in addition to the active draft.
*/
clearDraft(key: string | undefined): void;
/** Persist text under an explicit session key before the host switches it. */
setDraft(key: string | undefined, value: string): void;
/** The key the current textarea content is persisted under. */
activeDraftKey(): string | undefined;
}
export function useComposerDraft(input: {
textareaRef: RefObject<HTMLTextAreaElement | null>;
/** Runtime-only key used to keep unsent drafts isolated per session. */
draftKey: string | undefined;
/** Re-measure the textarea after a programmatic value swap. */
autoResize(): void;
/** Fired after the active key swaps so sibling state machines can reset. */
onDraftKeyChange(): void;
}): ComposerDraftApi {
const [hasDraftText, setHasDraftText] = useState(false);
const draftStoreRef = useRef<Map<string, string>>(new Map());
const activeDraftKeyRef = useRef<string | undefined>(input.draftKey);
function saveCurrentDraft(value?: string) {
const nextValue = value ?? input.textareaRef.current?.value ?? '';
rememberComposerDraft(draftStoreRef.current, activeDraftKeyRef.current, nextValue);
setHasDraftText(Boolean(nextValue.trim()));
}
function clearDraft(key: string | undefined) {
rememberComposerDraft(draftStoreRef.current, key, '');
}
function setDraft(key: string | undefined, value: string) {
rememberComposerDraft(draftStoreRef.current, key, value);
if (activeDraftKeyRef.current === key) setHasDraftText(Boolean(value.trim()));
}
function activeDraftKey() {
return activeDraftKeyRef.current;
}
useEffect(() => {
const el = input.textareaRef.current;
const previousKey = activeDraftKeyRef.current;
const nextKey = input.draftKey;
if (previousKey !== nextKey) {
rememberComposerDraft(draftStoreRef.current, previousKey, el?.value ?? '');
activeDraftKeyRef.current = nextKey;
input.onDraftKeyChange();
if (el) {
const nextDraft = readComposerDraft(draftStoreRef.current, nextKey);
el.value = nextDraft;
setHasDraftText(Boolean(nextDraft.trim()));
input.autoResize();
const length = el.value.length;
el.setSelectionRange(length, length);
}
}
}, [input.draftKey]);
return { hasDraftText, saveCurrentDraft, clearDraft, setDraft, activeDraftKey };
}