UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

105 lines (90 loc) • 3.39 kB
"use client"; import { useEffect, useRef, useState } from "react"; import { useAuiState } from "@assistant-ui/store"; import { useShallowSelector } from "@assistant-ui/store/internal"; /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */ export type Unstable_MessageStallDetectionOptions = { /** * Milliseconds of unchanged message content before the message counts as * stalled. * @default 2000 */ thresholdMs?: number | undefined; }; /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */ export type Unstable_MessageStallDetection = { /** True while the message is running and its content has not changed for at least `thresholdMs`. */ stalled: boolean; /** Milliseconds since the last observed content change. `0` while not stalled. */ stalledForMs: number; }; /** * Detects mid-run output stalls on the current message: while the message is * running, watches its text, reasoning, and tool-argument values plus tool-result * availability and reports a stall once they stop changing for `thresholdMs`. * Useful for re-surfacing a "still working" indicator during tool think-time or * provider stalls, after the first tokens have already streamed. * * Must be used inside a message scope. * * @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */ export function unstable_useMessageStallDetection( options?: Unstable_MessageStallDetectionOptions, ): Unstable_MessageStallDetection { const thresholdMs = options?.thresholdMs ?? 2000; const activity = useAuiState( useShallowSelector((s) => { const running = s.message.status?.type === "running"; if (!running) return [false]; const values: unknown[] = [true, s.message.content.length]; for (const part of s.message.content) { if (part.type === "text" || part.type === "reasoning") { values.push(part.type, part.text); } else if (part.type === "tool-call") { values.push(part.type, part.argsText, part.result !== undefined); } else { values.push(part.type); } } return values; }), ); const running = activity[0] === true; const lastActivityRef = useRef(0); const [stalled, setStalled] = useState(false); const [now, setNow] = useState(0); useEffect(() => { if (!running) return undefined; lastActivityRef.current = Date.now(); return undefined; }, [running, activity]); useEffect(() => { if (!running) { setStalled(false); return undefined; } const stall = () => { setNow(Date.now()); setStalled(true); }; const sinceActivity = Date.now() - lastActivityRef.current; if (sinceActivity >= thresholdMs) { stall(); return undefined; } setStalled(false); const id = setTimeout(stall, thresholdMs - sinceActivity); return () => clearTimeout(id); }, [running, activity, thresholdMs]); useEffect(() => { if (!stalled) return undefined; const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [stalled]); if (!stalled) return { stalled: false, stalledForMs: 0 }; return { stalled: true, stalledForMs: Math.max(0, now - lastActivityRef.current), }; }