/** * useChatSession — single hook that owns the WebSocket connection and all derived state. * * Production behaviour: * - On mount, opens /ws (with ?session_id= if we have one in localStorage). * - Receives the `session` envelope, persists the session_id so a refresh resumes. * - Streams trace events live (they land in `traceEvents` before the reply lands). * - On reconnect (server bounce / network blip) we keep the same session_id so the * backend's AgentLoop is preserved. * - Exposes minimal imperative methods: send(), reset(), reconnect(). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentStateSnapshot, ChatMessage, ClientMessage, ConnectionStatus, ServerMessage, TraceEvent, UiHint, } from "../types"; const SESSION_KEY = "ramco-chat.session_id"; const WS_URL = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; const RECONNECT_DELAY_MS = 1500; const MAX_RECONNECT_DELAY_MS = 10_000; function uid(): string { return Math.random().toString(36).slice(2, 10); } export type ChatSession = { status: ConnectionStatus; sessionId: string | null; resumed: boolean; availableJourneys: string[]; state: AgentStateSnapshot | null; messages: ChatMessage[]; /** Trace events for the most recent turn (cleared at start of each new turn). */ liveTrace: TraceEvent[]; /** All trace events ever, for a "show full history" pane. */ allTrace: TraceEvent[]; /** True while the agent is processing the latest user message. */ thinking: boolean; /** The latest assistant ui_hint, used by the smart-control renderer. */ activeUiHint: UiHint | null; /** When the latest turn produced candidate_journeys (ambiguity), they live here. */ candidateJourneys: string[]; send(text: string): void; reset(): void; reconnect(): void; }; export function useChatSession(): ChatSession { const [status, setStatus] = useState("connecting"); const [sessionId, setSessionId] = useState(() => localStorage.getItem(SESSION_KEY)); const [resumed, setResumed] = useState(false); const [availableJourneys, setAvailableJourneys] = useState([]); const [state, setState] = useState(null); const [messages, setMessages] = useState([]); const [liveTrace, setLiveTrace] = useState([]); const [allTrace, setAllTrace] = useState([]); const [thinking, setThinking] = useState(false); const [activeUiHint, setActiveUiHint] = useState(null); const [candidateJourneys, setCandidateJourneys] = useState([]); const wsRef = useRef(null); const reconnectAttemptRef = useRef(0); const reconnectTimerRef = useRef(null); const intentionalCloseRef = useRef(false); const sendClient = useCallback((msg: ClientMessage) => { const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); } }, []); const connect = useCallback(() => { const stored = localStorage.getItem(SESSION_KEY); const url = stored ? `${WS_URL}?session_id=${encodeURIComponent(stored)}` : WS_URL; setStatus("connecting"); intentionalCloseRef.current = false; const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { setStatus("open"); reconnectAttemptRef.current = 0; }; ws.onmessage = (ev) => { let msg: ServerMessage; try { msg = JSON.parse(ev.data); } catch { return; } handleServerMessage(msg); }; ws.onclose = () => { setStatus("closed"); wsRef.current = null; setThinking(false); if (!intentionalCloseRef.current) { scheduleReconnect(); } }; ws.onerror = () => { setStatus("error"); }; }, []); const scheduleReconnect = useCallback(() => { if (reconnectTimerRef.current !== null) return; const attempt = ++reconnectAttemptRef.current; const delay = Math.min(RECONNECT_DELAY_MS * attempt, MAX_RECONNECT_DELAY_MS); reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = null; connect(); }, delay); }, [connect]); const handleServerMessage = useCallback((msg: ServerMessage) => { switch (msg.type) { case "session": { setSessionId(msg.session_id); localStorage.setItem(SESSION_KEY, msg.session_id); setResumed(msg.resumed); setAvailableJourneys(msg.available_journeys); setState(msg.state); // If the server reports a non-trivial resumed state, surface a system note. if (msg.resumed && msg.state.turn_count > 0) { setMessages((m) => [ ...m, { id: uid(), role: "system", text: `Resumed your session — ${msg.state.turn_count} turns already on the record.`, ts: Date.now(), }, ]); } break; } case "turn_start": { setThinking(true); setLiveTrace([]); setActiveUiHint(null); setCandidateJourneys([]); // We optimistically appended the user message in send(); nothing to do here. break; } case "trace": { setLiveTrace((t) => [...t, msg.event]); setAllTrace((t) => [...t, msg.event]); break; } case "turn_complete": { setThinking(false); setState(msg.state); setActiveUiHint(msg.ui_hint); // Extract candidate_journeys from the most recent clarify_required trace event. const candidates = lastCandidateJourneysFromTrace(allTraceRef.current.concat(liveTraceRef.current)); setCandidateJourneys(candidates); setMessages((m) => [ ...m, { id: uid(), role: "assistant", text: msg.reply, ts: Date.now(), ui_hint: msg.ui_hint, candidates, terminated: msg.terminated, }, ]); break; } case "error": { setThinking(false); setMessages((m) => [ ...m, { id: uid(), role: "system", text: `Error: ${msg.message}`, ts: Date.now(), level: "error" }, ]); break; } case "pong": break; } }, []); // refs so handleServerMessage can read current trace state inside its closure const allTraceRef = useRef([]); const liveTraceRef = useRef([]); useEffect(() => { allTraceRef.current = allTrace; }, [allTrace]); useEffect(() => { liveTraceRef.current = liveTrace; }, [liveTrace]); useEffect(() => { connect(); return () => { intentionalCloseRef.current = true; if (reconnectTimerRef.current !== null) clearTimeout(reconnectTimerRef.current); wsRef.current?.close(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Keep-alive ping every 30s so dev proxies don't drop idle sockets. useEffect(() => { const id = window.setInterval(() => sendClient({ type: "ping" }), 30_000); return () => clearInterval(id); }, [sendClient]); const send = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed) return; // Optimistically render the user bubble; server will echo via turn_start. setMessages((m) => [...m, { id: uid(), role: "user", text: trimmed, ts: Date.now() }]); setLiveTrace([]); setActiveUiHint(null); setCandidateJourneys([]); sendClient({ type: "user_message", text: trimmed }); }, [sendClient], ); const reset = useCallback(() => { sendClient({ type: "reset" }); setMessages([]); setAllTrace([]); setLiveTrace([]); setActiveUiHint(null); setCandidateJourneys([]); }, [sendClient]); const reconnect = useCallback(() => { intentionalCloseRef.current = true; wsRef.current?.close(); intentionalCloseRef.current = false; setTimeout(connect, 50); }, [connect]); return useMemo( () => ({ status, sessionId, resumed, availableJourneys, state, messages, liveTrace, allTrace, thinking, activeUiHint, candidateJourneys, send, reset, reconnect, }), [ status, sessionId, resumed, availableJourneys, state, messages, liveTrace, allTrace, thinking, activeUiHint, candidateJourneys, send, reset, reconnect, ], ); } function lastCandidateJourneysFromTrace(events: TraceEvent[]): string[] { for (let i = events.length - 1; i >= 0; i--) { const e = events[i]; if (e.kind === "clarify_required" && Array.isArray((e.data as any)?.candidates)) { return (e.data as any).candidates as string[]; } if (e.kind === "turn_begin") break; // only look within the current turn } return []; }