/** * 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 { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, } from "react"; import type { AgentProfile, GatewayError, ReplyChunk, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { Button, Spinner, cn } from "@/shared"; import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; export interface CustomAgentChatViewProps { projectId: string; agentId: string; agentName: string; profile: AgentProfile; cwd: string; layoutId?: 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; attachments?: string[] } | { role: "agent"; text: string; pending?: boolean } | { role: "tool"; label: string } | { role: "progress"; progress: ReplyProgress; settled?: boolean } | { role: "final"; text: string } | { role: "error"; text: string } | { role: "unknown"; text: string }; interface CachedChatView { turns: ChatTurn[]; chunkCount: number; } const chatSessionViewCache = new Map(); export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); } interface AttachmentDraft { id: string; label: string; input: ChatAttachmentInput; previewUrl?: string; dedupeKey?: string; } interface ProfileCommandDialogState { profiles: AgentProfile[]; selectedProfileId: string; confirmedReset: boolean; busy: boolean; error: string | null; } interface SlashCommandInvocation { name: string; arguments: unknown[]; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } function instrumentationError(e: unknown): Record { if (e && typeof e === "object") { const err = e as GatewayError & { name?: string }; return { code: err.code, message: err.message, name: err.name, raw: e, }; } return { message: String(e), raw: e }; } function isNotFound(e: unknown): boolean { if (e && typeof e === "object" && (e as GatewayError).code === "NOT_FOUND") { return true; } const message = typeof e === "string" ? e : e && typeof e === "object" && "message" in e ? String((e as GatewayError).message) : ""; return /^not found: structured session\b/i.test(message); } 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 attachmentLabels(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim() !== "") : []; } function appendUserPrompt( turns: ChatTurn[], text: string, attachments: string[] = [], ): ChatTurn[] { const last = turns[turns.length - 1]; if ( last?.role === "user" && last.text === text && JSON.stringify(last.attachments ?? []) === JSON.stringify(attachments) ) { return turns; } return [ ...turns, { role: "user", text, ...(attachments.length > 0 ? { attachments } : {}), }, ]; } function isReplyProgressSource(value: unknown): value is ReplyProgressSource { return value === "providerNative" || value === "ideaLocal"; } function isReplyProgressKind(value: unknown): value is ReplyProgressKind { return ( value === "turn" || value === "message" || value === "tool" || value === "mcp" || value === "other" ); } function isReplyProgressStage(value: unknown): value is ReplyProgressStage { return ( value === "started" || value === "delta" || value === "completed" || value === "info" ); } function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } function normalizeProgress(raw: unknown): ReplyProgress | null { if (!raw || typeof raw !== "object") return null; const record = raw as Record; const label = optionalString(record.label); if (!label) return null; return { source: isReplyProgressSource(record.source) ? record.source : "providerNative", kind: isReplyProgressKind(record.kind) ? record.kind : "other", stage: isReplyProgressStage(record.stage) ? record.stage : "info", label, text: optionalString(record.text), provider: optionalString(record.provider), nativeEvent: optionalString(record.nativeEvent), toolName: optionalString(record.toolName), }; } function progressKey(progress: ReplyProgress): string { return [ progress.source, progress.kind, progress.stage, progress.toolName ?? "", progress.label, progress.text ?? "", ].join("\u0000"); } function normalizedProgressToolName(progress: ReplyProgress): string | null { const name = progress.toolName ?? progress.label; const normalized = name.trim().toLowerCase(); return normalized || null; } function isDelegationProgress(progress: ReplyProgress): boolean { const name = normalizedProgressToolName(progress); return name === "idea_ask_agent" || name === "idea_ask_agents"; } function progressEntityKey(progress: ReplyProgress): string | null { const name = normalizedProgressToolName(progress); if (!name) return null; if (isDelegationProgress(progress)) return `delegation:${name}`; if (progress.kind === "tool" || progress.kind === "mcp") return `tool:${name}`; return null; } function progressStageBucket(progress: ReplyProgress): string { if (!isCenteredToolProgress(progress)) return progress.stage; if (progress.stage === "started" || progress.stage === "delta") return "started"; if (progress.stage === "completed") return "completed"; return "info"; } function progressDisplayKey(progress: ReplyProgress): string { const entity = progressEntityKey(progress); if (entity) return `${entity}\u0000${progressStageBucket(progress)}`; return progressKey(progress); } function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] { const entity = progressEntityKey(progress); if (entity && progress.stage === "completed") { let runningIndex = -1; for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if ( turn.role === "progress" && progressEntityKey(turn.progress) === entity && (turn.progress.stage === "started" || turn.progress.stage === "delta") ) { runningIndex = index; break; } } if (runningIndex >= 0) { const next = [...turns]; const current = next[runningIndex]; if (current.role === "progress") { next[runningIndex] = { role: "progress", progress: mergeCompletedProgress(current.progress, progress), }; } return next; } } const key = progressDisplayKey(progress); if ( turns.some( (turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, ) ) { return turns; } return [...turns, { role: "progress", progress }]; } function reattachNotice(missedChunks: number): ChatTurn | null { if (missedChunks <= 0) return null; return { role: "tool", label: missedChunks === 1 ? "Reprise effectuée: 1 événement rattrapé pendant l'absence." : `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`, }; } function mergeCompletedProgress( running: ReplyProgress, completed: ReplyProgress, ): ReplyProgress { if (!isDelegationProgress(running) || !completed.text) return completed; if (delegationSummary(completed).target) return completed; return { ...completed, text: running.text, }; } function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] { const completedEntities = new Set( turns .filter( (turn): turn is Extract => turn.role === "progress" && progressStageBucket(turn.progress) === "completed" && Boolean(progressEntityKey(turn.progress)), ) .map((turn) => progressEntityKey(turn.progress)), ); return turns.map((turn) => { if ( turn.role !== "progress" || turn.progress.kind === "turn" || (turn.progress.stage !== "started" && turn.progress.stage !== "delta") ) { return turn; } const entity = progressEntityKey(turn.progress); if (entity && completedEntities.has(entity)) { return { ...turn, settled: true, progress: { ...turn.progress, stage: "completed", }, }; } return { ...turn, progress: { ...turn.progress, stage: "completed", }, }; }); } function fileExtension(mime: string): string { if (mime === "image/png") return "png"; if (mime === "image/jpeg") return "jpg"; if (mime === "image/gif") return "gif"; if (mime === "image/webp") return "webp"; return "img"; } function pathBasename(path: string): string { return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path; } function bytesToBase64(bytes: Uint8Array): string { let binary = ""; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); binary += String.fromCharCode(...chunk); } return btoa(binary); } async function clipboardFileToAttachment(file: File): Promise { const mime = file.type || "application/octet-stream"; const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())); const filename = file.name || (mime.startsWith("image/") ? `clipboard-image-${Date.now()}.${fileExtension(mime)}` : `clipboard-file-${Date.now()}`); return { id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`, label: filename, previewUrl: mime.startsWith("image/") ? `data:${mime};base64,${contentBase64}` : undefined, dedupeKey: file.name ? fileMetadataKey(file) : `${mime}\u0000${file.size}\u0000${contentBase64}`, input: { filename, contentBase64, mime, sourceKind: "clipboard", }, }; } function fileMetadataKey(file: File): string { return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`; } function dataTransferFiles(clipboardData: DataTransfer): File[] { const clipboardItems = clipboardData.items ? Array.from(clipboardData.items) : []; const clipboardFiles = clipboardData.files ? Array.from(clipboardData.files) : []; const fromItems = clipboardItems .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); const seen = new Set(); return [...fromItems, ...clipboardFiles].filter((file) => { const key = fileMetadataKey(file); if (seen.has(key)) return false; seen.add(key); return true; }); } async function asyncClipboardImageFiles(): Promise { if ( typeof navigator === "undefined" || typeof navigator.clipboard?.read !== "function" ) { return []; } const items = await navigator.clipboard.read(); const files: File[] = []; for (const item of items) { for (const type of item.types) { if (!type.startsWith("image/")) continue; const blob = await item.getType(type); files.push(new File([blob], "", { type, lastModified: 0 })); } } return files; } function slashCommandPrefix(draft: string): string | null { if (!draft.startsWith("/")) return null; if (/\s/.test(draft)) return null; return draft; } function slashCommandInvocation(text: string): SlashCommandInvocation | null { if (!text.startsWith("/")) return null; const [name, ...argumentParts] = text.split(/\s+/); if (!name) return null; const rawArguments = argumentParts.join(" ").trim(); return { name, arguments: rawArguments ? [rawArguments] : [], }; } function isSlashCommandAvailable(command: SlashCommand): boolean { return command.availability.status === "available"; } function formatSlashCommandHelp(commands: SlashCommand[]): string { return commands .map((command) => `${command.name} - ${command.shortDescription}`) .join("\n"); } function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { if (!isReplyRecord(raw)) { return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; } const last = turns[turns.length - 1]; if ( (last?.role === "final" || last?.role === "error") && raw.kind !== "userPrompt" && raw.kind !== "UserPrompt" ) { return turns; } switch (raw.kind) { case "textDelta": return appendAgentDelta(turns, String(raw.text ?? "")); case "toolActivity": return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }]; case "progress": { const progress = normalizeProgress(raw.progress); if (!progress) return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; return appendProgress(turns, progress); } case "final": { const content = String(raw.content ?? ""); const next = completeRunningProgress(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 = completeRunningProgress(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 appendUserPrompt( turns, String(raw.text ?? raw.prompt ?? ""), attachmentLabels(raw.attachments), ); default: return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; } } export function CustomAgentChatView({ projectId, agentId, agentName, profile, cwd, layoutId, nodeId, sessionId, conversationId, onSessionId, onConversationId, }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system } = useGateways(); const pluginRuntime = usePluginRuntime(); const [turns, setTurns] = useState( () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), ); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); const [slashCommands, setSlashCommands] = useState([]); const [slashMenuOpen, setSlashMenuOpen] = useState(false); const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [profileDialog, setProfileDialog] = useState(null); const [opening, setOpening] = useState(false); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); const composerRef = useRef(null); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; const scrollbackChunkCountRef = useRef( sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0, ); const detachAgentChatRef = useRef(agent.detachAgentChat); detachAgentChatRef.current = agent.detachAgentChat; const slashQuerySeqRef = useRef(0); const draftSaveSeqRef = useRef(0); const draftHydratedRef = useRef(false); const draftEditedBeforeHydrationRef = useRef(false); const lastPersistedDraftRef = useRef(""); const draftRef = useRef(""); draftRef.current = draft; const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(undefined); 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, ); const slashPrefix = slashCommandPrefix(draft); const draftScope = useMemo( () => (layoutId ? { projectId, layoutId, nodeId } : null), [layoutId, nodeId, projectId], ); const clearPersistedDraft = useCallback(async () => { if (!draftScope) return; draftSaveSeqRef.current += 1; lastPersistedDraftRef.current = ""; try { if (agent.clearChatDraft) { await agent.clearChatDraft(draftScope); } else if (agent.saveChatDraft) { await agent.saveChatDraft(draftScope, ""); } } catch (e) { console.warn("[chat-draft] clear failed", instrumentationError(e)); } }, [agent, draftScope]); const editDraft = useCallback((text: string) => { if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; setDraft(text); }, []); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; const sid = sessionRef.current; if (!sid) return; void detachAgentChatRef.current?.(sid).catch((e) => { if (!isNotFound(e)) { console.warn("[ticket188] detachAgentChat failed", instrumentationError(e)); } }); }; }, []); const receive = useCallback((chunk: ReplyChunk) => { scrollbackChunkCountRef.current += 1; setTurns((prev) => foldChunk(prev, chunk)); if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); }, []); useEffect(() => { if (!currentSession) return; chatSessionViewCache.set(currentSession, { turns, chunkCount: scrollbackChunkCountRef.current, }); }, [currentSession, turns]); useEffect(() => { draftSaveSeqRef.current += 1; draftHydratedRef.current = false; draftEditedBeforeHydrationRef.current = false; lastPersistedDraftRef.current = ""; setDraft(""); if (!draftScope || !agent.readChatDraft) { draftHydratedRef.current = true; return; } let cancelled = false; agent .readChatDraft(draftScope) .then((snapshot) => { if (cancelled || !mountedRef.current) return; const restored = snapshot.text ?? ""; lastPersistedDraftRef.current = restored; if (!draftEditedBeforeHydrationRef.current) { setDraft(restored); } }) .catch((e) => { if (!cancelled) { console.warn("[chat-draft] read failed", instrumentationError(e)); } }) .finally(() => { if (!cancelled) { draftHydratedRef.current = true; if ( draftEditedBeforeHydrationRef.current && agent.saveChatDraft && draftRef.current !== lastPersistedDraftRef.current ) { const text = draftRef.current; void agent .saveChatDraft(draftScope, text) .then(() => { lastPersistedDraftRef.current = text; }) .catch((e) => { console.warn("[chat-draft] save failed", instrumentationError(e)); }); } } }); return () => { cancelled = true; }; }, [agent, draftScope]); useEffect(() => { if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return; if (draft === lastPersistedDraftRef.current) return; const seq = ++draftSaveSeqRef.current; const timeout = window.setTimeout(() => { if (draftSaveSeqRef.current !== seq) return; agent .saveChatDraft!(draftScope, draft) .then(() => { if (draftSaveSeqRef.current === seq) { lastPersistedDraftRef.current = draft; } }) .catch((e) => { console.warn("[chat-draft] save failed", instrumentationError(e)); }); }, 250); return () => window.clearTimeout(timeout); }, [agent, draft, draftScope]); useEffect(() => { if ( !supported || !agent.listSlashCommands || slashPrefix === null || busy || opening ) { setSlashMenuOpen(false); setSlashCommands([]); setSlashActiveIndex(0); return; } const seq = ++slashQuerySeqRef.current; agent .listSlashCommands(slashPrefix) .then((commands) => { if (slashQuerySeqRef.current !== seq) return; setSlashCommands(commands); setSlashMenuOpen(commands.length > 0); const firstAvailable = commands.findIndex(isSlashCommandAvailable); setSlashActiveIndex(firstAvailable >= 0 ? firstAvailable : 0); }) .catch(() => { if (slashQuerySeqRef.current !== seq) return; setSlashCommands([]); setSlashMenuOpen(false); setSlashActiveIndex(0); }); }, [agent, busy, opening, slashPrefix, supported]); const selectSlashCommand = useCallback((command: SlashCommand) => { if (!isSlashCommandAvailable(command)) return; editDraft(`${command.name} `); setSlashMenuOpen(false); setSlashCommands([]); setSlashActiveIndex(0); requestAnimationFrame(() => composerRef.current?.focus()); }, [editDraft]); const publishSessionId = useCallback((nextSessionId: string | null) => { selfEmittedSessionIdRef.current = nextSessionId; onSessionIdRef.current(nextSessionId); }, []); const reattachStructuredSession = useCallback( async (sid: string, options: { applyScrollback?: boolean } = {}) => { if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable"); console.debug("[ticket149] reattachStructuredSession:start", { timestamp: new Date().toISOString(), sessionId: sid, options, }); let reattached: Awaited>>; try { reattached = await agent.reattachAgentChat(sid, receive); } catch (e) { console.error("[ticket149] reattachStructuredSession:error", { timestamp: new Date().toISOString(), sessionId: sid, error: instrumentationError(e), }); throw e; } console.debug("[ticket149] reattachStructuredSession:success", { timestamp: new Date().toISOString(), requestedSessionId: sid, returnedSessionId: reattached.sessionId, scrollbackCount: reattached.scrollback.length, }); if (!mountedRef.current) return reattached.sessionId; setCurrentSession(reattached.sessionId); if (options.applyScrollback) { const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0; scrollbackChunkCountRef.current = Math.max( scrollbackChunkCountRef.current, reattached.scrollback.length, ); const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]); const notice = reattachNotice(reattached.scrollback.length - cachedCount); setTurns(notice ? [...replayed, notice] : replayed); } return reattached.sessionId; }, [agent, receive], ); const recoverStructuredSession = useCallback( async ( options: { applyScrollback?: boolean; retryAttachNotFound?: boolean; conversationId?: string | null; } = {}, ) => { if (!agent.launchAgentChat) throw new Error("Structured launch unavailable"); console.debug("[ticket149] recoverStructuredSession:start", { timestamp: new Date().toISOString(), currentSession: sessionRef.current, conversationId, options, }); let retryAttachNotFound = Boolean(options.retryAttachNotFound); for (;;) { const launchRequest = { cwd, rows: 24, cols: 80, conversationId: options.conversationId === undefined ? conversationId ?? undefined : options.conversationId ?? undefined, nodeId, }; console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", { timestamp: new Date().toISOString(), projectId, agentId, request: launchRequest, options, retryAttachNotFound, }); let launched: Awaited>>; try { launched = await agent.launchAgentChat(projectId, agentId, launchRequest); } catch (e) { console.error("[ticket149] recoverStructuredSession:launchAgentChat:error", { timestamp: new Date().toISOString(), projectId, agentId, request: launchRequest, error: instrumentationError(e), }); throw e; } console.debug("[ticket149] recoverStructuredSession:launchAgentChat:success", { timestamp: new Date().toISOString(), projectId, agentId, launched, }); if (!mountedRef.current) return launched.sessionId; scrollbackChunkCountRef.current = 0; setCurrentSession(launched.sessionId); publishSessionId(launched.sessionId); if (launched.assignedConversationId) { onConversationIdRef.current(launched.assignedConversationId); } try { const recoveredSessionId = await reattachStructuredSession(launched.sessionId, { applyScrollback: options.applyScrollback, }); console.debug("[ticket149] recoverStructuredSession:success", { timestamp: new Date().toISOString(), launchedSessionId: launched.sessionId, recoveredSessionId, }); return recoveredSessionId; } catch (e) { if (isNotFound(e) && retryAttachNotFound) { retryAttachNotFound = false; if (mountedRef.current) { setCurrentSession(null); publishSessionId(null); } continue; } console.error("[ticket149] recoverStructuredSession:reattach:error", { timestamp: new Date().toISOString(), launchedSessionId: launched.sessionId, error: instrumentationError(e), }); throw e; } } }, [ agent, projectId, agentId, cwd, nodeId, conversationId, reattachStructuredSession, publishSessionId, ], ); useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [turns]); useEffect(() => { if (selfEmittedSessionIdRef.current === sessionId) { console.debug("[ticket149] openOrAttach:self-session-echo:skip", { timestamp: new Date().toISOString(), sessionId, }); selfEmittedSessionIdRef.current = undefined; return; } setExternalSessionId(sessionId); }, [sessionId]); useEffect(() => { if (!supported) return; let cancelled = false; async function openOrAttach() { const execution = openOrAttachCountRef.current + 1; openOrAttachCountRef.current = execution; console.debug("[ticket149] openOrAttach:start", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, }); setOpening(true); setError(null); try { if (externalSessionId) { const cached = chatSessionViewCache.get(externalSessionId); if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; setTurns(cached.turns); } try { await reattachStructuredSession(externalSessionId, { applyScrollback: true }); if (cancelled) return; console.debug("[ticket149] openOrAttach:reattach-existing:success", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, }); return; } catch (e) { // The backend contract for `reattach_agent_chat` (NOT_FOUND) is that // the session is gone (closed/never live) and the caller falls back // to a fresh launch — any other error still surfaces to the user. if (!isNotFound(e)) throw e; if (!cancelled) { setCurrentSession(null); publishSessionId(null); } console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, error: instrumentationError(e), }); } } await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, }); if (cancelled) return; console.debug("[ticket149] openOrAttach:recover:success", { timestamp: new Date().toISOString(), execution, receivedSessionId: externalSessionId, }); } catch (e) { console.error("[ticket149] openOrAttach:error", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, error: instrumentationError(e), }); if (!cancelled) setError(describe(e)); } finally { if (!cancelled) setOpening(false); } } void openOrAttach(); return () => { cancelled = true; }; }, [ supported, externalSessionId, reattachStructuredSession, recoverStructuredSession, publishSessionId, ]); const canSend = useMemo( () => supported && (Boolean(draft.trim()) || attachments.length > 0) && !busy && !opening, [supported, draft, attachments.length, busy, opening], ); async function openProfileDialog() { const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) => Boolean(candidate.structuredAdapter), ); if (availableProfiles.length === 0) { throw new Error("Aucun profil compatible avec la CLI custom n'est configuré."); } setDraft(""); setProfileDialog({ profiles: availableProfiles, selectedProfileId: availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ?? availableProfiles[0].id, confirmedReset: false, busy: false, error: null, }); } async function confirmProfileCommandChange() { if (!profileDialog) return; if (!agent.changeAgentProfile) { setProfileDialog((prev) => prev ? { ...prev, error: "Changement de profil indisponible." } : prev, ); return; } const selected = profileDialog.profiles.find( (candidate) => candidate.id === profileDialog.selectedProfileId, ); if (!selected) return; setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev)); try { const { relaunchedSession } = await agent.changeAgentProfile( projectId, agentId, selected.id, 24, 80, ); setTurns([]); setCurrentSession(null); publishSessionId(null); onConversationIdRef.current(null); if (relaunchedSession?.sessionId) { try { setCurrentSession(relaunchedSession.sessionId); publishSessionId(relaunchedSession.sessionId); await reattachStructuredSession(relaunchedSession.sessionId, { applyScrollback: false, }); } catch { await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, conversationId: null, }); } } else { await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, conversationId: null, }); } setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]); setProfileDialog(null); } catch (e) { setProfileDialog((prev) => prev ? { ...prev, busy: false, error: describe(e) } : prev, ); } } async function executeSlashCommand(invocation: SlashCommandInvocation) { if (!agent.executeSlashCommand) { setError(`Commande ${invocation.name} indisponible dans ce runtime.`); return; } setDraft(""); void clearPersistedDraft(); setError(null); try { const sid = currentSession ?? (await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, })); const result = await agent.executeSlashCommand(invocation.name, { sessionId: sid, ...(invocation.arguments.length > 0 ? { arguments: invocation.arguments } : {}), }); if (result.effect.kind === "help") { const helpText = formatSlashCommandHelp(result.effect.commands); setTurns((prev) => [ ...prev, { role: "agent", text: helpText }, ]); return; } if (result.effect.kind === "cleanConversation") { setTurns([]); return; } if (result.effect.kind === "profileSwitch") { await openProfileDialog(); return; } if (result.effect.kind === "pluginCallback") { await pluginRuntime.registry.runCommandStrict( result.effect.pluginId, result.effect.commandId, ...result.effect.arguments, ); setTurns((prev) => [ ...prev, { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, ]); return; } } catch (e) { const message = `Commande ${invocation.name} échouée: ${describe(e)}`; setError(message); setTurns((prev) => [...prev, { role: "error", text: message }]); } } async function pickAttachment() { const path = await system.pickFile(); if (path) { setAttachments((prev) => [ ...prev, { id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`, label: pathBasename(path), input: { path, sourceKind: "localFile" }, }, ]); } } async function pasteClipboardFiles( event: ClipboardEvent, ) { const target = event.currentTarget; const pastedText = event.clipboardData.getData("text/plain"); const selectionStart = target.selectionStart; const selectionEnd = target.selectionEnd; const filesFromEvent = dataTransferFiles(event.clipboardData); try { const files = filesFromEvent.length > 0 ? filesFromEvent : await asyncClipboardImageFiles(); if (files.length === 0) return; if (filesFromEvent.length > 0) event.preventDefault(); const nextAttachments = await Promise.all( files.map(clipboardFileToAttachment), ); setAttachments((prev) => { const known = new Set( prev .map((attachment) => attachment.dedupeKey) .filter((key): key is string => Boolean(key)), ); const unique = nextAttachments.filter((attachment) => { if (!attachment.dedupeKey) return true; if (known.has(attachment.dedupeKey)) return false; known.add(attachment.dedupeKey); return true; }); return unique.length > 0 ? [...prev, ...unique] : prev; }); if (filesFromEvent.length > 0 && pastedText) { if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; setDraft((prev) => prev.slice(0, selectionStart) + pastedText + prev.slice(selectionEnd), ); } } catch (e) { setError(describe(e)); } } async function send() { const text = draft.trim(); if (!canSend || !agent.sendAgentChat) return; const slashInvocation = slashCommandInvocation(text); if (slashInvocation) { await executeSlashCommand(slashInvocation); return; } const outgoingAttachments = attachments; const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentLabels = outgoingAttachments.map((item) => item.label); const displayText = text || "Pièce jointe"; setDraft(""); setAttachments([]); void clearPersistedDraft(); setBusy(true); setError(null); setTurns((prev) => [ ...prev, { role: "user", text: displayText, ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ]); const sendTurn = (sid: string) => attachmentInputs.length > 0 ? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs }) : agent.sendAgentChat!(sid, text, receive); try { const sid = currentSession ?? (await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, })); await sendTurn(sid); } catch (e) { if (isNotFound(e)) { try { setCurrentSession(null); publishSessionId(null); const recoveredSession = await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, }); await sendTurn(recoveredSession); return; } catch (recoveryError) { setBusy(false); setError(describe(recoveryError)); setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]); return; } } setBusy(false); setError(describe(e)); setTurns((prev) => [...prev, { role: "error", text: describe(e) }]); } } async function stopAgent() { const sid = sessionRef.current; if (!sid || !agent.stopLiveAgent || stopping) return; setStopping(true); setError(null); try { const stopped = await agent.stopLiveAgent(projectId, agentId); setBusy(false); setOpening(false); setCurrentSession(null); publishSessionId(null); setTurns((prev) => [ ...completeRunningProgress(prev), { role: "tool", label: stopped.kind === "structured" ? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés." : "Agent arrêté.", }, ]); } catch (e) { if (isNotFound(e)) { setBusy(false); setOpening(false); setCurrentSession(null); publishSessionId(null); setTurns((prev) => [ ...completeRunningProgress(prev), { role: "tool", label: "Session déjà arrêtée." }, ]); return; } setError(describe(e)); setTurns((prev) => [ ...prev, { role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, ]); } finally { setStopping(false); } } return (
{agentName}
CLI custom · {profile.name}
{!supported && (

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

)} {error && (

{error}

)} {profileDialog && (

Changer de profil

Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.

{profileDialog.error && (

{profileDialog.error}

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

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

) : ( turns.map((turn, index) => ( )) )}
{attachments.length > 0 && (
{attachments.map((item) => (
{item.previewUrl && ( )} Fichier joint: {item.label}
))}
)} {slashMenuOpen && slashCommands.length > 0 && (
{slashCommands.map((command, index) => { const available = isSlashCommandAvailable(command); const selected = index === slashActiveIndex; return ( ); })}
)}