/** * 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 { 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([]); const [currentSession, setCurrentSession] = useState(sessionId); const [draft, setDraft] = useState(""); const [attachment, setAttachment] = useState(null); const [opening, setOpening] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); const sessionRef = useRef(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 (
{agentName}
CLI custom · {profile.name}
{(opening || busy) && ( )}
{!supported && (

CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.

)} {error && (

{error}

)}
{opening && turns.length === 0 ? (
Ouverture de la session structurée…
) : turns.length === 0 ? (

Envoyez un message pour démarrer la conversation structurée.

) : ( turns.map((turn, index) => ) )}
{attachment && (
Fichier joint: {attachment}
)}