/** * View-model hook for the ticket AI assistant chat (ticket #8, F2). * * Owns a ticket-scoped assistant session: it lists the AI profiles to drive the * assistant, opens/closes the session via the {@link TicketGateway}, sends * messages and folds the streamed {@link ReplyChunk}s into a user↔assistant * transcript. The session is disposed on unmount (leaving the ticket) so no * assistant outlives its editor. * * The displayed ticket refreshes on assistant edits automatically: those edits * land as `Issue*` domain events which {@link useTicketDetail} already listens * to — this hook does not need to wire that. */ import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain"; import { useGateways } from "@/app/di"; /** One rendered turn of the assistant transcript. */ export interface AssistantTurn { role: "user" | "assistant"; text: string; /** True while an assistant turn is still streaming (no `final` yet). */ pending?: boolean; } export interface TicketAssistantViewModel { /** AI profiles available to drive the assistant. */ profiles: AgentProfile[]; /** Live session id once opened, else `null`. */ sessionId: string | null; /** The user↔assistant transcript, oldest first. */ turns: AssistantTurn[]; /** True while opening the session. */ opening: boolean; /** True while an assistant turn is streaming. */ busy: boolean; error: string | null; /** Opens an assistant session bound to this ticket, driven by `profileId`. */ open: (profileId: string) => Promise; /** Closes/disposes the session and clears the transcript. */ close: () => Promise; /** Sends a message and streams the assistant reply into the transcript. */ send: (message: string) => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export function useTicketAssistant( projectId: string, issueRef: string, ): TicketAssistantViewModel { const { ticket, profile } = useGateways(); const [profiles, setProfiles] = useState([]); const [sessionId, setSessionId] = useState(null); const [turns, setTurns] = useState([]); const [opening, setOpening] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Mirror of `sessionId` for the unmount cleanup (which must not re-run when the // id changes — only dispose whatever is live at teardown). const sessionRef = useRef(null); sessionRef.current = sessionId; useEffect(() => { let cancelled = false; void (async () => { try { const list = (await profile?.listProfiles()) ?? []; if (!cancelled) setProfiles(list); } catch (e) { if (!cancelled) setError(describe(e)); } })(); return () => { cancelled = true; }; }, [profile]); const open = useCallback( async (profileId: string) => { if (!profileId) return; setOpening(true); setError(null); try { const chat = await ticket.openTicketChat(projectId, issueRef, profileId); setSessionId(chat.sessionId); setTurns([]); } catch (e) { setError(describe(e)); } finally { setOpening(false); } }, [ticket, projectId, issueRef], ); const close = useCallback(async () => { if (!sessionRef.current) return; setSessionId(null); setTurns([]); try { await ticket.closeTicketChat(projectId, issueRef); } catch (e) { setError(describe(e)); } }, [ticket, projectId, issueRef]); const send = useCallback( async (message: string) => { const text = message.trim(); if (!sessionId || !text || busy) return; setError(null); setBusy(true); // Append the user turn and an empty, pending assistant turn to stream into. setTurns((prev) => [ ...prev, { role: "user", text }, { role: "assistant", text: "", pending: true }, ]); const applyChunk = (chunk: ReplyChunk) => { setTurns((prev) => { const next = [...prev]; const i = next.length - 1; if (i < 0 || next[i].role !== "assistant") return prev; if (chunk.kind === "textDelta") { next[i] = { ...next[i], text: next[i].text + chunk.text }; } else if (chunk.kind === "toolActivity") { // Best-effort inline activity badge. next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` }; } else if (chunk.kind === "error") { // Terminal fallback (ticket #60): the model returned no usable answer. // Freeze the turn with the readable explanation so the bubble is never // left empty and pending — the assistant no longer hangs silently. next[i] = { role: "assistant", text: `⚠️ ${chunk.message}`, pending: false, }; } else { // `final`: freeze the turn with the aggregated content. next[i] = { role: "assistant", text: chunk.content, pending: false }; } return next; }); // `busy` tracks the REAL end-of-turn: a `final` or an `error` terminal — // never merely `agent_send` returning (the stream is still flowing then, // ticket #60). The input stays disabled until one of them arrives. if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); }; try { await ticket.sendTicketChat(sessionId, text, applyChunk); } catch (e) { // Transport failure: no terminal chunk will arrive, so close the pending // turn and drop `busy` here to guarantee the assistant never hangs. setError(describe(e)); setTurns((prev) => { const next = [...prev]; const i = next.length - 1; if (i >= 0 && next[i].role === "assistant" && next[i].pending) { next[i] = { ...next[i], pending: false }; } return next; }); setBusy(false); } }, [ticket, sessionId, busy], ); // Dispose the session when leaving the ticket (unmount). Fire-and-forget. useEffect(() => { return () => { if (sessionRef.current) { void ticket.closeTicketChat(projectId, issueRef).catch(() => {}); } }; }, [ticket, projectId, issueRef]); return { profiles, sessionId, turns, opening, busy, error, open, close, send, }; }