'use client'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, } from 'react'; import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry'; import type { ChatConfig, ChatLabels, ChatTransport } from '../types'; import { DEFAULT_LABELS } from '../types'; import type { BlockRegistry } from '../messages/blocks'; import { setBridgeSender } from '../../../lib/browser-bridge'; import { useChat, type UseChatReturn } from '../hooks/useChat'; import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout'; import { useChatAudio } from '../hooks/useChatAudio'; import { useStreamEndFocus } from '../hooks/useStreamEndFocus'; import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types'; /** Imperative handle a composer (built-in or custom) registers so * other parts of the chat tree can drive it without prop-drilling a * ref. `focus()` is the baseline; the rest is optional so non-textarea * hosts can keep returning `{ focus }` only. * * Implemented by: * - built-in `` — backed by `useChatComposer.textareaRef`. * - `@djangocfg/ui-tools/markdown-editor` — backed by the TipTap * editor (`editor.commands.focus('end')`). * Consumed by `VoiceComposerSlot` for the focus / move-caret behaviour * during live dictation. */ // `ComposerHandle` lives in `@djangocfg/ui-tools/composer-registry` — // the cross-tool registry shared by chat (producer) and // speech-recognition (consumer). Re-export here so existing call sites // `import { ComposerHandle } from '@djangocfg/ui-tools/chat'` keep // working unchanged. export type { ComposerHandle } from '@djangocfg/ui-tools/composer-registry'; export interface ChatContextValue extends UseChatReturn { layout: UseChatLayoutReturn; config: ChatConfig; labels: ChatLabels; audio: UseChatAudioReturn; /** True iff the host wired at least one ``audio.sounds[event]`` URL. * Components like ``AudioToggle`` use this to auto-hide when there * is nothing to mute. */ hasAudio: boolean; /** Registry of `kind` → renderer for `message.blocks`. `null` when the * host wired none — `` then uses `BUILTIN_BLOCK_REGISTRY`. */ blockRegistry: BlockRegistry | null; } const Ctx = createContext(null); export interface ChatProviderProps { transport: ChatTransport; config?: ChatConfig; initialSessionId?: string; autoCreateSession?: boolean; streaming?: boolean; /** Audio-trigger configuration. Off by default (no `sounds` map). */ audio?: ChatAudioConfig; /** Enable verbose dev logging via consola. Defaults to `isDev`. */ debug?: boolean; /** Registry of `kind` → renderer for `message.blocks`. Stored in * context so nested `` / `` pick it up. */ blockRegistry?: BlockRegistry; /** * Rewrite outgoing message content before it hits the transport. * History bubble keeps the original; useful for stripping rich- * display chips so the LLM sees plain text. See `useChat`. */ onBeforeSend?: (content: string) => string | Promise; /** * Contribute extra transport metadata, computed fresh per send. * Merged over the static metadata right before `transport.stream/send`. * Keeps `Chat` decoupled from any specific metadata source — the host * supplies the function (e.g. the page-context snapshot from * `usePageSnapshot().getChatMetadata`). */ getDynamicMetadata?: () => Record | undefined; /** * Re-focus the registered composer on the streaming → idle edge — * the standard chat UX (type → send → read → keep typing without * reaching for the mouse). Default `true`. Works for every usage * pattern (`ChatRoot`, hand-rolled `Composer` layout, headless) as * long as a composer is registered. Set `false` to opt out. */ autoFocusOnStreamEnd?: boolean; children?: ReactNode; } export function ChatProvider({ transport, config = {}, initialSessionId, autoCreateSession, streaming, audio, debug, blockRegistry, onBeforeSend, getDynamicMetadata, autoFocusOnStreamEnd = true, children, }: ChatProviderProps) { const audioApi = useChatAudio(audio ?? {}); // Keep latest audio API in a ref so the chat-callback closures stay // referentially stable (don't re-mount transport on every audio change). const audioRef = useRef(audioApi); audioRef.current = audioApi; const onMessageSent = useCallback(() => audioRef.current.play('messageSent'), []); const onMessageEnd = useCallback(() => audioRef.current.play('messageReceived'), []); const onStreamStart = useCallback(() => audioRef.current.play('streamStart'), []); const onError = useCallback(() => audioRef.current.play('error'), []); const chat = useChat({ transport, initialSessionId, autoCreateSession, streaming, debug, metadata: { locale: config.prefs?.locale, slug: config.slug, }, userPersona: config.user, onMessageSent, onMessageEnd, onStreamStart, onError, onBeforeSend, getDynamicMetadata, }); const layout = useChatLayout({ defaultMode: 'embedded' }); // Auto-unlock audio on the first user gesture inside the provider. const rootRef = useRef(null); useEffect(() => { if (audioApi.isUnlocked) return; const root = rootRef.current; if (!root) return; const handler = () => { audioApi.unlock(); }; root.addEventListener('pointerdown', handler, { once: true, capture: true }); root.addEventListener('keydown', handler, { once: true, capture: true }); return () => { root.removeEventListener('pointerdown', handler, { capture: true }); root.removeEventListener('keydown', handler, { capture: true }); }; }, [audioApi]); // Publish `sendMessage` to the browser bridge so the dev-only // `window.__chatBridge.sendMessage(...)` can drive the chat from the // console. `setBridgeSender` no-ops outside dev; cleared on unmount. useEffect(() => { setBridgeSender(chat.sendMessage); return () => setBridgeSender(null); }, [chat.sendMessage]); const labels = useMemo( () => ({ ...DEFAULT_LABELS, ...(config.labels ?? {}) }), [config.labels], ); // True when audio is wired up — drives the auto-injected mute toggle. // `audio={{}}` counts: `useChatAudio` falls back to the bundled // DEFAULT_CHAT_SOUNDS, so an empty config still has sounds to mute. // Only an absent `audio` prop or `silenced` means "nothing to mute". const hasAudio = useMemo(() => { if (!audio) return false; if (audio.silenced) return false; const sounds = audio.sounds; if (sounds === undefined) return true; return Object.values(sounds).some( (v) => typeof v === 'string' && v.length > 0, ); }, [audio]); // Re-focus the composer on the streaming → idle edge. Lives here (not // in `ChatRoot`) so it works for *every* usage pattern — `ChatRoot`, // a hand-rolled `ChatProvider` + `Composer` layout, or headless — as // long as a composer registered its handle. The active handle lives // in `@djangocfg/ui-tools/composer-registry` — a single cross-tool // registry shared with ``. useStreamEndFocus({ isStreaming: chat.isStreaming, enabled: autoFocusOnStreamEnd, delayMs: 0, resolveTarget: () => getActiveComposer(), }); const value = useMemo( () => ({ ...chat, layout, config, labels, audio: audioApi, hasAudio, blockRegistry: blockRegistry ?? null, }), [chat, layout, config, labels, audioApi, hasAudio, blockRegistry], ); return (
{children}
); } export function useChatContext(): ChatContextValue { const v = useContext(Ctx); if (!v) throw new Error('useChatContext must be used inside '); return v; } export function useChatContextOptional(): ChatContextValue | null { return useContext(Ctx); }