Introduit un variant terminal `ReplyEvent::Error { message }` (domain/ports)
propagé jusqu'au chat : l'adapter OpenAI-compat récupère `reasoning_content`
quand `content` est vide, et un `Final` vide est converti en `Error` visible
plutôt qu'un tour silencieux qui pend. Le front (`ReplyChunk` + useTicketAssistant)
rend ce message d'erreur dans la cellule.
- domain: variant `ReplyEvent::Error`, bras non terminal (readiness, structured drain)
- infra/session: parse `reasoning_content` (delta/completion), fallback visible
- app-tauri: mapping ReplyEvent::Error -> ReplyChunk::Error, Final vide -> Error
- frontend: ReplyChunk `error`, rendu dans useTicketAssistant (+ test .test.tsx)
NB: commit sur la feature branch, PAS un merge. #60 reste inProgress.
Les 10 tests `cargo test -p infrastructure --lib session` échouent SOUS SANDBOX
uniquement (bind loopback 127.0.0.1:0 interdit -> Os PermissionDenied), pas une
régression : revérification hors-sandbox requise avant tout merge vers develop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
/** Closes/disposes the session and clears the transcript. */
|
|
close: () => Promise<void>;
|
|
/** Sends a message and streams the assistant reply into the transcript. */
|
|
send: (message: string) => Promise<void>;
|
|
}
|
|
|
|
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<AgentProfile[]>([]);
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [turns, setTurns] = useState<AssistantTurn[]>([]);
|
|
const [opening, setOpening] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<string | null>(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,
|
|
};
|
|
}
|