import React from 'react'; const DEFAULT_TIMEOUT_MS = 20000; const LIFECYCLE_GRACE_MS = 8000; type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed'; interface MessageStreamLifecycle { phase: MessageStreamPhase; startedAt: number; lastUpdateAt: number; completedAt?: number; } interface MessagePart { type?: string; time?: { end?: number }; state?: { status?: string }; text?: string; content?: string; } interface ChatMessageInfo { id: string; role: string; time: { created: number; completed?: number; updated?: number }; animationSettled?: boolean; } interface ChatMessageRecord { info: ChatMessageInfo; parts: MessagePart[]; } const hasFinalizedTextPart = (parts: MessagePart[]): boolean => { return parts.some((part) => { if (part?.type !== 'text') { return false; } if (!part?.time || typeof part.time.end === 'undefined') { return false; } const content = typeof part.text === 'string' ? part.text : part.content; return Boolean(content && content.trim().length > 0); }); }; const getAssistantMessagesAfterLastUser = (messages: ChatMessageRecord[]): ChatMessageRecord[] => { let lastUserIndex = -1; for (let i = messages.length - 1; i >= 0; i -= 1) { if (messages[i]?.info?.role === 'user') { lastUserIndex = i; break; } } return messages.filter((message, index) => index > lastUserIndex && message?.info?.role === 'assistant'); }; const buildAssistantActivitySignature = (messages: ChatMessageRecord[]): string => { return messages .map((message) => { const partSignature = (message.parts || []) .map((part) => { const type = part?.type || 'unknown'; const finalized = part?.time && typeof part.time.end !== 'undefined' ? '1' : '0'; const status = part?.state?.status || ''; const textLength = typeof part?.text === 'string' ? part.text.length : 0; const contentLength = typeof part?.content === 'string' ? part.content.length : 0; return `${type}:${finalized}:${status}:${textLength}:${contentLength}`; }) .join('|'); const completed = message.info?.time?.completed || ''; const updated = message.info?.time?.updated || ''; return `${message.info?.id || 'unknown'}:${message.parts?.length || 0}:${completed}:${updated}:${partSignature}`; }) .join('||'); }; interface UseAssistantTypingOptions { messages: ChatMessageRecord[]; timeoutMs?: number; messageStreamStates?: Map; } interface UseAssistantTypingResult { isTyping: boolean; } export const useAssistantTyping = ({ messages, timeoutMs = DEFAULT_TIMEOUT_MS, messageStreamStates, }: UseAssistantTypingOptions): UseAssistantTypingResult => { const assistantMessages = React.useMemo(() => getAssistantMessagesAfterLastUser(messages), [messages]); const hasAssistantActivity = assistantMessages.length > 0; const hasFinalAssistantText = assistantMessages.some((message) => hasFinalizedTextPart(message.parts)); const assistantHasUnsettledAnimation = assistantMessages.some((message) => { return message.info.animationSettled !== true; }); const hasActiveLifecycle = React.useMemo(() => { if (!messageStreamStates || messageStreamStates.size === 0) { return false; } return assistantMessages.some((message) => { const lifecycle = messageStreamStates.get(message.info.id); if (!lifecycle) { return false; } return lifecycle.phase === 'streaming' || lifecycle.phase === 'cooldown'; }); }, [assistantMessages, messageStreamStates]); const hasRunningTool = React.useMemo(() => { return assistantMessages.some((message) => (message.parts || []).some( (part) => part?.type === 'tool' && part?.state?.status === 'running' ) ); }, [assistantMessages]); const shouldShowBecauseOfLifecycle = hasAssistantActivity && (hasActiveLifecycle || hasRunningTool); const shouldShowBasedOnContent = assistantHasUnsettledAnimation && hasAssistantActivity && !hasFinalAssistantText; const [graceUntil, setGraceUntil] = React.useState(null); const previousLifecycleRef = React.useRef(false); React.useEffect(() => { if (shouldShowBecauseOfLifecycle) { setGraceUntil(null); } else if (previousLifecycleRef.current) { setGraceUntil(Date.now() + LIFECYCLE_GRACE_MS); } previousLifecycleRef.current = shouldShowBecauseOfLifecycle; }, [shouldShowBecauseOfLifecycle]); React.useEffect(() => { if (graceUntil === null) { return undefined; } const remaining = graceUntil - Date.now(); if (remaining <= 0) { setGraceUntil(null); return undefined; } const timer = window.setTimeout(() => { setGraceUntil(null); }, remaining); return () => window.clearTimeout(timer); }, [graceUntil]); const withinLifecycleGrace = graceUntil !== null; const shouldShowIndicator = shouldShowBasedOnContent || shouldShowBecauseOfLifecycle || withinLifecycleGrace; const signatureRef = React.useRef(null); const [lastActivityAt, setLastActivityAt] = React.useState(null); const [hasTimedOut, setHasTimedOut] = React.useState(false); React.useEffect(() => { if (!shouldShowIndicator) { signatureRef.current = null; setLastActivityAt(null); setHasTimedOut(false); return; } const contentSignature = buildAssistantActivitySignature(assistantMessages); const lifecycleSignature = messageStreamStates ? assistantMessages .map((message) => { const lifecycle = messageStreamStates.get(message.info.id); if (!lifecycle) { return `${message.info.id}:none`; } return `${message.info.id}:${lifecycle.phase}:${lifecycle.lastUpdateAt}:${ lifecycle.completedAt || '' }`; }) .join('||') : ''; const signature = `${contentSignature}::${lifecycleSignature}::${hasRunningTool ? 'tool-running' : ''}::${ graceUntil ?? 'no-grace' }`; if (signatureRef.current !== signature) { signatureRef.current = signature; setLastActivityAt(Date.now()); setHasTimedOut(false); } }, [assistantMessages, shouldShowIndicator, messageStreamStates, hasRunningTool, graceUntil]); React.useEffect(() => { if (!shouldShowIndicator) { return undefined; } if (lastActivityAt === null) { return undefined; } const now = Date.now(); const elapsed = now - lastActivityAt; if (elapsed >= timeoutMs) { setHasTimedOut(true); return undefined; } const remaining = timeoutMs - elapsed; const timer = window.setTimeout(() => { setHasTimedOut(true); }, remaining); return () => window.clearTimeout(timer); }, [shouldShowIndicator, lastActivityAt, timeoutMs]); const isTyping = shouldShowIndicator && !hasTimedOut; return React.useMemo(() => ({ isTyping }), [isTyping]); };