'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { useHotkey } from '@djangocfg/ui-core/hooks'; import { ChatProvider, useChatContextOptional } from '../context'; import type { ChatAudioConfig } from '../core/audio/types'; import { useChatDockPrefs } from '../hooks/useChatDockPrefs'; import type { ChatConfig, ChatMessage, ChatTransport, } from '../types'; import { ChatFAB, type ChatFABPosition, type ChatFABProps } from './ChatFAB'; import { ChatDock, type ChatDockProps } from './ChatDock'; import { ChatGreeting, type ChatGreetingProps } from './ChatGreeting'; import { ChatUnreadPreview, type ChatUnreadPreviewProps } from './ChatUnreadPreview'; import { HeaderSlotsRenderer } from './header'; import { resolveHeaderSlots, type ChatHeaderSlots } from './types'; export interface ChatLauncherHotkey { /** Key (case-sensitive single char or named like 'Escape'). */ key: string; /** Require Cmd (mac) or Ctrl (other). */ meta?: boolean; /** Require Shift. */ shift?: boolean; /** Require Alt. */ alt?: boolean; } export interface ChatLauncherGreeting extends Omit { /** Greeting body — string for the default style, or any ReactNode. */ content: ReactNode; /** Persistence key for "user dismissed this greeting" in `localStorage`. Pass `null` to disable persistence. @default null */ dismissStorageKey?: string | null; /** Hide the greeting once the user opens the chat. @default true */ hideOnOpen?: boolean; } export interface ChatLauncherProps { // ---- chat-provider wiring (mounts internally) ----------- /** * Transport. Required unless the launcher is mounted inside an * existing `` (in which case the ambient provider is * reused and `transport` is ignored). */ transport?: ChatTransport; /** Optional chat config (labels, prefs, persona, etc.). */ config?: ChatConfig; /** Pre-existing session to attach to. */ initialSessionId?: string; /** Create a new backend session automatically when none is provided. */ autoCreateSession?: boolean; /** Enable streaming. Defaults to transport's preference. */ streaming?: boolean; /** * Audio-trigger configuration (sounds map). The launcher owns the * `useChatAudio()` hook internally; consumers no longer construct it * themselves. */ audio?: ChatAudioConfig; /** Verbose dev logging via consola. */ debug?: boolean; /** Rewrite outgoing content before transport. */ onBeforeSend?: (content: string) => string | Promise; // ---- visual chrome ------------------------------------------------------ /** Dock contents — typically a `` or custom chat shell. */ children: ReactNode; /** FAB customization. */ fab?: Omit; /** Dock customization. `headerActions` is computed from `headerSlots`. */ dock?: Omit; /** * Declarative header buttons rendered INSIDE the launcher's * ``. See `ChatHeaderSlots` for the available knobs. */ headerSlots?: ChatHeaderSlots; /** * Proactive greeting bubble shown next to the FAB before the user * opens the chat. */ greeting?: string | ChatLauncherGreeting; /** Open/close via a keyboard shortcut. */ hotkey?: ChatLauncherHotkey; /** Initial open state for uncontrolled mode. @default false */ defaultOpen?: boolean; /** Controlled open state. */ open?: boolean; /** Controlled open state setter. */ onOpenChange?: (open: boolean) => void; /** Focus the composer when the dock opens. @default true */ autoFocusComposerOnOpen?: boolean; /** Close the dock on Escape. @default true */ closeOnEscape?: boolean; /** * Last unread inbound message — drives `` and the * FAB badge. */ unreadMessage?: ChatMessage | null; /** Called when the chat is opened or the preview dismissed. */ onMarkRead?: () => void; /** Customize the unread bubble. */ unreadPreview?: Omit< ChatUnreadPreviewProps, 'open' | 'message' | 'onClick' | 'onDismiss' | 'position' | 'fabOffset' >; } function readDismissed(storageKey: string | null | undefined): boolean { if (!storageKey) return false; if (typeof window === 'undefined') return false; try { return window.localStorage.getItem(storageKey) === '1'; } catch { return false; } } function writeDismissed(storageKey: string | null | undefined): void { if (!storageKey) return; if (typeof window === 'undefined') return; try { window.localStorage.setItem(storageKey, '1'); } catch { // private mode / storage full — silently ignore } } /** * Floating chat launcher = `` + FAB + Dock + presence * + optional greeting + hotkey. * * The provider lives at this level so: * - declarative `headerSlots` (e.g. `reset`) can read `sessionId` / * call `clearMessages()` via `useChatContext()` while rendering in * the dock header, which is a sibling of `children` (not a child). * - descendant `` instances detect the ambient provider and * skip wrapping in a second one. */ export function ChatLauncher({ // provider wiring transport, config, initialSessionId, autoCreateSession, streaming, audio, debug, onBeforeSend, // visual chrome children, fab, dock, headerSlots, greeting, hotkey, defaultOpen = false, open: controlledOpen, onOpenChange, autoFocusComposerOnOpen = true, closeOnEscape = true, unreadMessage, onMarkRead, unreadPreview, }: ChatLauncherProps) { const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; const dockContentRef = useRef(null); // Auto-focus the composer when the dock opens. useEffect(() => { if (!autoFocusComposerOnOpen || !open) return; const t = setTimeout(() => { const root = dockContentRef.current; if (!root) return; const target = root.querySelector( 'textarea:not([disabled]):not([readonly]), input[type="text"]:not([disabled]):not([readonly])', ); target?.focus(); }, 120); return () => clearTimeout(t); }, [open, autoFocusComposerOnOpen]); const setOpen = useCallback( (next: boolean) => { if (!isControlled) setUncontrolledOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); const toggleOpen = useCallback(() => setOpen(!open), [open, setOpen]); useHotkey( 'escape', (e) => { const target = (e?.target as HTMLElement | null) ?? null; const inEditable = !!target && (target.matches?.('input, textarea, [contenteditable="true"]') ?? false); if (inEditable) { target.blur(); return; } setOpen(false); }, { enabled: closeOnEscape && open }, ); // Normalize greeting prop. const greetingConfig: ChatLauncherGreeting | null = greeting === undefined ? null : typeof greeting === 'string' ? { content: greeting } : greeting; const [dismissed, setDismissed] = useState(() => readDismissed(greetingConfig?.dismissStorageKey), ); // Hotkey. useEffect(() => { if (!hotkey) return; const handler = (e: KeyboardEvent) => { const metaOk = hotkey.meta ? e.metaKey || e.ctrlKey : !e.metaKey && !e.ctrlKey; const shiftOk = hotkey.shift ? e.shiftKey : !e.shiftKey; const altOk = hotkey.alt ? e.altKey : !e.altKey; if (!metaOk || !shiftOk || !altOk) return; if (e.key !== hotkey.key) return; e.preventDefault(); setOpen(!open); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [hotkey?.key, hotkey?.meta, hotkey?.shift, hotkey?.alt, open, setOpen, hotkey]); const greetingOpen = !!greetingConfig && !dismissed && (greetingConfig.hideOnOpen === false || !open); const fabPosition: ChatFABPosition = fab?.position ?? 'bottom-right'; const fabOffset = fab?.offset ?? 24; const handleGreetingDismiss = () => { setDismissed(true); writeDismissed(greetingConfig?.dismissStorageKey); }; const handleGreetingClick = () => { setOpen(true); setDismissed(true); writeDismissed(greetingConfig?.dismissStorageKey); }; useEffect(() => { if (open && unreadMessage) onMarkRead?.(); }, [open, unreadMessage, onMarkRead]); const unreadOpen = !open && !!unreadMessage; const handleUnreadClick = () => { setOpen(true); onMarkRead?.(); }; const handleUnreadDismiss = () => { onMarkRead?.(); }; const resolvedFab = unreadMessage && fab?.badge === undefined ? { ...fab, badge: 1 } : fab; // Reused below for the ambient-provider branch; read up here so // `audioConfigured` can fall back to the ambient provider's audio. const ambient = useChatContextOptional(); // Whether audio wires up any actual sound. Used as the default for // `headerSlots.audio` — no point auto-injecting the toggle when // there's nothing to mute. // // When the launcher is rendered inside an existing `` // (the host owns the provider), audio was configured there, not via // this component's `audio` prop — so honor the ambient provider's // `hasAudio` too. Without this the dock-header mute toggle silently // never appears for host-owned-provider setups. const audioConfigured = useMemo(() => { if (ambient?.hasAudio) return true; if (!audio) return false; if (audio.silenced) return false; const sounds = audio.sounds; // `useChatAudio` falls back to DEFAULT_CHAT_SOUNDS when `sounds` is // undefined and `silenced` is false. Treat that as "configured" too. if (sounds === undefined) return true; return Object.values(sounds).some( (v) => typeof v === 'string' && v.length > 0, ); }, [audio, ambient?.hasAudio]); const resolvedSlots = useMemo( () => resolveHeaderSlots(headerSlots, audioConfigured), [headerSlots, audioConfigured], ); // Single source of truth for dock layout prefs. The mode-toggle slot // and `` live in separate subtrees — if each called // `useChatDockPrefs` independently they'd hold isolated `useState` // (no same-tab cross-instance sync), so the toggle would flip // localStorage but never re-render the dock. Owning the hook here and // threading both the values and the toggle down keeps them in sync. const modeToggleSlot = resolvedSlots.modeToggle; const dockPrefs = useChatDockPrefs({ storageKey: modeToggleSlot?.persistAs, defaults: modeToggleSlot?.defaults, }); const hasAnySlot = resolvedSlots.audio || resolvedSlots.modeToggle !== null || resolvedSlots.languagePicker !== null || resolvedSlots.reset !== null || resolvedSlots.custom !== null; const body = ( <> {unreadMessage ? ( ) : greetingConfig ? ( {greetingConfig.content} ) : null} setOpen(false)} headerActions={ hasAnySlot ? ( ) : undefined } >
{children}
); if (ambient) { // Already inside a ChatProvider — reuse it. Provider-level props // (transport / config / audio / debug / onBeforeSend) are ignored // because they belong to the upstream provider. return body; } if (!transport) { // No ambient provider and no transport — programmer error. throw new Error( ' requires `transport` when mounted outside a .', ); } return ( {body} ); }