/** * 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, useId, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject, } from "react"; import type { AgentProfile, GatewayError, ReplyChunk, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; import { AnnouncementsPreview, shortRestartFailure, useAgentRestart, } from "@/features/announcements"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { markProjectCliInteractionFinished, markProjectCliInteractionStarted, markProjectCliInteractionStopped, } from "@/features/projects/projectCliInteractionActivity"; 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; composerHeightPx?: number; composerDefaultHeightPx?: number; onComposerHeightPxChange?: (heightPx: number) => void; draftScope?: ChatDraftScope | null; mobile?: boolean; onOpenNativeTui?: () => void; onSessionId: (sessionId: string | null) => void; onConversationId: (conversationId: string | null) => void; } type ChatTurn = | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number } | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number } | { role: "tool"; label: string; receivedAtMs?: number } | { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string } | { role: "final"; text: string; receivedAtMs?: number } | { role: "error"; text: string; receivedAtMs?: number } | { role: "restart"; key: string; text: string; message?: string; failed?: boolean } | { role: "unknown"; text: string; receivedAtMs?: number }; interface CachedChatView { turns: ChatTurn[]; progressIndex: Map; progressScope: number; chunkCount: number; sessionId?: string; } const chatSessionViewCache = new Map(); const chatConversationViewCache = new Map(); const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; const COMPOSER_HISTORY_LIMIT = 100; const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1); const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1); const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000, ); const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000, ); export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); chatConversationViewCache.clear(); } interface AttachmentDraft { id: string; label: string; input: ChatAttachmentInput; previewUrl?: string; dedupeKey?: string; token?: string; } interface ProgressIndexEntry { firstOccurredAtMs: number; sequence: number; } interface ProfileCommandDialogState { profiles: AgentProfile[]; selectedProfileId: string; confirmedReset: boolean; busy: boolean; error: string | null; } interface SlashCommandInvocation { name: string; arguments: unknown[]; } interface ChatErrorState { message: string; openFailure: boolean; } interface RequesterDelegationState { currentAgentBusy: boolean; delegationTargetName: string | null; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } function describeOpenFailure(e: unknown): string { const message = describe(e).trim(); return message || "Cause technique non disponible."; } function workAgentDisplayName(agent: { agentId: string; name: string }): string { return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`; } function structuredSessionLabel( adapter: AgentProfile["structuredAdapter"], ): string | null { switch (adapter) { case "codex": return "Session Codex"; case "claude": return "Session Claude"; case "openCode": return "Session OpenCode"; case "openAiCompatible": return "Session HTTP"; default: return null; } } 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, receivedAtMs: number, ): 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, receivedAtMs: last.receivedAtMs ?? receivedAtMs, }; return next; } next.push({ role: "agent", text, pending: true, receivedAtMs }); 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[] = [], receivedAtMs = Date.now(), ): { turns: ChatTurn[]; appended: boolean } { for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (turn.role === "final" || turn.role === "error") break; if (turn.role !== "user") continue; if ( turn.text === text && JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments) ) { return { turns, appended: false }; } break; } return { turns: [ ...turns, { role: "user", text, receivedAtMs, ...(attachments.length > 0 ? { attachments } : {}), }, ], appended: true, }; } 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 { progressId: optionalString(record.progressId), occurredAtMs: typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs) ? record.occurredAtMs : undefined, 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 progressUpsertKey(progress: ReplyProgress, scope: number): string { const progressId = progress.progressId?.trim(); if (progressId) return `progress:${scope}:${progressId}`; return `legacy:${scope}:${progressDisplayKey(progress)}`; } function turnProgressKey(turn: Extract): string { return turn.progressKey ?? progressUpsertKey(turn.progress, 0); } 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 legacyActiveProgressKey(progress: ReplyProgress, scope: number): string | null { const entity = progressEntityKey(progress); if (!entity) return null; if (progress.stage !== "started" && progress.stage !== "delta") return null; return `legacy:${scope}:${entity}\u0000started`; } function legacyCompletedDedupKey(progress: ReplyProgress): string | null { const entity = progressEntityKey(progress); if (!entity || progress.stage !== "completed") return null; if (isDelegationProgress(progress)) return entity; return `${entity}\u0000${progress.text ?? ""}`; } function findLastProgressIndex( turns: ChatTurn[], predicate: (turn: Extract) => boolean, ): number { for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (turn.role === "progress" && predicate(turn)) return index; } return -1; } function legacyCompletedOccurrenceKey( progress: ReplyProgress, entry: ProgressIndexEntry, scope: number, ): string { return `legacy-completed:${scope}:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`; } interface ChatViewState { turns: ChatTurn[]; progressIndex: Map; progressScope: number; } function emptyChatViewState(): ChatViewState { return { turns: [], progressIndex: new Map(), progressScope: 0 }; } function conversationCacheKey( projectId: string, agentId: string, conversationId: string | null, ): string | null { return conversationId ? `${projectId}::${agentId}::${conversationId}` : null; } function composerHistoryStorageKey( projectId: string, agentId: string, layoutId: string | undefined, nodeId: string | undefined, ): string { return [ "idea.custom-chat-history", projectId, layoutId ?? "default", nodeId ?? "agent", agentId, ].join("."); } function readComposerHistory(storageKey: string): string[] { if (typeof window === "undefined") return []; try { const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]"); if (!Array.isArray(parsed)) return []; return parsed .filter((entry): entry is string => typeof entry === "string") .map((entry) => entry.trim()) .filter(Boolean) .slice(-COMPOSER_HISTORY_LIMIT); } catch { return []; } } function writeComposerHistory(storageKey: string, history: string[]): void { if (typeof window === "undefined") return; try { window.localStorage.setItem( storageKey, JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), ); } catch { /* local history only */ } } function appendComposerHistoryEntry(history: string[], text: string): string[] { const entry = text.trim(); if (!entry) return history; const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history; return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT); } function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { let key = progressUpsertKey(progress, state.progressScope); let existingIndex = state.turns.findIndex( (turn) => turn.role === "progress" && turnProgressKey(turn) === key, ); const activeKey = !progress.progressId && progress.stage === "completed" ? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope) : null; if (activeKey) { const activeIndex = state.turns.findIndex( (turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey, ); if (activeIndex >= 0) { existingIndex = activeIndex; key = activeKey; } } const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null; if (completedDedupKey) { existingIndex = findLastProgressIndex( state.turns, (turn) => turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) && legacyCompletedDedupKey(turn.progress) === completedDedupKey, ); const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null; if (existingTurn?.role === "progress") { key = turnProgressKey(existingTurn); } } const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null; const existingProgress = existingTurn?.role === "progress" ? existingTurn.progress : null; const merged = existingProgress && progress.stage === "completed" ? mergeCompletedProgress(existingProgress, progress) : progress; const nextIndex = new Map(state.progressIndex); const existingEntry = nextIndex.get(key); const firstOccurredAtMs = existingEntry?.firstOccurredAtMs ?? progress.occurredAtMs ?? Date.now(); const sequence = existingEntry?.sequence ?? nextIndex.size; const nextEntry = { firstOccurredAtMs, sequence }; let nextKey = key; if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) { nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope); nextIndex.delete(activeKey); } nextIndex.set(nextKey, nextEntry); if (existingIndex >= 0) { const nextTurns = [...state.turns]; nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey }; return { ...state, turns: nextTurns, progressIndex: nextIndex }; } const insertAt = state.turns.findIndex((turn) => { if (turn.role !== "progress") return false; const candidate = state.progressIndex.get(turnProgressKey(turn)); if (!candidate) return false; if (candidate.firstOccurredAtMs !== firstOccurredAtMs) { return candidate.firstOccurredAtMs > firstOccurredAtMs; } return candidate.sequence > sequence; }); const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey }; if (insertAt < 0) { return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex }; } return { ...state, turns: [ ...state.turns.slice(0, insertAt), nextTurn, ...state.turns.slice(insertAt), ], progressIndex: nextIndex, }; } 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 progressShouldSettle( turn: Extract, completedEntities: Set, ): boolean { if ( turn.progress.kind === "turn" || (turn.progress.stage !== "started" && turn.progress.stage !== "delta") ) { return false; } const entity = progressEntityKey(turn.progress); return Boolean(entity && completedEntities.has(entity)); } function shouldCompleteProgress( turn: Extract, ): boolean { return ( turn.progress.kind !== "turn" && (turn.progress.stage === "started" || turn.progress.stage === "delta") ); } function completeChatViewProgress(state: ChatViewState): ChatViewState { const completedEntities = new Set( state.turns .filter( (turn): turn is Extract => turn.role === "progress" && progressStageBucket(turn.progress) === "completed" && Boolean(progressEntityKey(turn.progress)), ) .map((turn) => progressEntityKey(turn.progress)), ); const nextIndex = new Map(state.progressIndex); const turns = state.turns.map((turn) => { if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn; const previousKey = turnProgressKey(turn); const previousEntry = nextIndex.get(previousKey); const entry = previousEntry ?? { firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(), sequence: nextIndex.size, }; const completedProgress = { ...turn.progress, stage: "completed" as const, }; const nextKey = turn.progress.progressId ? progressUpsertKey(completedProgress, state.progressScope) : legacyCompletedOccurrenceKey(completedProgress, entry, state.progressScope); if (previousKey !== nextKey) nextIndex.delete(previousKey); nextIndex.set(nextKey, entry); if (progressShouldSettle(turn, completedEntities)) { return { ...turn, settled: true, progress: completedProgress, progressKey: nextKey, }; } return { ...turn, progress: completedProgress, progressKey: nextKey, }; }); return { ...state, turns, progressIndex: nextIndex }; } function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE; } function turnsIndicateBusy(state: ChatViewState): boolean { let sawOpenActivity = false; for (let index = state.turns.length - 1; index >= 0; index -= 1) { const turn = state.turns[index]; if (turn.role === "final" || turn.role === "error") return false; if (turn.role === "user") return sawOpenActivity; if (turn.role === "agent" && turn.pending) sawOpenActivity = true; if ( turn.role === "progress" && !turn.settled && progressStageBucket(turn.progress) !== "completed" ) { sawOpenActivity = true; } } return sawOpenActivity; } function insertTextAtSelection( text: string, insertion: string, selectionStart: number, selectionEnd: number, ): string { return text.slice(0, selectionStart) + insertion + text.slice(selectionEnd); } function removeAttachmentToken(text: string, token: string): string { const index = text.indexOf(token); if (index < 0) return text; const before = text.slice(0, index); const after = text.slice(index + token.length); if (/\s$/.test(before) && /^\s/.test(after)) { return before + after.replace(/^\s+/, ""); } return before + after; } 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 lastAgentReceivedAtMs(turns: ChatTurn[]): number | undefined { for (let index = turns.length - 1; index >= 0; index -= 1) { const turn = turns[index]; if (turn.role === "agent") return turn.receivedAtMs; if (turn.role === "user" || turn.role === "final" || turn.role === "error") { return undefined; } } return undefined; } function foldChunk( state: ChatViewState, raw: unknown, receivedAtMs = Date.now(), ): ChatViewState { const { turns } = state; if (!isReplyRecord(raw)) { return { ...state, turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], }; } const last = turns[turns.length - 1]; if ( (last?.role === "final" || last?.role === "error") && raw.kind !== "userPrompt" && raw.kind !== "UserPrompt" ) { if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") { return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs); } return state; } switch (raw.kind) { case "textDelta": return { ...state, turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs), }; case "toolActivity": return { ...state, turns: [ ...turns, { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs }, ], }; case "progress": { const progress = normalizeProgress(raw.progress); if (!progress) { return { ...state, turns: [ ...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }, ], }; } return appendProgress(state, progress); } case "final": { const content = String(raw.content ?? ""); const completedState = completeChatViewProgress(state); const next = [...completedState.turns]; const last = next[next.length - 1]; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; next.push({ role: "final", text: content, receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, }); return { ...completedState, turns: next }; } case "error": { const completedState = completeChatViewProgress(state); const next = [...completedState.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"), receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, }); return { ...completedState, turns: next }; } case "userPrompt": case "UserPrompt": { const appended = appendUserPrompt( turns, String(raw.text ?? raw.prompt ?? ""), attachmentLabels(raw.attachments), receivedAtMs, ); return { ...state, turns: appended.turns, progressScope: appended.appended ? state.progressScope + 1 : state.progressScope, }; } default: return { ...state, turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], }; } } export function CustomAgentChatView({ projectId, agentId, agentName, profile, cwd, layoutId, nodeId, sessionId, conversationId, composerHeightPx = FALLBACK_COMPOSER_HEIGHT, composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT, onComposerHeightPxChange, draftScope: explicitDraftScope, mobile = false, onOpenNativeTui, onSessionId, onConversationId, }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system, workState } = useGateways(); const pluginRuntime = usePluginRuntime(); const initialConversationCacheKey = conversationCacheKey( projectId, agentId, conversationId, ); const [turns, setTurns] = useState( () => sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : initialConversationCacheKey ? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? []) : [], ); const [progressIndex, setProgressIndex] = useState>( () => sessionId ? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map()) : initialConversationCacheKey ? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map()) : new Map(), ); const [progressScope, setProgressScope] = useState( () => sessionId ? (chatSessionViewCache.get(sessionId)?.progressScope ?? 0) : initialConversationCacheKey ? (chatConversationViewCache.get(initialConversationCacheKey)?.progressScope ?? 0) : 0, ); const [expandedProgressKeys, setExpandedProgressKeys] = useState>( () => new Set(), ); 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(Boolean(sessionId || conversationId)); const [openRetryNonce, setOpenRetryNonce] = useState(0); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); const composerRef = useRef(null); const chatRootRef = useRef(null); const turnsRef = useRef(turns); const progressIndexRef = useRef(progressIndex); const progressScopeRef = useRef(progressScope); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; const scrollbackChunkCountRef = useRef( sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0, ); const detachAgentChatRef = useRef<((sessionId: string) => Promise) | undefined>( agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined, ); detachAgentChatRef.current = agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined; const slashQuerySeqRef = useRef(0); const draftSaveSeqRef = useRef(0); const interactionSeqRef = useRef(0); const activeInteractionIdRef = useRef(null); const draftHydratedRef = useRef(false); const draftEditedBeforeHydrationRef = useRef(false); const lastPersistedDraftRef = useRef(""); const draftRef = useRef(""); draftRef.current = draft; const draftBeforeHistoryNavigationRef = useRef(""); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; const imageTokenSeqRef = useRef(0); const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(undefined); const reconciledRestartIdsRef = useRef(new Set()); const onSessionIdRef = useRef(onSessionId); onSessionIdRef.current = onSessionId; const onConversationIdRef = useRef(onConversationId); onConversationIdRef.current = onConversationId; const domId = nodeId ?? `agent-${agentId}`; const clampComposerHeight = useCallback( (heightPx: number): number => { const visibleHeight = chatRootRef.current?.getBoundingClientRect().height ?? composerRef.current?.closest("[data-custom-chat-shell]") ?.getBoundingClientRect().height ?? 0; const maxHeight = visibleHeight > 0 ? Math.max(composerDefaultHeightPx, Math.floor(visibleHeight * 0.5)) : Number.POSITIVE_INFINITY; return Math.round( Math.min(Math.max(heightPx, composerDefaultHeightPx), maxHeight), ); }, [composerDefaultHeightPx], ); const commitComposerHeight = useCallback( (heightPx: number): void => { onComposerHeightPxChange?.(clampComposerHeight(heightPx)); }, [clampComposerHeight, onComposerHeightPxChange], ); const startComposerResize = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); const startY = event.clientY; const startHeight = composerHeightPx; const onMove = (moveEvent: MouseEvent) => { commitComposerHeight(startHeight + startY - moveEvent.clientY); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [commitComposerHeight, composerHeightPx], ); const handleComposerResizeKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === "ArrowUp") { event.preventDefault(); commitComposerHeight(composerHeightPx + COMPOSER_RESIZE_STEP); } else if (event.key === "ArrowDown") { event.preventDefault(); commitComposerHeight(composerHeightPx - COMPOSER_RESIZE_STEP); } }, [commitComposerHeight, composerHeightPx], ); const applyChatViewState = useCallback((next: ChatViewState) => { turnsRef.current = next.turns; progressIndexRef.current = next.progressIndex; progressScopeRef.current = next.progressScope; setTurns(next.turns); setProgressIndex(next.progressIndex); setProgressScope(next.progressScope); }, []); const updateChatViewState = useCallback( (updater: (prev: ChatViewState) => ChatViewState) => { applyChatViewState( updater({ turns: turnsRef.current, progressIndex: progressIndexRef.current, progressScope: progressScopeRef.current, }), ); }, [applyChatViewState], ); const supported = Boolean( profile.structuredAdapter && agent.launchAgentChat && agent.reattachAgentChat && agent.sendAgentChat && agent.cancelAgentChat && agent.closeAgentChat, ); const slashPrefix = slashCommandPrefix(draft); const draftScope = useMemo(() => { if (explicitDraftScope) return explicitDraftScope; return layoutId && nodeId ? { projectId, layoutId, nodeId } : null; }, [explicitDraftScope, layoutId, nodeId, projectId]); const currentConversationCacheKey = useMemo( () => conversationCacheKey(projectId, agentId, conversationId), [agentId, conversationId, projectId], ); const composerHistoryKey = useMemo( () => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId), [agentId, layoutId, nodeId, projectId], ); const [composerHistory, setComposerHistory] = useState(() => readComposerHistory(composerHistoryKey), ); const [, setComposerHistoryIndex] = useState(null); const displayTurns = turns; const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const [requesterDelegation, setRequesterDelegation] = useState({ currentAgentBusy: false, delegationTargetName: null, }); const { restart, events: restartEvents } = useAgentRestart(agentId); const delegationActive = requesterDelegation.delegationTargetName !== null; const effectiveBusy = busy || stopping || Boolean(restart && restart.outcome !== "failed") || requesterDelegation.currentAgentBusy || delegationActive; const delegationFallbackText = requesterDelegation.delegationTargetName ? `${agentName} attend ${requesterDelegation.delegationTargetName}` : "Délégation en cours."; const refreshRequesterDelegation = useCallback(async () => { if (!workState) { setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); return; } try { const state = await workState.getProjectWorkState(projectId); const current = state.agents.find((row) => row.agentId === agentId); const currentAgentBusy = Boolean( current?.busy.state === "busy" || current?.tickets.some((ticket) => ticket.status === "inProgress"), ); const target = state.agents.find((row) => row.tickets.some( (ticket) => ticket.status === "inProgress" && ticket.source.kind === "agent" && ticket.source.agentId === agentId, ), ); setRequesterDelegation({ currentAgentBusy, delegationTargetName: target ? workAgentDisplayName(target) : null, }); } catch { setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); } }, [agentId, projectId, workState]); 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; setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; setDraft(text); }, []); const recallComposerHistory = useCallback( (direction: "previous" | "next") => { if (composerHistory.length === 0) return; setComposerHistoryIndex((current) => { if (current === null && direction === "previous") { draftBeforeHistoryNavigationRef.current = draftRef.current; } const nextIndex = direction === "previous" ? current === null ? composerHistory.length - 1 : Math.max(0, current - 1) : current === null ? null : current >= composerHistory.length - 1 ? null : current + 1; if (nextIndex === null) { setDraft(draftBeforeHistoryNavigationRef.current); draftBeforeHistoryNavigationRef.current = ""; } else { setDraft(composerHistory[nextIndex] ?? ""); } return nextIndex; }); }, [composerHistory], ); const handleComposerHistoryKeyDown = useCallback( (event: KeyboardEvent): boolean => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { return false; } const target = event.currentTarget; if ( event.key === "ArrowUp" && target.value.slice(0, target.selectionStart).includes("\n") ) { return false; } if ( event.key === "ArrowDown" && target.value.slice(target.selectionEnd).includes("\n") ) { return false; } event.preventDefault(); recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next"); return true; }, [recallComposerHistory], ); useEffect(() => { const restored = readComposerHistory(composerHistoryKey); setComposerHistory(restored); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; }, [composerHistoryKey]); useEffect(() => { writeComposerHistory(composerHistoryKey, composerHistory); }, [composerHistory, composerHistoryKey]); useEffect(() => { void refreshRequesterDelegation(); }, [refreshRequesterDelegation]); useEffect(() => { if (!system?.onDomainEvent) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if ( event.type === "agentBusyChanged" || event.type === "delegationReady" || event.type === "orchestratorRequestProcessed" || event.type === "agentInboxChanged" || event.type === "agentWakeChanged" ) { if ( event.type === "agentBusyChanged" && event.agentId === agentId && !event.busy ) { if (activeInteractionIdRef.current) { markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } setBusy(false); } void refreshRequesterDelegation(); } }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }); return () => { cancelled = true; unsubscribe?.(); }; }, [agentId, projectId, refreshRequesterDelegation, system]); useEffect(() => { if (restartEvents.length === 0) return; updateChatViewState((previous) => { const nextTurns = [...previous.turns]; let changed = false; for (const event of restartEvents) { const key = `${event.restartId}:${event.type}`; if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) { continue; } if (event.type === "agentRestartRequested") { changed = true; nextTurns.push({ role: "restart", key, text: "------- redemarrage de l'agent -------", }); } else if (event.type === "agentRestartCompleted") { changed = true; nextTurns.push({ role: "restart", key, text: "------- redemarrage de l'agent termine -------", }); } else { changed = true; nextTurns.push({ role: "restart", key, text: "------- redemarrage de l'agent echoue -------", message: shortRestartFailure(event.message), failed: true, }); } } return changed ? { ...previous, turns: nextTurns } : previous; }); }, [restartEvents, updateChatViewState]); 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; const activeInteractionId = activeInteractionIdRef.current; if ( activeInteractionId && chunk.kind === "progress" && chunk.progress.kind === "turn" ) { if ( chunk.progress.stage === "started" || chunk.progress.stage === "delta" ) { markProjectCliInteractionStarted(projectId, activeInteractionId); } if (chunk.progress.stage === "completed") { markProjectCliInteractionFinished(projectId, activeInteractionId); activeInteractionIdRef.current = null; } } updateChatViewState((prev) => foldChunk(prev, chunk)); if (chunk.kind === "final" || chunk.kind === "error") { if (activeInteractionIdRef.current) { markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } setBusy(false); } }, [projectId, updateChatViewState], ); useEffect(() => { if (currentSession) { chatSessionViewCache.set(currentSession, { turns, progressIndex, progressScope, chunkCount: scrollbackChunkCountRef.current, sessionId: currentSession, }); } if (currentConversationCacheKey) { const previous = chatConversationViewCache.get(currentConversationCacheKey); const cachedSessionId = currentSession ?? previous?.sessionId; if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) { chatConversationViewCache.set(currentConversationCacheKey, { turns, progressIndex, progressScope, chunkCount: scrollbackChunkCountRef.current, ...(cachedSessionId ? { sessionId: cachedSessionId } : {}), }); } } }, [currentConversationCacheKey, currentSession, progressIndex, progressScope, 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 || effectiveBusy || 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, effectiveBusy, 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; stableIdentity?: boolean; preserveRestartTurns?: 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 = options.stableIdentity ? await agent.reattachAgentChat(sid, receive, { projectId, agentId }) : 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 (reattached.sessionId !== sid) { publishSessionId(reattached.sessionId); } if (reattached.conversationId) { onConversationIdRef.current(reattached.conversationId); } if (options.applyScrollback) { scrollbackChunkCountRef.current = reattached.scrollback.length; let replayed = reattached.scrollback.reduce( (state, chunk) => foldChunk(state, chunk), emptyChatViewState(), ); if (options.preserveRestartTurns) { const restartTurns = turnsRef.current.filter((turn) => turn.role === "restart"); if (restartTurns.length > 0) { replayed = { ...replayed, turns: [...replayed.turns, ...restartTurns] }; } } applyChatViewState(replayed); setBusy(turnsIndicateBusy(replayed)); } return reattached.sessionId; }, [agent, agentId, applyChatViewState, projectId, publishSessionId, receive], ); useEffect(() => { const completed = [...restartEvents] .reverse() .find((event) => event.type === "agentRestartCompleted"); if (!completed || reconciledRestartIdsRef.current.has(completed.restartId)) return; const sid = sessionRef.current ?? externalSessionId; if (!sid) return; reconciledRestartIdsRef.current.add(completed.restartId); let cancelled = false; setOpening(true); setError(null); void reattachStructuredSession(sid, { applyScrollback: true, stableIdentity: true, preserveRestartTurns: true, }) .catch((e) => { if (!cancelled) { setError({ message: describeOpenFailure(e), openFailure: true }); } }) .finally(() => { if (!cancelled) setOpening(false); }); return () => { cancelled = true; }; }, [externalSessionId, reattachStructuredSession, restartEvents]); 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 ? { 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; }, [displayTurns]); 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 { const localSession = sessionRef.current; if (!externalSessionId && localSession) { console.debug("[ticket149] openOrAttach:local-session-present:skip", { timestamp: new Date().toISOString(), execution, currentSession: localSession, }); return; } if (externalSessionId) { const cached = chatSessionViewCache.get(externalSessionId); if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; applyChatViewState({ turns: cached.turns, progressIndex: cached.progressIndex ?? new Map(), progressScope: cached.progressScope ?? 0, }); } 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; try { const replacementSessionId = await reattachStructuredSession(externalSessionId, { applyScrollback: true, stableIdentity: true, }); if (cancelled) return; publishSessionId(replacementSessionId); console.debug("[ticket295] openOrAttach:stable-reattach:success", { timestamp: new Date().toISOString(), execution, requestedSessionId: externalSessionId, replacementSessionId, }); return; } catch (stableError) { if (!isNotFound(stableError)) throw stableError; } if (!cancelled) { setCurrentSession(null); publishSessionId(null); } console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, error: instrumentationError(e), }); } } else if (currentConversationCacheKey) { const cached = chatConversationViewCache.get(currentConversationCacheKey); if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; applyChatViewState({ turns: cached.turns, progressIndex: cached.progressIndex ?? new Map(), progressScope: cached.progressScope ?? 0, }); if (cached.sessionId) { const reattachedSessionId = await reattachStructuredSession(cached.sessionId, { applyScrollback: true, }); if (cancelled) return; publishSessionId(reattachedSessionId); console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", { timestamp: new Date().toISOString(), execution, sessionId: reattachedSessionId, }); return; } return; } } await recoverStructuredSession({ applyScrollback: Boolean(conversationId), 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({ message: describeOpenFailure(e), openFailure: true }); } } finally { if (!cancelled) setOpening(false); } } void openOrAttach(); return () => { cancelled = true; }; }, [ supported, externalSessionId, reattachStructuredSession, recoverStructuredSession, publishSessionId, applyChatViewState, currentConversationCacheKey, conversationId, openRetryNonce, ]); const canSend = useMemo( () => supported && (Boolean(draft.trim()) || attachments.length > 0) && !effectiveBusy && !opening, [supported, draft, attachments.length, effectiveBusy, opening], ); const hasLinkedConversation = Boolean( conversationId || requesterDelegation.currentAgentBusy || restart || delegationActive, ); 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, ); applyChatViewState(emptyChatViewState()); 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, }); } applyChatViewState({ turns: [ { role: "tool", label: `Profil changé vers ${selected.name}.`, receivedAtMs: Date.now(), }, ], progressIndex: new Map(), progressScope: 0, }); setProfileDialog(null); } catch (e) { setProfileDialog((prev) => prev ? { ...prev, busy: false, error: describe(e) } : prev, ); } } async function executeSlashCommand(invocation: SlashCommandInvocation) { if (!agent.executeSlashCommand) { setError({ message: `Commande ${invocation.name} indisponible dans ce runtime.`, openFailure: false, }); 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); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }], })); return; } if (result.effect.kind === "cleanConversation") { applyChatViewState(emptyChatViewState()); 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, ); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "tool", label: `Commande plugin ${result.command.name} exécutée.`, receivedAtMs: Date.now(), }, ], })); return; } } catch (e) { const message = `Commande ${invocation.name} échouée: ${describe(e)}`; setError({ message, openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], })); } } 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" }, }, ]); } } function removeAttachment(item: AttachmentDraft) { setAttachments((prev) => prev.filter((candidate) => candidate.id !== item.id), ); if (item.token) { editDraft(removeAttachmentToken(draftRef.current, item.token)); } } 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), ); const known = new Set( attachmentsRef.current .map((attachment) => attachment.dedupeKey) .filter((key): key is string => Boolean(key)), ); const uniqueAttachments = nextAttachments .filter((attachment) => { if (!attachment.dedupeKey) return true; if (known.has(attachment.dedupeKey)) return false; known.add(attachment.dedupeKey); return true; }) .map((attachment) => { if (!attachment.previewUrl) return attachment; const token = `[Image ${++imageTokenSeqRef.current}]`; return { ...attachment, token }; }); if (uniqueAttachments.length > 0) { setAttachments((prev) => [...prev, ...uniqueAttachments]); } const imageTokens = uniqueAttachments .map((attachment) => attachment.token) .filter((token): token is string => Boolean(token)); const insertionParts = [ ...(pastedText ? [pastedText] : []), ...(imageTokens.length > 0 ? [imageTokens.join(" ")] : []), ]; if (insertionParts.length > 0) { if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; setDraft((prev) => insertTextAtSelection( prev, insertionParts.join(" "), selectionStart, selectionEnd, ), ); } } catch (e) { setError({ message: describe(e), openFailure: false }); } } 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"; const interactionId = `${agentId}:${++interactionSeqRef.current}`; activeInteractionIdRef.current = interactionId; markProjectCliInteractionStarted(projectId, interactionId); const finishInteraction = () => { if (activeInteractionIdRef.current !== interactionId) return; markProjectCliInteractionFinished(projectId, interactionId); activeInteractionIdRef.current = null; }; if (text) { setComposerHistory((prev) => appendComposerHistoryEntry(prev, text)); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; } setDraft(""); setAttachments([]); imageTokenSeqRef.current = 0; void clearPersistedDraft(); setBusy(true); setError(null); const sentAtMs = Date.now(); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "user", text: displayText, receivedAtMs: sentAtMs, ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ], progressScope: prev.progressScope + 1, })); 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); finishInteraction(); } catch (e) { if (isNotFound(e)) { try { setCurrentSession(null); publishSessionId(null); const recoveredSession = await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, }); await sendTurn(recoveredSession); finishInteraction(); return; } catch (recoveryError) { finishInteraction(); setBusy(false); setError({ message: describe(recoveryError), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "error", text: describe(recoveryError), receivedAtMs: Date.now() }, ], })); return; } } finishInteraction(); setBusy(false); setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], })); } } async function stopCurrentRequest() { const sid = sessionRef.current; if (!sid || !agent.cancelAgentChat || stopping) return; setStopping(true); setError(null); try { await agent.cancelAgentChat(sid); if (activeInteractionIdRef.current) { markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } setBusy(false); setOpening(false); updateChatViewState((prev) => { const completed = completeChatViewProgress(prev); return { ...completed, turns: [ ...completed.turns, { role: "tool", label: "Request stopped.", receivedAtMs: Date.now(), }, ], }; }); } catch (e) { if (isNotFound(e)) { if (activeInteractionIdRef.current) { markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } setBusy(false); setOpening(false); updateChatViewState((prev) => { const completed = completeChatViewProgress(prev); return { ...completed, turns: [ ...completed.turns, { role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() }, ], }; }); return; } setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "error", text: `Arrêt de la demande échoué: ${describe(e)}`, receivedAtMs: Date.now(), }, ], })); } 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.openFailure ? (
Le CLI custom n'a pas pu démarrer.
Cause: {error.message}
{onOpenNativeTui && ( )}
) : ( error.message )}
)} {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 && displayTurns.length === 0 ? (
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
) : displayTurns.length === 0 && hasLinkedConversation ? (

Conversation liée — récupération du transcript en cours.

) : displayTurns.length === 0 ? (

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

) : ( displayTurns.map((turn, index) => ( setExpandedProgressKeys((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }) } /> )) )}
{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 ( ); })}
)}