Files
IdeA/frontend/src/features/tickets/useTicketAssistant.ts
Blomios 1fdf62c089 feat(tickets): création d'un assistant IA de ticket (backend + frontend)
Ajoute le chat assistant IA attaché à un ticket (#8).

Backend Rust :
- use cases OpenTicketAssistant/CloseTicketAssistant + tests
- politique d'outils par agent (domain/agent_tool_policy) et policy MCP
- store de contexte assistant + gabarit default_ticket_assistant.md + tests
- events TicketAssistantOpened/Closed
- commandes Tauri open_ticket_chat/close_ticket_chat et câblage state/lib/events

Frontend :
- gateway (ports, adapters ticket + mock, domain)
- hook useTicketAssistant + composant TicketAssistantPanel
- intégration dans TicketDetail

Tests verts : vitest tickets.test.tsx (23), cargo test application::ticket_assistant (1),
infrastructure::assistant_context_store (2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:06:24 +02:00

178 lines
5.5 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}\${chunk.label}` };
} else {
// `final`: freeze the turn with the aggregated content.
next[i] = { role: "assistant", text: chunk.content, pending: false };
}
return next;
});
if (chunk.kind === "final") setBusy(false);
};
try {
await ticket.sendTicketChat(sessionId, text, applyChunk);
} catch (e) {
setError(describe(e));
} finally {
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,
};
}