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>
This commit is contained in:
178
frontend/src/features/tickets/TicketAssistantPanel.tsx
Normal file
178
frontend/src/features/tickets/TicketAssistantPanel.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Ticket AI assistant chat panel (ticket #8, F2).
|
||||
*
|
||||
* Consumes {@link useTicketAssistant} to drive a ticket-scoped assistant: pick
|
||||
* an AI profile, open a read-only session bound to this ticket, then converse.
|
||||
* The assistant can only read files and edit *this* ticket — its edits refresh
|
||||
* the ticket automatically (see {@link useTicketDetail}). Purely presentational:
|
||||
* all session/streaming logic lives in the hook.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button, Spinner, cn } from "@/shared";
|
||||
import { useTicketAssistant } from "./useTicketAssistant";
|
||||
|
||||
const selectClass = cn(
|
||||
"h-9 rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
export interface TicketAssistantPanelProps {
|
||||
projectId: string;
|
||||
ticketRef: string;
|
||||
}
|
||||
|
||||
export function TicketAssistantPanel({
|
||||
projectId,
|
||||
ticketRef,
|
||||
}: TicketAssistantPanelProps) {
|
||||
const vm = useTicketAssistant(projectId, ticketRef);
|
||||
const [profileId, setProfileId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// Auto-scroll the transcript to the newest content as it streams in.
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [vm.turns]);
|
||||
|
||||
const open = vm.sessionId !== null;
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (!text || vm.busy) return;
|
||||
void vm.send(text);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!open ? (
|
||||
// ── Session opener ──
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="profil de l'assistant"
|
||||
className={selectClass}
|
||||
value={profileId}
|
||||
disabled={vm.opening || vm.profiles.length === 0}
|
||||
onChange={(e) => setProfileId(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{vm.profiles.length === 0
|
||||
? "Aucun profil disponible"
|
||||
: "Choisir un profil IA…"}
|
||||
</option>
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!profileId || vm.opening}
|
||||
loading={vm.opening}
|
||||
onClick={() => void vm.open(profileId)}
|
||||
>
|
||||
Ouvrir la conversation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// ── Live conversation ──
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted">
|
||||
Conversation ouverte — l'assistant peut lire le projet et éditer ce
|
||||
ticket.
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.close()}
|
||||
>
|
||||
Fermer la conversation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex max-h-96 min-h-32 flex-col gap-2 overflow-auto rounded-md border border-border bg-raised/40 p-3"
|
||||
>
|
||||
{vm.turns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Démarrez la conversation en décrivant ce que l'assistant doit
|
||||
faire sur ce ticket.
|
||||
</p>
|
||||
) : (
|
||||
vm.turns.map((turn, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex",
|
||||
turn.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm",
|
||||
turn.role === "user"
|
||||
? "bg-primary/15 text-content"
|
||||
: "bg-surface text-content",
|
||||
)}
|
||||
>
|
||||
{turn.text}
|
||||
{turn.pending && (
|
||||
<span className="ml-1 inline-flex items-center align-middle text-muted">
|
||||
<Spinner size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label="message à l'assistant"
|
||||
className={cn(
|
||||
"min-h-10 flex-1 resize-none rounded-md bg-raised p-2 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
rows={2}
|
||||
placeholder="Votre message…"
|
||||
value={draft}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!draft.trim() || vm.busy}
|
||||
loading={vm.busy}
|
||||
onClick={submit}
|
||||
>
|
||||
Envoyer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vm.error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,7 @@ import type { TicketLinkKind } from "@/domain";
|
||||
import { Button, Input, Spinner, cn } from "@/shared";
|
||||
import { useTicketDetail } from "./useTicketDetail";
|
||||
import { useProjectAgents } from "./useProjectAgents";
|
||||
import { TicketAssistantPanel } from "./TicketAssistantPanel";
|
||||
import {
|
||||
PriorityBadge,
|
||||
StatusBadge,
|
||||
@ -498,6 +499,14 @@ export function TicketDetail({
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── AI assistant chat (#8, F2) ── */}
|
||||
<Section
|
||||
title="Assistant IA"
|
||||
hint="Conversez avec un agent IA pour faire évoluer ce ticket. L'agent lit le projet en lecture seule et ne peut éditer que ce ticket."
|
||||
>
|
||||
<TicketAssistantPanel projectId={projectId} ticketRef={ticketRef} />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} from "@/adapters/mock";
|
||||
@ -43,6 +44,18 @@ function renderView(
|
||||
);
|
||||
}
|
||||
|
||||
async function seedAssistantProfile(profile: MockProfileGateway) {
|
||||
return profile.saveProfile({
|
||||
id: "qa-assistant",
|
||||
name: "QA Assistant",
|
||||
command: "codex",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
});
|
||||
}
|
||||
|
||||
describe("MockTicketGateway", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
@ -427,6 +440,66 @@ describe("TicketsView", () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens, streams, and closes the ticket AI assistant chat (#8)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Assistant target" });
|
||||
const profile = new MockProfileGateway();
|
||||
await seedAssistantProfile(profile);
|
||||
const gateways = { ticket, system, agent, profile } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketDetail
|
||||
projectId={PROJECT_ID}
|
||||
ticketRef={t.ref}
|
||||
onClose={() => {}}
|
||||
onOpenRef={() => {}}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
||||
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
|
||||
|
||||
const profileSelect = await within(detail).findByLabelText(
|
||||
"profil de l'assistant",
|
||||
);
|
||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
|
||||
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
|
||||
fireEvent.click(within(detail).getByText("Ouvrir la conversation"));
|
||||
|
||||
expect(
|
||||
await within(detail).findByLabelText("message à l'assistant"),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), {
|
||||
target: { value: "Crée un plan de correction" },
|
||||
});
|
||||
fireEvent.click(within(detail).getByText("Envoyer"));
|
||||
|
||||
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
|
||||
expect(
|
||||
await within(detail).findByText(
|
||||
"Assistant: reçu « Crée un plan de correction ».",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(detail).getByText("Fermer la conversation"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(detail).queryByLabelText("message à l'assistant"),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
|
||||
expect(
|
||||
within(detail).queryByText("Crée un plan de correction"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SprintManager (#11)", () => {
|
||||
|
||||
177
frontend/src/features/tickets/useTicketAssistant.ts
Normal file
177
frontend/src/features/tickets/useTicketAssistant.ts
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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 {
|
||||
// `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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user