// chat.jsx — coach chat pane. Streams Server-Sent Events from /api/chat. // // We can't use the browser's EventSource because EventSource has no way to // attach an Authorization header. Instead we POST with fetch, then read the // response.body as a ReadableStream and parse the SSE frames ourselves. // // SSE frame format: // event: \n // data: \n // \n // // Event types we handle: meta, delta, tool_use, tool_result, done, error. const { useEffect, useState, useRef, useCallback } = React; const _auth = window.CoachAuth; async function* parseSseStream(response) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); if (!frame.trim()) continue; let event = 'message'; let data = ''; for (const line of frame.split('\n')) { if (line.startsWith('event: ')) event = line.slice(7).trim(); else if (line.startsWith('data: ')) data += line.slice(6); } let parsed = null; try { parsed = data ? JSON.parse(data) : null; } catch (_e) { parsed = { _raw: data }; } yield { event, data: parsed }; } } } function ToolCard({ tool, args, result }) { // Render the tool call inline with the conversation. Args + result are // both shown small + collapsed-ish so they don't dominate the chat. let resultObj = null; try { resultObj = result ? JSON.parse(result) : null; } catch (_e) { /* not json */ } return (
tool {tool} {args && Object.keys(args).length > 0 && ( {JSON.stringify(args)} )}
{result != null && (
          {resultObj ? JSON.stringify(resultObj, null, 2) : result}
        
)}
); } function MessageBubble({ message }) { // `message.content` is always an array of content blocks (matches what // the backend persists). Render text blocks as markdown; tool_use / // tool_result blocks become ToolCards interleaved in order. const blocks = Array.isArray(message.content) ? message.content : [{ type: 'text', text: typeof message.content === 'string' ? message.content : JSON.stringify(message.content) }]; // Pair tool_use with the matching tool_result by tool_use_id. const resultByUseId = {}; for (const b of blocks) { if (b.type === 'tool_result' && b.tool_use_id) { resultByUseId[b.tool_use_id] = b.content; } } return (
{message.role}
{blocks.map((b, i) => { if (b.type === 'text') { const html = window.marked ? window.marked.parse(b.text || '') : (b.text || ''); return
; } if (b.type === 'tool_use') { return ; } if (b.type === 'tool_result' && !blocks.some((x) => x.type === 'tool_use' && x.id === b.tool_use_id)) { return ; } return null; })}
); } function ChatCard() { const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(null); const [input, setInput] = useState(''); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); const refresh = useCallback(async () => { try { const r = await _auth.authedFetch('/api/chat/messages'); setMessages(r.messages || []); } catch (e) { setError(e.message); } }, []); useEffect(() => { refresh(); }, [refresh]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages, streaming]); const send = async (e) => { e.preventDefault(); if (!input.trim() || pending) return; setError(null); setPending(true); const userMsg = { role: 'user', content: [{ type: 'text', text: input }] }; setMessages((prev) => [...prev, userMsg]); const text = input; setInput(''); const live = { role: 'assistant', content: [] }; setStreaming(live); try { const client = _auth.getClient(); const { data } = await client.auth.getSession(); const token = data.session?.access_token; const resp = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), }, body: JSON.stringify({ message: text }), }); if (!resp.ok) { let detail = 'http_' + resp.status; try { const j = await resp.json(); detail = j.detail || detail; } catch (_e) {} throw new Error(detail); } let currentText = ''; for await (const { event, data: evt } of parseSseStream(resp)) { if (!evt) continue; if (event === 'delta') { currentText += evt.text || ''; const blocks = [...live.content]; const last = blocks[blocks.length - 1]; if (last && last.type === 'text') { blocks[blocks.length - 1] = { type: 'text', text: currentText }; } else { blocks.push({ type: 'text', text: currentText }); } live.content = blocks; setStreaming({ ...live }); } else if (event === 'tool_use') { live.content = [ ...live.content, { type: 'tool_use', id: evt.tool_use_id || ('use_' + Date.now()), tool: evt.tool, args: evt.args }, ]; currentText = ''; setStreaming({ ...live }); } else if (event === 'tool_result') { live.content = [ ...live.content, { type: 'tool_result', tool_use_id: evt.tool_use_id, content: evt.content }, ]; setStreaming({ ...live }); } else if (event === 'error') { throw new Error((evt.error || 'error') + ': ' + (evt.detail || '')); } } setMessages((prev) => [...prev, live]); setStreaming(null); } catch (e) { setError(e.message); setStreaming(null); } finally { setPending(false); } }; const newConversation = async () => { if (!confirm('Start a new conversation? The current thread will be archived.')) return; try { await _auth.authedFetch('/api/chat/archive', { method: 'POST' }); setMessages([]); await refresh(); } catch (e) { setError(e.message); } }; return (
coach chat
{messages.length === 0 && !streaming && (
Ask anything — try "how should I train today given my recent metrics?"
)} {messages.map((m, i) => )} {streaming && }
{error &&
{error}
}