|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { useEffect, useState } from 'react'; |
| 4 | + |
| 5 | +export type BotPresence = { |
| 6 | + online: boolean; |
| 7 | + lastAt: number; |
| 8 | +}; |
| 9 | + |
| 10 | +export type BotDisplayState = 'online' | 'idle' | 'offline' | 'unknown'; |
| 11 | + |
| 12 | +type BotDisplay = { |
| 13 | + state: BotDisplayState; |
| 14 | + label: 'Online' | 'Idle' | 'Offline' | 'Unknown'; |
| 15 | +}; |
| 16 | + |
| 17 | +export function computeBotDisplay(params: { |
| 18 | + instanceStatus: string | null; |
| 19 | + presence: BotPresence | undefined; |
| 20 | + now: number; |
| 21 | +}): BotDisplay { |
| 22 | + if (params.instanceStatus !== 'running') return { state: 'offline', label: 'Offline' }; |
| 23 | + if (!params.presence) return { state: 'unknown', label: 'Unknown' }; |
| 24 | + if (!params.presence.online) return { state: 'offline', label: 'Offline' }; |
| 25 | + const elapsed = params.now - params.presence.lastAt; |
| 26 | + if (elapsed > 90_000) return { state: 'offline', label: 'Offline' }; |
| 27 | + if (elapsed > 30_000) return { state: 'idle', label: 'Idle' }; |
| 28 | + return { state: 'online', label: 'Online' }; |
| 29 | +} |
| 30 | + |
| 31 | +const DOT_CLASS: Record<BotDisplayState, string> = { |
| 32 | + online: 'bg-green-500', |
| 33 | + idle: 'bg-amber-500', |
| 34 | + offline: 'bg-muted-foreground/50', |
| 35 | + unknown: 'bg-muted-foreground/30', |
| 36 | +}; |
| 37 | + |
| 38 | +type BotStatusProps = { |
| 39 | + instanceStatus: string | null; |
| 40 | + presence?: BotPresence; |
| 41 | + model?: string | null; |
| 42 | +}; |
| 43 | + |
| 44 | +export function BotStatus({ instanceStatus, presence, model }: BotStatusProps) { |
| 45 | + const now = useNowTicker(10_000); |
| 46 | + const display = computeBotDisplay({ instanceStatus, presence, now }); |
| 47 | + const tooltip = buildTooltip(display.state, presence, now, model ?? null); |
| 48 | + return ( |
| 49 | + <div className="flex items-center gap-1.5" title={tooltip}> |
| 50 | + <div className={`h-2 w-2 rounded-full ${DOT_CLASS[display.state]}`} /> |
| 51 | + <span className="text-muted-foreground text-xs">{display.label}</span> |
| 52 | + </div> |
| 53 | + ); |
| 54 | +} |
| 55 | + |
| 56 | +// Staleness ticker: keeps re-renders scoped to the subtree that uses it so |
| 57 | +// sibling components (memoized message bubbles, etc.) are not invalidated |
| 58 | +// every tick. Exported so MessageArea can reuse it for the send-gate that |
| 59 | +// reacts to presence going stale without any user interaction. |
| 60 | +export function useNowTicker(intervalMs: number): number { |
| 61 | + const [now, setNow] = useState(() => Date.now()); |
| 62 | + useEffect(() => { |
| 63 | + const id = setInterval(() => setNow(Date.now()), intervalMs); |
| 64 | + return () => clearInterval(id); |
| 65 | + }, [intervalMs]); |
| 66 | + return now; |
| 67 | +} |
| 68 | + |
| 69 | +function buildTooltip( |
| 70 | + state: BotDisplayState, |
| 71 | + presence: BotPresence | undefined, |
| 72 | + now: number, |
| 73 | + model: string | null |
| 74 | +): string { |
| 75 | + if (state === 'unknown' || !presence) return 'Bot status unknown'; |
| 76 | + if (state === 'offline') return 'Bot is offline'; |
| 77 | + const seconds = Math.max(0, Math.round((now - presence.lastAt) / 1000)); |
| 78 | + const bits = [`Last heartbeat ${seconds}s ago`]; |
| 79 | + if (model) bits.push(`model: ${model}`); |
| 80 | + return bits.join(' · '); |
| 81 | +} |
0 commit comments