'use client'; import { type RefObject, useEffect } from 'react'; import { attachComposer, getActiveComposer, type ComposerHandle, } from '@djangocfg/ui-tools/composer-registry'; import { useChatContextOptional } from '../context'; import { useStreamEndFocus, type Focusable } from './useStreamEndFocus'; export type { Focusable } from './useStreamEndFocus'; export interface UseAutoFocusOnStreamEndOptions { /** True while an assistant reply is streaming. The hook fires the * focus() call on the true → false transition. * * When omitted, the hook reads `isStreaming` from `useChatContext`. * Pass it explicitly only if you're driving stream state from your * own store (cmdop's Wails event bus, for example). */ isStreaming?: boolean; /** Ref / handle to focus when the reply lands. * * When omitted, the hook uses the composer handle registered in * the chat context — the built-in `` registers itself * automatically, custom composers can opt in via * `useRegisterComposer`. Pass `targetRef` only when you need to * focus something other than the composer (e.g. an "approve" * button, a quick-reply chip). */ targetRef?: RefObject; /** Opt-out. Default true. Pass false to disable without unmounting * the hook (e.g. user preference). */ enabled?: boolean; /** Delay the focus call by this many ms. Default 0 = next animation * frame, which lets the streaming bubble's final commit settle * before focus pulls scroll. Bump to 50-150ms for layouts that * re-mount the composer after the final chunk. */ delayMs?: number; } /** * Refocus the chat composer the moment the assistant reply finishes * streaming. Standard chat UX: the user types → sends → reads the * reply → starts typing again without reaching for the mouse. * * **You usually do NOT need to call this.** `` runs the * stream-end focus internally, so every chat — `ChatRoot`, a hand-rolled * `ChatProvider` + `Composer` layout, or a headless setup with a * registered composer — gets it for free. Disable it provider-wide with * ``. * * Call this hook directly only for advanced cases the provider can't * cover: * * // focus something OTHER than the composer (an approve button): * const ref = useRef<{ focus: () => void } | null>(null); * useAutoFocusOnStreamEnd({ targetRef: ref }); * * // drive stream state from your own store: * useAutoFocusOnStreamEnd({ isStreaming: myExternalStreaming }); */ export function useAutoFocusOnStreamEnd( options: UseAutoFocusOnStreamEndOptions = {}, ): void { const { isStreaming: isStreamingProp, targetRef, enabled = true, delayMs = 0 } = options; const ctx = useChatContextOptional(); // Prefer the prop (caller knows best), fall back to context. const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false; useStreamEndFocus({ isStreaming, enabled, delayMs, // Resolve in priority order: explicit ref > active composer // (from the cross-tool registry). resolveTarget: () => (targetRef?.current as Focusable | null) ?? getActiveComposer(), }); } /** * Helper for custom composers (anything that's NOT the built-in * ``) to register their focus() with the chat context so * `useAutoFocusOnStreamEnd()` and other consumers work without * prop-drilling. * * Usage inside your custom composer: * * const focus = useCallback(() => { * myEditorRef.current?.commands.focus(); * }, []); * useRegisterComposer(focus); * * No-op when called outside a ``. */ export function useRegisterComposer(handle: ComposerHandle): void { const focus = handle.focus; const moveCursorToEnd = handle.moveCursorToEnd; // Forward `getValue/setValue` too — voice dictation reads/writes the // draft through them, so dropping them silently broke dictation for // custom composers (e.g. the TipTap MarkdownEditor wrapper). const getValue = handle.getValue; const setValue = handle.setValue; useEffect(() => { return attachComposer({ focus, moveCursorToEnd, getValue, setValue }); }, [focus, moveCursorToEnd, getValue, setValue]); }