"use client"; /** * TASK-0122 — Ask AI panel state (the streaming conversation hook). * * Owns, for ONE workspace: the conversation list, the active conversation's * messages, the in-flight SSE stream, and the error banner state. The panel * component is a thin view over this hook. * * Streaming model (mirrors docs/api/ai.md): * - `send(content)` → ensure a conversation (seeded with the entry point's * context) → optimistic user bubble → `sendAiMessage` frames append to a * live assistant bubble (`delta`), collect tool calls, then `done` * finalizes the rows (the server persisted them — the ids come back). * - `stop()` aborts the fetch; the server persists the partial * (`metadata.truncated`) and we refresh the rows on the next open. * - `retry()` re-sends the LAST user message with `retryOf` (no duplicate * user row) after a mid-stream / pre-stream error. */ import { useCallback, useEffect, useRef, useState } from "react"; import { aiApi, sendAiMessage, AiPreStreamError, type AiContextRef, type AiConversationSummary, type AiMessage, type AiSseFrame } from "@/lib/ai"; export interface AiBannerError { code: string; message: string; retryable: boolean; retryAfterSeconds?: number; } interface AiTurn { userMessageId?: string; content: string; toolCalls: Array<{ id: string; name: string; arguments: Record }>; usage?: { tokensIn: number; tokensOut: number }; done?: boolean; assistantMessageId?: string; truncated?: boolean; error?: string; } /** Optimistic row ids (never collide with server cuids). */ let tmpSeq = 0; const tmpId = (prefix: string) => `${prefix}-tmp-${Date.now().toString(36)}-${(tmpSeq += 1)}`; export function useAiStream(workspaceId: string | null, context: AiContextRef | null) { const [conversations, setConversations] = useState([]); const [convError, setConvError] = useState(null); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [messagesLoading, setMessagesLoading] = useState(false); const [streaming, setStreaming] = useState(false); const [banner, setBanner] = useState(null); /** The in-flight assistant bubble (empty = none). */ const [live, setLive] = useState(null); /** The optimistic user bubble for the in-flight turn. */ const [pendingUser, setPendingUser] = useState<{ id: string; content: string } | null>(null); /** * Synchronous source of truth for the in-flight turn. `setLive` is only the * render trigger; every transition is computed from `liveRef` and the ref * is updated in the SAME synchronous block (before the React state commit). * * Why a ref instead of reading the `live` state: the finalize runs right * after `sendAiMessage` resolves — before React has re-rendered, so the * `live` closure is stale. And the ref must NOT be updated inside a * `setLive` *updater*: React runs updaters during render (asynchronously, * twice under StrictMode), so a finalize that reads a ref written in an * updater would see the pre-`done` value. That bug made Retry send the * optimistic `tmpId("u")` as `retryOf` (server 404: "Message not found") * because the server's real `userMessageId` (from the `done` frame) had * never landed in the ref. */ const liveRef = useRef(null); const abortRef = useRef(null); /** The last turn's text (+ its server row id once known) — the Retry * button re-sends THIS, whether or not the user row was persisted * (a pre-stream 429 on a fresh conversation has no row to retryOf). */ const lastSentRef = useRef<{ content: string; userMessageId?: string } | null>(null); // Refs so `send`/`retry` (stable callbacks) see fresh context without // re-creating on every keystroke of an unrelated input. const contextRef = useRef(context); contextRef.current = context; /* --------------------------------------------------------- conv list */ const refreshConversations = useCallback(async () => { if (!workspaceId) return; try { const list = await aiApi.listConversations(workspaceId); setConversations(list); setConvError(null); } catch { setConvError("Could not load conversations."); } }, [workspaceId]); useEffect(() => { setConversations([]); setActiveId(null); setMessages([]); setBanner(null); if (workspaceId) void refreshConversations(); }, [workspaceId, refreshConversations]); /* -------------------------------------------------------- active conv */ const openConversation = useCallback( async (id: string) => { if (streaming) stop(); setActiveId(id); setMessages([]); setBanner(null); setMessagesLoading(true); try { const rows = await aiApi.listMessages(id); setMessages(rows); } catch { setBanner({ code: "ERROR", message: "Could not load this conversation.", retryable: false }); } finally { setMessagesLoading(false); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [streaming] ); const startNew = useCallback(() => { if (streaming) stop(); setActiveId(null); setMessages([]); setBanner(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [streaming]); const removeConversation = useCallback( async (id: string) => { try { await aiApi.deleteConversation(id); } catch { /* the row simply stays if the delete failed — non-fatal */ } setConversations((list) => list.filter((c) => c.id !== id)); if (activeId === id) startNew(); }, [activeId, startNew] ); /** Inline rename (Enter/blur) — the server PATCHes the title. */ const renameConversation = useCallback( async (id: string, title: string) => { const t = title.trim(); if (!t) return; try { const updated = await aiApi.renameConversation(id, t); setConversations((list) => list.map((c) => (c.id === id ? { ...c, title: updated.title ?? t } : c))); } catch { /* keep the old title; the row is untouched */ } }, [] ); /* -------------------------------------------------------------- send */ const applyFrame = useCallback((frame: AiSseFrame) => { // Transition from the ref (the synchronous truth) and commit BOTH the ref // and the render state in the same synchronous block. `setLive(next)` is // a plain value (not an updater), so StrictMode's double-invocation does // not apply and there is no async-timing hazard. const t = liveRef.current; if (t) { let next = t; if (frame.type === "delta") next = { ...t, content: t.content + frame.text }; else if (frame.type === "tool_call") next = { ...t, toolCalls: [...t.toolCalls, { id: frame.id, name: frame.name, arguments: frame.arguments }] }; else if (frame.type === "usage") next = { ...t, usage: { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut } }; else if (frame.type === "done") next = { ...t, done: true, assistantMessageId: frame.assistantMessageId, userMessageId: frame.userMessageId ?? t.userMessageId, truncated: frame.reason === "aborted", ...(frame.reason === "aborted" ? { error: "aborted" } : {}) }; if (next !== t) { liveRef.current = next; setLive(next); } } if (frame.type === "error") { setBanner({ code: frame.code, message: frame.message, retryable: frame.retryable === true, ...(typeof frame.retryAfterSeconds === "number" ? { retryAfterSeconds: frame.retryAfterSeconds } : {}) }); } }, []); /** Ensure the turn has a conversation (create on the fly, seeded context). */ const ensureConversation = useCallback(async (): Promise => { if (activeId) return activeId; if (!workspaceId) return null; const created = await aiApi.createConversation(workspaceId, { ...(contextRef.current && Object.keys(contextRef.current).length > 0 ? { context: contextRef.current } : {}) }); setActiveId(created.id); setConversations((list) => [created, ...list]); return created.id; }, [activeId, workspaceId]); const send = useCallback( async (content: string) => { const text = content.trim(); if (!text || streaming || !workspaceId) return; setBanner(null); const convId = await ensureConversation(); if (!convId) return; const userRow = { id: tmpId("u"), content: text }; lastSentRef.current = { content: text }; setPendingUser(userRow); liveRef.current = { userMessageId: userRow.id, content: "", toolCalls: [] }; setLive(liveRef.current); setStreaming(true); const ac = new AbortController(); abortRef.current = ac; try { const reason = await sendAiMessage( { workspaceId, conversationId: convId, content: text, context: contextRef.current ?? undefined, signal: ac.signal }, applyFrame ); // Finalize: `liveRef` is the synchronous truth (every transition is // computed from the ref in `applyFrame`, so it already carries the // server's real `userMessageId` from the `done` frame). Reading it // here — not the stale `live` closure, and not a ref written inside a // `setLive` updater (React runs updaters asynchronously during // render) — is what lets Retry send the REAL row id as `retryOf` // instead of the optimistic tmpId (which 404s server-side). const t = liveRef.current; if (t) { if (t.userMessageId) lastSentRef.current = { content: text, userMessageId: t.userMessageId }; setMessages((rows) => [ ...rows, { id: t.userMessageId ?? userRow.id, role: "user", content: text, createdAt: new Date().toISOString() }, ...(t.content ? [ { id: t.assistantMessageId ?? tmpId("a"), role: "assistant" as const, content: t.content, model: null, tokensIn: t.usage?.tokensIn ?? null, tokensOut: t.usage?.tokensOut ?? null, createdAt: new Date().toISOString(), metadata: { feature: "chat", ...(t.truncated ? { truncated: true } : {}), ...(t.error ? { error: t.error } : {}) } } ] : []) ]); } liveRef.current = null; setLive(null); void reason; } catch (err) { if (err instanceof AiPreStreamError) { setBanner({ code: err.code, message: err.message, retryable: err.code === "RATE_LIMITED" || err.status >= 500, ...(err.retryAfterSeconds !== undefined ? { retryAfterSeconds: err.retryAfterSeconds } : {}) }); // pre-stream: the user row was NOT persisted — re-queue the text // on Retry (no retryOf — the server has no row to re-send). } else if (!(err instanceof Error && err.name === "AbortError")) { setBanner({ code: "ERROR", message: "Network error — the AI reply could not be completed.", retryable: true }); } } finally { setPendingUser(null); setStreaming(false); abortRef.current = null; void refreshConversations(); // auto-title / message counts changed } }, [workspaceId, streaming, ensureConversation, applyFrame, refreshConversations] ); const stop = useCallback(() => { abortRef.current?.abort(); }, []); /** * Re-send the last turn after an error (the Retry button). * * `lastSentRef` carries the turn's text + the persisted user-row id (set * when the `done` frame arrived). When the row exists (a mid-stream error) * we send `retryOf` so the server re-runs WITHOUT duplicating the user * message; when it does not (a pre-stream 402/429 on a fresh conversation) * we send a plain new message — same text, new row. */ const retry = useCallback(async () => { if (streaming || !workspaceId || !activeId) return; const last = lastSentRef.current; if (!last) return; setBanner(null); liveRef.current = { userMessageId: last.userMessageId, content: "", toolCalls: [] }; setLive(liveRef.current); setStreaming(true); const ac = new AbortController(); abortRef.current = ac; try { await sendAiMessage( { workspaceId, conversationId: activeId, content: last.content, context: contextRef.current ?? undefined, ...(last.userMessageId ? { retryOf: last.userMessageId } : {}), signal: ac.signal }, applyFrame ); // Finalize outside the updater (same StrictMode reason as send). The // user row already lives in `messages` (the original send added it, or // it came from the server) — retry appends ONLY the new assistant row; // with `retryOf` the server did not create a second user row either. const t = liveRef.current; if (t && t.content && !ac.signal.aborted) { if (t.userMessageId) lastSentRef.current = { content: last.content, userMessageId: t.userMessageId }; setMessages((rows) => [ ...rows, { id: t.assistantMessageId ?? tmpId("a"), role: "assistant", content: t.content, model: null, tokensIn: t.usage?.tokensIn ?? null, tokensOut: t.usage?.tokensOut ?? null, createdAt: new Date().toISOString(), metadata: { feature: "chat", ...(t.truncated ? { truncated: true } : {}) } } ]); } liveRef.current = null; setLive(null); } catch (err) { if (err instanceof AiPreStreamError) { setBanner({ code: err.code, message: err.message, retryable: err.code === "RATE_LIMITED" || err.status >= 500, ...(err.retryAfterSeconds !== undefined ? { retryAfterSeconds: err.retryAfterSeconds } : {}) }); } else if (!(err instanceof Error && err.name === "AbortError")) { setBanner({ code: "ERROR", message: "Network error — the AI reply could not be completed.", retryable: true }); } } finally { setStreaming(false); abortRef.current = null; void refreshConversations(); } }, [streaming, workspaceId, activeId, applyFrame, refreshConversations]); // Aborting on unmount (panel close) → the server persists the partial. useEffect(() => () => abortRef.current?.abort(), []); return { conversations, convError, activeId, messages, messagesLoading, streaming, banner, live, pendingUser, send, stop, retry, openConversation, startNew, removeConversation, renameConversation, refreshConversations }; }