From 2dfe82afbc26ab469915d02b61dcf0c66b0335d7 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Mon, 12 Jan 2026 18:44:09 -0500 Subject: [PATCH] feat: add useTranscriptChat WebSocket hook Task 5: Frontend WebSocket Hook - Creates React hook for bidirectional chat WebSocket - Handles token streaming with proper state accumulation - Manages conversation history (user + assistant messages) - Prevents memory leaks with isMounted check - Proper cleanup on unmount - Type-safe Message interface Validated: - No React dependency issues (removed currentStreamingText from deps) - No stale closure bugs (using ref for streaming text) - Proper mounted state tracking - Lint passes with no errors - TypeScript types correctly defined - WebSocket cleanup on unmount ~100 lines --- .../(app)/transcripts/useTranscriptChat.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 www/app/(app)/transcripts/useTranscriptChat.ts diff --git a/www/app/(app)/transcripts/useTranscriptChat.ts b/www/app/(app)/transcripts/useTranscriptChat.ts new file mode 100644 index 00000000..5eb22ecd --- /dev/null +++ b/www/app/(app)/transcripts/useTranscriptChat.ts @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { WEBSOCKET_URL } from "../../lib/apiClient"; + +export type Message = { + id: string; + role: "user" | "assistant"; + text: string; + timestamp: Date; +}; + +export type UseTranscriptChat = { + messages: Message[]; + sendMessage: (text: string) => void; + isStreaming: boolean; + currentStreamingText: string; +}; + +export const useTranscriptChat = (transcriptId: string): UseTranscriptChat => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [currentStreamingText, setCurrentStreamingText] = useState(""); + const wsRef = useRef(null); + const streamingTextRef = useRef(""); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/chat`; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log("Chat WebSocket connected"); + }; + + ws.onmessage = (event) => { + if (!isMountedRef.current) return; + + const msg = JSON.parse(event.data); + + switch (msg.type) { + case "token": + setIsStreaming(true); + streamingTextRef.current += msg.text; + setCurrentStreamingText(streamingTextRef.current); + break; + + case "done": + setMessages((prev) => [ + ...prev, + { + id: Date.now().toString(), + role: "assistant", + text: streamingTextRef.current, + timestamp: new Date(), + }, + ]); + streamingTextRef.current = ""; + setCurrentStreamingText(""); + setIsStreaming(false); + break; + + case "error": + console.error("Chat error:", msg.message); + setIsStreaming(false); + break; + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + ws.onclose = () => { + console.log("Chat WebSocket closed"); + }; + + return () => { + isMountedRef.current = false; + ws.close(); + }; + }, [transcriptId]); + + const sendMessage = (text: string) => { + if (!wsRef.current) return; + + setMessages((prev) => [ + ...prev, + { + id: Date.now().toString(), + role: "user", + text, + timestamp: new Date(), + }, + ]); + + wsRef.current.send(JSON.stringify({ type: "message", text })); + }; + + return { messages, sendMessage, isStreaming, currentStreamingText }; +};