Files
IdeA/frontend/src/features/agents/CustomAgentChatView.tsx
Blomios dcba76b871 feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:59:39 +02:00

389 lines
12 KiB
TypeScript

/**
* Custom agent CLI for structured/headless profiles (#147).
*
* This is an alternative human view for an agent cell, not a replacement for
* the native TUI. It uses the structured chat commands when available and
* deliberately does not try to parse PTY bytes.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared";
export interface CustomAgentChatViewProps {
projectId: string;
agentId: string;
agentName: string;
profile: AgentProfile;
cwd: string;
nodeId: string;
sessionId: string | null;
conversationId: string | null;
onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void;
}
type ChatTurn =
| { role: "user"; text: string; attachment?: string }
| { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string }
| { role: "final"; text: string }
| { role: "error"; text: string }
| { role: "unknown"; text: string };
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function unknownChunkLabel(chunk: unknown): string {
try {
return JSON.stringify(chunk);
} catch {
return String(chunk);
}
}
function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
}
function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") {
next[next.length - 1] = {
role: "agent",
text: last.text + text,
pending: true,
};
return next;
}
next.push({ role: "agent", text, pending: true });
return next;
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
}
switch (raw.kind) {
case "textDelta":
return appendAgentDelta(turns, String(raw.text ?? ""));
case "toolActivity":
return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }];
case "final": {
const content = String(raw.content ?? "");
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "final", text: content });
return next;
}
case "error": {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
return next;
}
case "userPrompt":
case "UserPrompt":
return [...turns, { role: "user", text: String(raw.text ?? raw.prompt ?? "") }];
default:
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
}
}
export function CustomAgentChatView({
projectId,
agentId,
agentName,
profile,
cwd,
nodeId,
sessionId,
conversationId,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, system } = useGateways();
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [currentSession, setCurrentSession] = useState(sessionId);
const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const onConversationIdRef = useRef(onConversationId);
onConversationIdRef.current = onConversationId;
const supported = Boolean(
profile.structuredAdapter &&
agent.launchAgentChat &&
agent.reattachAgentChat &&
agent.sendAgentChat &&
agent.cancelAgentChat &&
agent.closeAgentChat,
);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns]);
useEffect(() => {
if (!supported) return;
let cancelled = false;
const receive = (chunk: ReplyChunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
};
async function openOrAttach() {
setOpening(true);
setError(null);
try {
if (sessionId) {
const reattached = await agent.reattachAgentChat!(sessionId, receive);
if (cancelled) return;
setCurrentSession(reattached.sessionId);
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
return;
}
const launched = await agent.launchAgentChat!(projectId, agentId, {
cwd,
rows: 24,
cols: 80,
conversationId: conversationId ?? undefined,
nodeId,
});
if (cancelled) return;
setCurrentSession(launched.sessionId);
onSessionIdRef.current(launched.sessionId);
if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId);
}
// Attach the view so any in-flight chunks can be replayed after launch.
await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {});
} catch (e) {
if (!cancelled) setError(describe(e));
} finally {
if (!cancelled) setOpening(false);
}
}
void openOrAttach();
return () => {
cancelled = true;
};
}, [
supported,
agent,
projectId,
agentId,
cwd,
nodeId,
sessionId,
conversationId,
]);
const canSend = useMemo(
() =>
supported &&
Boolean(currentSession) &&
Boolean(draft.trim()) &&
!busy &&
!opening,
[supported, currentSession, draft, busy, opening],
);
async function pickAttachment() {
const path = await system.pickFile();
if (path) setAttachment(path);
}
async function send() {
const text = draft.trim();
if (!canSend || !currentSession || !agent.sendAgentChat) return;
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
setDraft("");
setAttachment(null);
setBusy(true);
setError(null);
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
try {
await agent.sendAgentChat(currentSession, prompt, (chunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
});
} catch (e) {
setBusy(false);
setError(describe(e));
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
}
}
async function cancel() {
const sid = sessionRef.current;
if (!sid || !agent.cancelAgentChat) return;
setBusy(false);
setOpening(false);
setError(null);
try {
await agent.cancelAgentChat(sid);
setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]);
} catch (e) {
setError(describe(e));
}
}
return (
<div
data-testid="custom-agent-chat-view"
className="flex h-full min-h-0 flex-col bg-surface text-content"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{agentName}</div>
<div className="truncate text-xs text-muted">
CLI custom · {profile.name}
</div>
</div>
{(opening || busy) && (
<Button size="sm" variant="danger" onClick={() => void cancel()}>
Cancel
</Button>
)}
</div>
{!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
</p>
)}
{error && (
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
{error}
</p>
)}
<div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-3 py-3">
{opening && turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Ouverture de la session structurée</span>
</div>
) : turns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Envoyez un message pour démarrer la conversation structurée.
</p>
) : (
turns.map((turn, index) => <ChatBubble key={index} turn={turn} />)
)}
</div>
<div className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2">
{attachment && (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
<span className="truncate">Fichier joint: {attachment}</span>
<button type="button" className="text-content" onClick={() => setAttachment(null)}>
Retirer
</button>
</div>
)}
<div className="flex items-end gap-2">
<textarea
aria-label={`message CLI custom ${nodeId}`}
className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
value={draft}
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button size="sm" variant="ghost" disabled={!supported || opening || busy} onClick={() => void pickAttachment()}>
Joindre
</Button>
<Button size="sm" disabled={!canSend} loading={busy} onClick={() => void send()}>
Envoyer
</Button>
</div>
</div>
</div>
);
}
function ChatBubble({ turn }: { turn: ChatTurn }) {
if (turn.role === "tool") {
return (
<div className="flex justify-center">
<span className="max-w-[80%] rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
{turn.label}
</span>
</div>
);
}
if (turn.role === "final") {
return (
<div className="flex justify-end">
<div className="max-w-[86%] rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<div className="mb-1 text-xs font-semibold text-success">Task Complete</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
);
}
if (turn.role === "error" || turn.role === "unknown") {
return (
<div className="flex justify-center">
<div className="max-w-[86%] rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
</div>
</div>
);
}
const user = turn.role === "user";
return (
<div className={cn("flex", user ? "justify-start" : "justify-end")}>
<div
className={cn(
"max-w-[82%] rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
)}
>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachment && (
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
Réponse en cours
</span>
)}
</div>
</div>
);
}