'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useChatContext } from '../context'; import type { ChatMessage } from '../types'; export interface UseChatUnreadOptions { /** * When true, unread state is auto-cleared (treated as "user is reading * the chat right now"). Pass your dock-open boolean here so the badge * resets the moment the user opens the chat. */ open?: boolean; /** * Which message roles count as "unread". Defaults to `['assistant']` — * we only count inbound replies, not the user's own messages. */ countRoles?: Array; } export interface UseChatUnreadReturn { /** Most-recent inbound message since the last mark-as-read. */ unread: ChatMessage | null; /** Total inbound messages since the last mark-as-read. */ count: number; /** Manually clear the unread state. */ markRead: () => void; } /** * Track inbound chat messages while the user isn't watching. * * Must be called **inside** the chat's `` (i.e. inside the * `children` of `ChatLauncher`, alongside `ChatRoot`). * * @example * ```tsx * function ChatRootWithUnreadBadge({ open, onUnread }: { open: boolean; onUnread: (m: ChatMessage | null) => void }) { * const { unread, count, markRead } = useChatUnread({ open }); * useEffect(() => onUnread(unread), [unread, onUnread]); * // pass `count` to your FAB badge via the host's state * return ; * } * ``` * * For end-to-end wiring with ``, * see the `Launcher / WithLivePush` story. */ export function useChatUnread(opts: UseChatUnreadOptions = {}): UseChatUnreadReturn { const { open = false, countRoles = ['assistant'] } = opts; const ctx = useChatContext(); const [lastSeenId, setLastSeenId] = useState(null); // On first mount, treat the current tail as already seen so old history // doesn't immediately register as unread. const initialized = useRef(false); useEffect(() => { if (initialized.current) return; initialized.current = true; const tail = ctx.messages[ctx.messages.length - 1]; setLastSeenId(tail?.id ?? null); }, [ctx.messages]); // While the dock is open, auto-advance the seen pointer to the tail so // count stays at 0. useEffect(() => { if (!open) return; const tail = ctx.messages[ctx.messages.length - 1]; setLastSeenId(tail?.id ?? null); }, [open, ctx.messages]); // Compute unread tail since `lastSeenId`. const seenIdx = lastSeenId ? ctx.messages.findIndex((m) => m.id === lastSeenId) : -1; const after = seenIdx === -1 ? ctx.messages : ctx.messages.slice(seenIdx + 1); const inbound = after.filter((m) => countRoles.includes(m.role)); const unread = inbound.length > 0 ? inbound[inbound.length - 1]! : null; const markRead = useCallback(() => { const tail = ctx.messages[ctx.messages.length - 1]; setLastSeenId(tail?.id ?? null); }, [ctx.messages]); return { unread, count: inbound.length, markRead }; }