/** * 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, TurnView, } from "@/domain"; import { useGateways } from "@/app/di"; import { useI18n } from "@/i18n"; import { AnnouncementsPreview, shortRestartFailure, useAgentRestart, } from "@/features/announcements"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { markProjectCliInteractionFinished, markProjectCliInteractionStarted, markProjectCliInteractionStopped, } from "@/features/projects/projectCliInteractionActivity"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp, useThreadScrollController, } from "@/shared"; import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons"; import type { ChatAttachmentInput, ChatDraftScope, ConversationTranscriptUnavailableReason, CustomAgentConversationSummary, ProjectIconRef, } 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; /** Start blank when there is no live session; durable threads reopen via the menu. */ freshOnOpen?: boolean; 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 | null } | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number | null } | { role: "tool"; label: string; receivedAtMs?: number | null } | { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string } | { role: "final"; text: string; receivedAtMs?: number | null } | { role: "error"; text: string; receivedAtMs?: number | null } | { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null } | { role: "separator"; text: string } | { role: "unknown"; text: string; receivedAtMs?: number | null }; interface CachedChatView { turns: ChatTurn[]; progressIndex: Map; progressScope: number; chunkCount: number; sessionId?: string; } const chatSessionViewCache = new Map(); const chatConversationViewCache = new Map(); const chatAttachmentDraftCache = new Map(); const chatMessageQueueCache = new Map(); const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24; const CHAT_MESSAGE_QUEUE_CACHE_LIMIT = 24; const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const MODEL_RESPONSE_NOT_FINALIZED_MESSAGE = "La réponse du modèle n'a pas pu être finalisée."; const TURN_FINISHED_WITHOUT_FINAL_MESSAGE = "Tour terminé sans réponse finale."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; const COMPOSER_HISTORY_LIMIT = 100; export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); chatConversationViewCache.clear(); chatAttachmentDraftCache.clear(); chatMessageQueueCache.clear(); } interface AttachmentDraft { id: string; label: string; input: ChatAttachmentInput; previewUrl?: string; dedupeKey?: string; token?: string; } interface QueuedChatMessage { id: string; text: string; attachments: AttachmentDraft[]; createdAtMs: number; } function messageQueueCacheKey( projectId: string, agentId: string, layoutId?: string, nodeId?: string, ): string { return `${projectId}\u0000${agentId}\u0000${layoutId ?? ""}\u0000${nodeId ?? ""}`; } function cacheMessageQueue(key: string, messages: QueuedChatMessage[]): void { chatMessageQueueCache.delete(key); if (messages.length > 0) chatMessageQueueCache.set(key, messages); while (chatMessageQueueCache.size > CHAT_MESSAGE_QUEUE_CACHE_LIMIT) { const oldest = chatMessageQueueCache.keys().next().value as string | undefined; if (!oldest) break; chatMessageQueueCache.delete(oldest); } } export function customAgentChatQueueCount( projectId: string, agentId: string, layoutId?: string, nodeId?: string, ): number { return chatMessageQueueCache.get( messageQueueCacheKey(projectId, agentId, layoutId, nodeId), )?.length ?? 0; } function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null { return scope ? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}` : null; } function cacheAttachmentDraft(key: string, attachments: AttachmentDraft[]): void { chatAttachmentDraftCache.delete(key); if (attachments.length > 0) chatAttachmentDraftCache.set(key, attachments); while (chatAttachmentDraftCache.size > CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT) { const oldest = chatAttachmentDraftCache.keys().next().value as string | undefined; if (!oldest) break; chatAttachmentDraftCache.delete(oldest); } } 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 | null | undefined, ): 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?: number | null, ): { 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) ) { if (turn.receivedAtMs == null && receivedAtMs != null) { const next = [...turns]; next[index] = { ...turn, receivedAtMs }; return { turns: next, appended: false }; } 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 durableHistoryTurns(history: TurnView[]): ChatTurn[] { return history.flatMap((turn): ChatTurn[] => { if (turn.role === "prompt") { return [{ role: "user", text: turn.text, receivedAtMs: turn.atMs }]; } if (turn.role === "response") { return [{ role: "final", text: turn.text, receivedAtMs: turn.atMs }]; } return []; }); } function conversationActivityLabel(atMs: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short", }).format(new Date(atMs)); } 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[]): boolean { if (typeof window === "undefined") return true; try { window.localStorage.setItem( storageKey, JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), ); return true; } catch { return false; } } 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 || turn.text === TURN_FINISHED_WITHOUT_FINAL_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 foldChunk( state: ChatViewState, raw: unknown, receivedAtMs?: number | null, ): ChatViewState { const { turns } = state; if (!isReplyRecord(raw)) { return { ...state, turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], }; } const emittedAtMs = receivedAtMs ?? normalizeTimestamp(typeof raw.atMs === "number" ? raw.atMs : null); 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 ?? ""), emittedAtMs), }; case "toolActivity": return { ...state, turns: [ ...turns, { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs: emittedAtMs }, ], }; case "progress": { const progress = normalizeProgress(raw.progress); if (!progress) { return { ...state, turns: [ ...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }, ], }; } 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: emittedAtMs, }); 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") === MODEL_RESPONSE_NOT_FINALIZED_MESSAGE ? TURN_FINISHED_WITHOUT_FINAL_MESSAGE : String(raw.message ?? "Erreur agent"), receivedAtMs: emittedAtMs, }); return { ...completedState, turns: next }; } case "userPrompt": case "UserPrompt": { const appended = appendUserPrompt( turns, String(raw.text ?? raw.prompt ?? ""), attachmentLabels(raw.attachments), emittedAtMs, ); return { ...state, turns: appended.turns, progressScope: appended.appended ? state.progressScope + 1 : state.progressScope, }; } default: return { ...state, turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }], }; } } export function CustomAgentChatView({ projectId, agentId, agentName, profile, cwd, layoutId, nodeId, sessionId, conversationId, freshOnOpen = false, composerHeightPx = FALLBACK_COMPOSER_HEIGHT, composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT, onComposerHeightPxChange, draftScope: explicitDraftScope, mobile = false, onOpenNativeTui, onSessionId, onConversationId, }: CustomAgentChatViewProps) { const { agent, conversation, profile: profileGateway, project, system, workState } = useGateways(); const { t, tr } = useI18n(); const pluginRuntime = usePluginRuntime(); const initialConversationCacheKey = conversationCacheKey( projectId, agentId, sessionId || !freshOnOpen ? conversationId : null, ); 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); // `freshOnOpen` is reserved for an explicit reset intent. Ordinary React // remounts keep the durable conversation selected by the hosting cell. const [activeConversationId, setActiveConversationId] = useState( sessionId || !freshOnOpen ? conversationId : null, ); const activeConversationIdRef = useRef(activeConversationId); activeConversationIdRef.current = activeConversationId; const [externalSessionId, setExternalSessionId] = useState(sessionId); const initialDraftScope = explicitDraftScope ?? ( layoutId && nodeId ? { projectId, layoutId, nodeId } : null ); const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope); const initialMessageQueueKey = messageQueueCacheKey( projectId, agentId, layoutId, nodeId, ); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState(() => initialAttachmentCacheKey ? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? []) : [], ); const [attachmentPicking, setAttachmentPicking] = useState(false); const [queuedMessages, setQueuedMessages] = useState( () => chatMessageQueueCache.get(initialMessageQueueKey) ?? [], ); const [queueOpen, setQueueOpen] = useState(false); const [clearQueueConfirmation, setClearQueueConfirmation] = useState(false); const [deletedQueueMessage, setDeletedQueueMessage] = useState<{ message: QueuedChatMessage; index: number; } | null>(null); const [queueAnnouncement, setQueueAnnouncement] = useState(""); const [queueDrainReady, setQueueDrainReady] = useState(false); const [terminalWorkStatePending, setTerminalWorkStatePending] = useState(false); 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 || (!freshOnOpen && conversationId)), ); const [liveSessionAttached, setLiveSessionAttached] = useState(false); const [savedConversationLoadState, setSavedConversationLoadState] = useState< "idle" | "loading" | "loaded" | "error" | "notFound" >(!sessionId && !freshOnOpen && conversationId ? "loading" : "idle"); const [openRetryNonce, setOpenRetryNonce] = useState(0); const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState< ConversationTranscriptUnavailableReason | "conversationNotFound" | null >(null); const [busy, setBusy] = useState(false); const [transmissionActive, setTransmissionActive] = useState(false); const [stopping, setStopping] = useState(false); const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [error, setError] = useState(null); const [projectIcon, setProjectIcon] = useState(null); const [conversationMenuOpen, setConversationMenuOpen] = useState(false); const [conversationList, setConversationList] = useState([]); const [conversationListLoading, setConversationListLoading] = useState(false); const [conversationListError, setConversationListError] = useState(null); const [conversationNotice, setConversationNotice] = useState(null); const [resumedConversation, setResumedConversation] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyNextAnchor, setHistoryNextAnchor] = useState(); const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(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 readModelSettledInteractionIdRef = useRef(null); const transmissionInFlightRef = useRef(false); const cancelledInteractionIdsRef = useRef(new Set()); const ignoreUnscopedChunksAfterStopRef = useRef(false); const stopInFlightRef = useRef(false); const stopTerminalOverrideRef = useRef(false); const requesterRefreshSeqRef = useRef(0); const draftHydratedRef = useRef(false); const draftEditedBeforeHydrationRef = useRef(false); const lastPersistedDraftRef = useRef(""); const draftRef = useRef(""); draftRef.current = draft; const draftBeforeHistoryNavigationRef = useRef(""); const placeComposerAtEndAfterHistoryRef = useRef(false); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey); const queuedMessagesRef = useRef(queuedMessages); queuedMessagesRef.current = queuedMessages; const messageQueueKeyRef = useRef(initialMessageQueueKey); 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}`; useEffect(() => { let cancelled = false; void project?.readProjectSettings(projectId).then((settings) => { if (!cancelled) { setProjectIcon(settings.icon ?? null); } }).catch(() => undefined); const unsubscribeIcon = onProjectIconChanged((change) => { if (change.projectId === projectId) setProjectIcon(change.icon); }); return () => { cancelled = true; unsubscribeIcon(); }; }, [project, projectId]); 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 attachmentCacheKey = attachmentDraftCacheKey(draftScope); const messageQueueKey = useMemo( () => messageQueueCacheKey(projectId, agentId, layoutId, nodeId), [agentId, layoutId, nodeId, projectId], ); const currentConversationCacheKey = useMemo( () => conversationCacheKey(projectId, agentId, activeConversationId), [activeConversationId, agentId, projectId], ); const composerHistoryKey = useMemo( () => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId), [agentId, layoutId, nodeId, projectId], ); const [composerHistory, setComposerHistory] = useState(() => readComposerHistory(composerHistoryKey), ); const composerHistoryRef = useRef(composerHistory); const [composerHistoryWarning, setComposerHistoryWarning] = useState(null); const [composerHistoryIndex, setComposerHistoryIndex] = useState(null); const displayTurns = turns; const threadScroll = useThreadScrollController({ itemCount: displayTurns.length, completedItemCount: displayTurns.filter( (turn) => turn.role === "final" || turn.role === "error", ).length, contentVersion: displayTurns .map((turn) => { if ("text" in turn) return `${turn.role}:${turn.text.length}`; if (turn.role === "tool") return `${turn.role}:${turn.label.length}`; return `${turn.role}:${JSON.stringify(turn.progress).length}:${Boolean(turn.settled)}`; }) .join("|"), }); const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final"); 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 || terminalWorkStatePending || Boolean(restart && restart.outcome !== "failed") || (!stopTerminalOverride && (requesterDelegation.currentAgentBusy || delegationActive)); const delegationFallbackText = requesterDelegation.delegationTargetName ? `${agentName} attend ${requesterDelegation.delegationTargetName}` : tr("Délégation en cours.", "Delegation in progress."); const refreshRequesterDelegation = useCallback(async () => { const refreshSeq = ++requesterRefreshSeqRef.current; const interactionAtRefresh = activeInteractionIdRef.current; if (!workState) { if (refreshSeq !== requesterRefreshSeqRef.current) return; setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); if (stopTerminalOverrideRef.current) { stopTerminalOverrideRef.current = false; setStopTerminalOverride(false); } 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, ), ); if (refreshSeq !== requesterRefreshSeqRef.current) return; setRequesterDelegation({ currentAgentBusy, delegationTargetName: target ? workAgentDisplayName(target) : null, }); if ( interactionAtRefresh && activeInteractionIdRef.current === interactionAtRefresh && !currentAgentBusy && !target ) { markProjectCliInteractionFinished(projectId, interactionAtRefresh); activeInteractionIdRef.current = null; readModelSettledInteractionIdRef.current = interactionAtRefresh; stopTerminalOverrideRef.current = true; setStopTerminalOverride(true); setBusy(false); setQueueDrainReady(false); updateChatViewState((previous) => foldChunk(previous, { kind: "error", message: TURN_FINISHED_WITHOUT_FINAL_MESSAGE, }), ); } if (stopTerminalOverrideRef.current && !currentAgentBusy && !target) { stopTerminalOverrideRef.current = false; setStopTerminalOverride(false); } } catch { if (refreshSeq !== requesterRefreshSeqRef.current) return; setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); } }, [agentId, projectId, updateChatViewState, 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 updateQueuedMessages = useCallback( ( updater: | QueuedChatMessage[] | ((current: QueuedChatMessage[]) => QueuedChatMessage[]), ) => { const next = typeof updater === "function" ? updater(queuedMessagesRef.current) : updater; queuedMessagesRef.current = next; cacheMessageQueue(messageQueueKeyRef.current, next); setQueuedMessages(next); if (next.length === 0) { setQueueOpen(false); setClearQueueConfirmation(false); } }, [], ); const recallComposerHistory = useCallback( (direction: "previous" | "next") => { if (composerHistory.length === 0) return; placeComposerAtEndAfterHistoryRef.current = true; 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], ); useEffect(() => { if (!placeComposerAtEndAfterHistoryRef.current) return; placeComposerAtEndAfterHistoryRef.current = false; const composer = composerRef.current; if (!composer) return; const end = composer.value.length; composer.setSelectionRange(end, end); composer.scrollTop = composer.scrollHeight; }, [composerHistoryIndex, draft]); const handleComposerHistoryKeyDown = useCallback( (event: KeyboardEvent): boolean => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing || event.keyCode === 229 || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { return false; } const target = event.currentTarget; const caretIsAtTextEnd = target.selectionStart === target.selectionEnd && target.selectionEnd === target.value.length; if (!caretIsAtTextEnd) { return false; } if (event.key === "ArrowUp" && composerHistory.length === 0) { return false; } if (event.key === "ArrowDown" && composerHistoryIndex === null) { return false; } event.preventDefault(); recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next"); return true; }, [composerHistory.length, composerHistoryIndex, recallComposerHistory], ); useEffect(() => { const restored = readComposerHistory(composerHistoryKey); composerHistoryRef.current = restored; setComposerHistory(restored); setComposerHistoryWarning(null); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; }, [composerHistoryKey]); // Attachments can be large, so keep draft bytes only in this renderer's // bounded lifetime instead of serialising base64 into localStorage. The // project/layout/node scope mirrors the persisted text draft contract. useEffect(() => { const key = attachmentCacheKeyRef.current; if (key) cacheAttachmentDraft(key, attachments); }, [attachments]); useEffect(() => { const previousKey = attachmentCacheKeyRef.current; if (previousKey) { cacheAttachmentDraft(previousKey, attachmentsRef.current); } attachmentCacheKeyRef.current = attachmentCacheKey; setAttachments( attachmentCacheKey ? (chatAttachmentDraftCache.get(attachmentCacheKey) ?? []) : [], ); }, [attachmentCacheKey]); useEffect(() => { const previousKey = messageQueueKeyRef.current; cacheMessageQueue(previousKey, queuedMessagesRef.current); messageQueueKeyRef.current = messageQueueKey; const restored = chatMessageQueueCache.get(messageQueueKey) ?? []; queuedMessagesRef.current = restored; setQueuedMessages(restored); setQueueOpen(false); setClearQueueConfirmation(false); setDeletedQueueMessage(null); setQueueDrainReady(false); }, [messageQueueKey]); 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" ) { // Reconcile through the current work-state snapshot below. A domain // event carries no turn id and may belong to a previously cancelled // generation, so it must never settle a newer local turn directly. 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: tr("------- redémarrage de l’agent -------", "------- restarting agent -------"), }); } else if (event.type === "agentRestartCompleted") { changed = true; setQueueDrainReady(true); nextTurns.push({ role: "restart", key, text: tr("------- redémarrage de l’agent terminé -------", "------- agent restart completed -------"), }); } else { changed = true; nextTurns.push({ role: "restart", key, text: tr("------- redémarrage de l’agent échoué -------", "------- agent restart failed -------"), 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, sourceInteractionId?: string) => { if (sourceInteractionId) { const isLateTerminalForReadModelSettledTurn = activeInteractionIdRef.current === null && readModelSettledInteractionIdRef.current === sourceInteractionId && (chunk.kind === "final" || chunk.kind === "error"); if ( cancelledInteractionIdsRef.current.has(sourceInteractionId) || (activeInteractionIdRef.current !== sourceInteractionId && !isLateTerminalForReadModelSettledTurn) ) { return; } } if (!sourceInteractionId && ignoreUnscopedChunksAfterStopRef.current) { if (chunk.kind === "userPrompt") { ignoreUnscopedChunksAfterStopRef.current = false; } else { return; } } setTranscriptUnavailableReason(null); 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); } } updateChatViewState((prev) => foldChunk(prev, chunk)); if (chunk.kind === "final" || chunk.kind === "error") { if (sourceInteractionId) { readModelSettledInteractionIdRef.current = null; } setQueueDrainReady(chunk.kind === "final"); if (activeInteractionIdRef.current) { markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } setBusy(false); if (workState) { setTerminalWorkStatePending(true); void refreshRequesterDelegation().finally(() => { if (mountedRef.current) setTerminalWorkStatePending(false); }); } else { setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); } } }, [projectId, refreshRequesterDelegation, updateChatViewState, workState], ); 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 publishConversationId = useCallback((nextConversationId: string | null) => { activeConversationIdRef.current = nextConversationId; setActiveConversationId(nextConversationId); onConversationIdRef.current(nextConversationId); }, []); const reattachStructuredSession = useCallback( async ( sid: string, options: { applyScrollback?: boolean; stableIdentity?: boolean; preserveRestartTurns?: boolean; preserveViewOnEmptyScrollback?: 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); setLiveSessionAttached(true); if (reattached.sessionId !== sid) { publishSessionId(reattached.sessionId); } if (reattached.conversationId) { publishConversationId(reattached.conversationId); } if ( options.applyScrollback && (reattached.scrollback.length > 0 || !options.preserveViewOnEmptyScrollback) ) { 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)); } if (reattached.scrollback.length > 0) { setTranscriptUnavailableReason(null); } else if (reattached.transcriptUnavailableReason) { setTranscriptUnavailableReason(reattached.transcriptUnavailableReason); } return reattached.sessionId; }, [agent, agentId, applyChatViewState, projectId, publishConversationId, 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: activeConversationIdRef.current, options, }); let retryAttachNotFound = Boolean(options.retryAttachNotFound); for (;;) { const launchRequest = { cwd, rows: 24, cols: 80, conversationId: options.conversationId === undefined ? activeConversationIdRef.current ?? 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) { publishConversationId(launched.assignedConversationId); } if (launched.resumed) { const replayed = { ...emptyChatViewState(), turns: durableHistoryTurns(launched.history ?? []), }; applyChatViewState(replayed); setResumedConversation(true); setConversationNotice(null); setHistoryHasMore(Boolean(launched.historyHasMore)); setHistoryNextAnchor(launched.historyNextAnchor); setHistoryError(null); } if (launched.transcriptUnavailableReason) { setTranscriptUnavailableReason(launched.transcriptUnavailableReason); } try { const recoveredSessionId = await reattachStructuredSession(launched.sessionId, { applyScrollback: launched.resumed ? false : 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, reattachStructuredSession, applyChatViewState, publishConversationId, publishSessionId, ], ); const prefersReducedMotion = useCallback( () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, [], ); const scrollToLatest = useCallback(() => { threadScroll.scrollToBottom(prefersReducedMotion() ? "auto" : "smooth"); }, [prefersReducedMotion, threadScroll]); const scrollToPreviousFinal = useCallback(() => { const element = threadScroll.scrollRef.current; if (!element) return; const finals = Array.from( element.querySelectorAll("[data-chat-final]"), ); if (finals.length === 0) return; const currentTop = element.scrollTop; const earlier = finals.filter((final) => final.offsetTop < currentTop - 8); const target = earlier.at(-1) ?? finals.at(-1); if (!target) return; const top = Math.max(0, target.offsetTop - 8); if (element.scrollTo) { element.scrollTo({ top, behavior: prefersReducedMotion() ? "auto" : "smooth", }); } else { element.scrollTop = top; } threadScroll.readHistory(); }, [prefersReducedMotion, threadScroll]); useEffect(() => { if (selfEmittedSessionIdRef.current === sessionId) { console.debug("[ticket149] openOrAttach:self-session-echo:skip", { timestamp: new Date().toISOString(), sessionId, }); selfEmittedSessionIdRef.current = undefined; return; } setExternalSessionId(sessionId); setLiveSessionAttached(false); }, [sessionId]); useEffect(() => { if (!supported) return; if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) { setOpening(false); setError(null); setSavedConversationLoadState("idle"); setLiveSessionAttached(false); 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); setTranscriptUnavailableReason(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) { setSavedConversationLoadState("idle"); setLiveSessionAttached(false); 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) { chatSessionViewCache.delete(externalSessionId); const cacheKey = conversationCacheKey( projectId, agentId, activeConversationIdRef.current, ); const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined; if (cacheKey && cached?.sessionId === externalSessionId) { chatConversationViewCache.set(cacheKey, { turns: cached.turns, progressIndex: cached.progressIndex, progressScope: cached.progressScope, chunkCount: cached.chunkCount, }); } setCurrentSession(null); setExternalSessionId(null); setLiveSessionAttached(false); publishSessionId(null); } console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, error: instrumentationError(e), }); return; } } else { const cacheKey = conversationCacheKey( projectId, agentId, activeConversationIdRef.current, ); const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined; if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; applyChatViewState({ turns: cached.turns, progressIndex: cached.progressIndex ?? new Map(), progressScope: cached.progressScope ?? 0, }); } const savedConversationId = activeConversationIdRef.current; if (!savedConversationId) { setSavedConversationLoadState("idle"); setLiveSessionAttached(false); if (freshOnOpen) { await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, conversationId: null, }); } return; } // A cell can remount while its structured runtime remains live (for // example after switching tabs). The layout session can temporarily // be absent, but the conversation cache and live registry still carry // the runtime identity. Reattach that runtime before considering the // durable transcript: a conversation id alone is not a request to // replace an active view with saved history (#370). const cachedSessionId = cached?.sessionId; if (cachedSessionId) { try { const attachedSessionId = await reattachStructuredSession(cachedSessionId, { applyScrollback: true, stableIdentity: true, preserveViewOnEmptyScrollback: true, }); if (cancelled) return; publishSessionId(attachedSessionId); setSavedConversationLoadState("idle"); setResumedConversation(false); return; } catch (e) { if (!isNotFound(e)) throw e; } } const live = agent.listLiveAgents ? (await agent.listLiveAgents(projectId)).find( (candidate) => candidate.agentId === agentId && candidate.kind === "structured", ) : undefined; if (live) { const rebound = nodeId && live.nodeId !== nodeId && agent.attachLiveAgent ? await agent.attachLiveAgent(projectId, agentId, nodeId) : live; const attachedSessionId = await reattachStructuredSession(rebound.sessionId, { applyScrollback: true, preserveViewOnEmptyScrollback: true, }); if (cancelled) return; publishSessionId(attachedSessionId); setSavedConversationLoadState("idle"); setResumedConversation(false); return; } setSavedConversationLoadState("loading"); setLiveSessionAttached(false); try { const page = await conversation.readPage(projectId, savedConversationId, { direction: "backward", limit: 50, }); if (cancelled) return; applyChatViewState({ ...emptyChatViewState(), turns: durableHistoryTurns(page.turns), }); setResumedConversation(true); setConversationNotice(null); setHistoryHasMore(page.hasMore); setHistoryNextAnchor(page.nextAnchor); setHistoryError(null); setSavedConversationLoadState("loaded"); } catch (e) { if (cancelled) return; setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error"); } return; } } catch (e) { console.error("[ticket149] openOrAttach:error", { timestamp: new Date().toISOString(), execution, sessionId: externalSessionId, error: instrumentationError(e), }); if (!cancelled && activeConversationIdRef.current && isNotFound(e)) { setTranscriptUnavailableReason("conversationNotFound"); setCurrentSession(null); publishSessionId(null); } else if (!cancelled) { setError({ message: describeOpenFailure(e), openFailure: true }); } } finally { if (!cancelled) setOpening(false); } } void openOrAttach(); return () => { cancelled = true; }; }, [ supported, externalSessionId, reattachStructuredSession, recoverStructuredSession, publishSessionId, applyChatViewState, conversation, agent, projectId, agentId, nodeId, freshOnOpen, openRetryNonce, ]); const canSubmit = useMemo( () => supported && (Boolean(draft.trim()) || attachments.length > 0) && !opening && savedConversationLoadState !== "error" && savedConversationLoadState !== "notFound", [supported, draft, attachments.length, opening, savedConversationLoadState], ); const unavailableLinkedTranscript = displayTurns.length === 0 && (transcriptUnavailableReason === "missingTranscript" || transcriptUnavailableReason === "conversationNotFound") ? transcriptUnavailableReason : null; const savedConversationLoadFailure = savedConversationLoadState === "error" || savedConversationLoadState === "notFound" ? savedConversationLoadState : null; function retryUnavailableTranscript() { const sid = sessionRef.current; setTranscriptUnavailableReason(null); setSavedConversationLoadState( !sid && activeConversationIdRef.current ? "loading" : "idle", ); setError(null); if (sid) { // Force the next user-triggered pass through the attach path. Keeping the // local session here would trip the #307/idempotency short-circuit. setCurrentSession(null); setExternalSessionId(sid); } setOpenRetryNonce((value) => value + 1); } const refreshConversationList = useCallback(async () => { if (!agent.listCustomAgentConversations) { setConversationListError( tr("Liste des conversations indisponible.", "Conversation list unavailable."), ); return; } setConversationListLoading(true); setConversationListError(null); try { setConversationList( await agent.listCustomAgentConversations(projectId, agentId, 50), ); } catch (e) { setConversationListError(describe(e)); } finally { setConversationListLoading(false); } }, [agent, agentId, projectId, tr]); useEffect(() => { if (conversationMenuOpen) void refreshConversationList(); }, [conversationMenuOpen, refreshConversationList]); async function reopenConversation(nextConversationId: string) { if (effectiveBusy || opening || nextConversationId === activeConversationId) return; setConversationMenuOpen(false); setOpening(true); setError(null); try { const sid = sessionRef.current; if (sid) await agent.closeAgentChat?.(sid); setCurrentSession(null); setExternalSessionId(null); setLiveSessionAttached(false); publishSessionId(null); publishConversationId(nextConversationId); applyChatViewState(emptyChatViewState()); setSavedConversationLoadState("loading"); setResumedConversation(false); setConversationNotice(null); setHistoryHasMore(false); setHistoryNextAnchor(undefined); setOpenRetryNonce((value) => value + 1); } catch (e) { setError({ message: describe(e), openFailure: false }); } finally { setOpening(false); } } async function startNewConversation() { if (effectiveBusy || opening) return; setConversationMenuOpen(false); await executeSlashCommand({ name: "/clean", arguments: [] }); } async function loadOlderHistory() { if (!activeConversationId || !historyNextAnchor || historyLoading) return; setHistoryLoading(true); setHistoryError(null); try { const page = await conversation.readPage(projectId, activeConversationId, { anchor: historyNextAnchor, direction: "backward", limit: 50, }); const older = durableHistoryTurns(page.turns); updateChatViewState((previous) => ({ ...previous, turns: [...older, ...previous.turns], })); setHistoryHasMore(page.hasMore); setHistoryNextAnchor(page.nextAnchor); } catch (e) { setHistoryError(describe(e)); } finally { setHistoryLoading(false); } } 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: tr("Changement de profil indisponible.", "Profile change unavailable.") } : 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: tr(`Profil changé vers ${selected.name}.`, `Profile changed to ${selected.name}.`), receivedAtMs: null, }, ], 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: tr(`Commande ${invocation.name} indisponible dans ce runtime.`, `Command ${invocation.name} is unavailable in this runtime.`), openFailure: false, }); return; } setDraft(""); void clearPersistedDraft(); setError(null); try { const sid = currentSession ?? (await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, ...(invocation.name === "/clean" ? { conversationId: null } : {}), })); 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: null }], })); return; } if (result.effect.kind === "cleanConversation") { applyChatViewState(emptyChatViewState()); setCurrentSession(result.effect.sessionId); setExternalSessionId(result.effect.sessionId); publishSessionId(result.effect.sessionId); publishConversationId(result.effect.conversationId ?? null); setSavedConversationLoadState("idle"); setResumedConversation(false); setHistoryHasMore(false); setHistoryNextAnchor(undefined); setHistoryError(null); setConversationNotice( tr("Nouvelle conversation prête.", "New conversation ready."), ); 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: tr(`Commande plugin ${result.command.name} exécutée.`, `Plugin command ${result.command.name} executed.`), receivedAtMs: null, }, ], })); return; } } catch (e) { const message = `${tr(`Commande ${invocation.name} échouée :`, `Command ${invocation.name} failed:`)} ${describe(e)}`; setError({ message, openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "error", text: message, receivedAtMs: null }], })); } } async function pickAttachment() { setAttachmentPicking(true); try { 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" }, }, ]); } } finally { setAttachmentPicking(false); } } 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 }); } } function rememberComposerText(text: string) { if (text) { const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text); composerHistoryRef.current = nextHistory; const persisted = writeComposerHistory(composerHistoryKey, nextHistory); setComposerHistory(nextHistory); setComposerHistoryWarning( persisted ? null : tr("Historique des prompts non sauvegardé.", "Prompt history was not saved."), ); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; } } function clearComposerAfterSubmit() { setDraft(""); setAttachments([]); imageTokenSeqRef.current = 0; void clearPersistedDraft(); } function enqueueComposerMessage(text: string) { const message: QueuedChatMessage = { id: `${agentId}:queued:${Date.now()}:${Math.random().toString(36).slice(2)}`, text, attachments: [...attachments], createdAtMs: Date.now(), }; updateQueuedMessages((current) => [...current, message]); clearComposerAfterSubmit(); setDeletedQueueMessage(null); setQueueAnnouncement( tr("Message ajouté à la file d’attente", "Message added to the queue"), ); } async function transmitMessage(message: QueuedChatMessage) { const text = message.text; if (!agent.sendAgentChat || transmissionInFlightRef.current) return; setQueueDrainReady(false); const slashInvocation = slashCommandInvocation(text); if (slashInvocation && message.id.endsWith(":composer")) { await executeSlashCommand(slashInvocation); return; } transmissionInFlightRef.current = true; setTransmissionActive(true); const outgoingAttachments = message.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}`; stopTerminalOverrideRef.current = false; setStopTerminalOverride(false); activeInteractionIdRef.current = interactionId; readModelSettledInteractionIdRef.current = null; markProjectCliInteractionStarted(projectId, interactionId); const failInteraction = () => { if (activeInteractionIdRef.current !== interactionId) return; markProjectCliInteractionFinished(projectId, interactionId); activeInteractionIdRef.current = null; if (readModelSettledInteractionIdRef.current === interactionId) { readModelSettledInteractionIdRef.current = null; } }; threadScroll.followLatest(); setBusy(true); setError(null); const continuingResumedConversation = resumedConversation; if (continuingResumedConversation) { setResumedConversation(false); setConversationNotice(null); } updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, ...(continuingResumedConversation ? [{ role: "separator" as const, text: tr("Conversation poursuivie", "Conversation continued") }] : []), { role: "user", text: displayText, receivedAtMs: null, ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ], progressScope: prev.progressScope + 1, })); const sendTurn = (sid: string) => attachmentInputs.length > 0 ? agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId), { attachments: attachmentInputs, }) : agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId)); try { const sid = currentSession ?? (await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, })); await sendTurn(sid); if (cancelledInteractionIdsRef.current.has(interactionId)) return; } catch (e) { if (cancelledInteractionIdsRef.current.has(interactionId)) return; if (isNotFound(e)) { try { setCurrentSession(null); publishSessionId(null); const recoveredSession = await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, }); await sendTurn(recoveredSession); return; } catch (recoveryError) { failInteraction(); setBusy(false); setQueueDrainReady(false); setError({ message: describe(recoveryError), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "error", text: describe(recoveryError), receivedAtMs: null }, ], })); return; } } failInteraction(); setBusy(false); setQueueDrainReady(false); setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }], })); } finally { transmissionInFlightRef.current = false; setTransmissionActive(false); } } async function send() { const text = draft.trim(); if (!canSubmit || !agent.sendAgentChat) return; rememberComposerText(text); if (effectiveBusy || transmissionInFlightRef.current) { enqueueComposerMessage(text); return; } const message: QueuedChatMessage = { id: `${agentId}:${++interactionSeqRef.current}:composer`, text, attachments: [...attachments], createdAtMs: Date.now(), }; clearComposerAfterSubmit(); await transmitMessage(message); } async function stopCurrentRequest() { const sid = sessionRef.current; if ( !sid || !agent.cancelAgentChat || stopping || stopInFlightRef.current || stopTerminalOverrideRef.current ) { return; } stopInFlightRef.current = true; setQueueDrainReady(false); ignoreUnscopedChunksAfterStopRef.current = true; const interactionBeingCancelled = activeInteractionIdRef.current; readModelSettledInteractionIdRef.current = null; if (interactionBeingCancelled) { cancelledInteractionIdsRef.current.add(interactionBeingCancelled); } setStopping(true); setError(null); const settleStoppedRequest = (alreadyStopped: boolean) => { if (stopTerminalOverrideRef.current) return; stopTerminalOverrideRef.current = true; setStopTerminalOverride(true); setRequesterDelegation({ currentAgentBusy: false, delegationTargetName: null, }); 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: alreadyStopped ? tr("Demande déjà arrêtée.", "Request already stopped.") : tr("Demande arrêtée.", "Request stopped."), receivedAtMs: null, }, ], }; }); void refreshRequesterDelegation(); }; try { const result = await agent.cancelAgentChat(sid); settleStoppedRequest(result.outcome !== "cancelled"); } catch (e) { if (isNotFound(e)) { settleStoppedRequest(true); return; } if (interactionBeingCancelled) { cancelledInteractionIdsRef.current.delete(interactionBeingCancelled); } ignoreUnscopedChunksAfterStopRef.current = false; setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, { role: "error", text: `${tr("Échec de l’arrêt de la demande", "Failed to stop request")}: ${describe(e)}`, receivedAtMs: null, }, ], })); } finally { stopInFlightRef.current = false; setStopping(false); } } function editQueuedMessage(message: QueuedChatMessage) { updateQueuedMessages((current) => current.filter((candidate) => candidate.id !== message.id), ); editDraft(message.text); setAttachments(message.attachments); setDeletedQueueMessage(null); setQueueAnnouncement( tr("Message remis dans le champ de saisie", "Message returned to the composer"), ); window.setTimeout(() => composerRef.current?.focus(), 0); } function deleteQueuedMessage(message: QueuedChatMessage) { const index = queuedMessagesRef.current.findIndex( (candidate) => candidate.id === message.id, ); if (index < 0) return; updateQueuedMessages((current) => current.filter((candidate) => candidate.id !== message.id), ); setDeletedQueueMessage({ message, index }); setQueueAnnouncement(tr("Message supprimé", "Message deleted")); } function undoDeleteQueuedMessage() { if (!deletedQueueMessage) return; updateQueuedMessages((current) => { const next = [...current]; next.splice( Math.min(deletedQueueMessage.index, next.length), 0, deletedQueueMessage.message, ); return next; }); setDeletedQueueMessage(null); setQueueAnnouncement(tr("Suppression annulée", "Deletion undone")); } function clearQueuedMessages() { updateQueuedMessages([]); setDeletedQueueMessage(null); setQueueAnnouncement( tr("File d’attente supprimée", "Queue cleared"), ); } useEffect(() => { if ( !queueDrainReady || effectiveBusy || opening || !supported || transmissionActive || queuedMessages.length === 0 ) { return; } const next = queuedMessages[0]; updateQueuedMessages((current) => current[0]?.id === next.id ? current.slice(1) : current.filter((candidate) => candidate.id !== next.id), ); setQueueAnnouncement( tr("Envoi du prochain message en attente", "Sending the next queued message"), ); void transmitMessage(next); }, [effectiveBusy, opening, queueDrainReady, queuedMessages, supported, transmissionActive]); const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId; return (
{agentName}
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
{conversationMenuOpen && (
{effectiveBusy && (

{tr( "Arrêtez la réponse avant de changer de conversation.", "Stop the response before changing conversations.", )}

)}
{conversationListLoading ? (
{tr("Chargement des conversations…", "Loading conversations…")}
) : conversationListError ? (
{tr("Impossible de charger les conversations.", "Unable to load conversations.")}
) : conversationList.length === 0 ? (

{tr("Aucune conversation passée.", "No past conversations.")}

) : ( conversationList.map((item) => { const isOpen = item.conversationId === activeConversationId; return ( ); }) )}
)}
{hasPreviousFinal && ( )} {!supported && (

{t("custom.unsupported")}

)} {error && (
{error.openFailure ? (
{t("custom.startFailure")}
{(!mobile || !/invalid node id/i.test(error.message)) && (
{tr("Cause", "Cause")}: {error.message}
)}
{onOpenNativeTui && ( )}
) : ( error.message )}
)} {profileDialog && (

{t("custom.profile.change")}

{t("custom.profile.warning")}

{profileDialog.error && (

{profileDialog.error}

)}
)}
{liveSessionAttached && delegationActive && (
)} {conversationNotice && (

{conversationNotice}

)} {resumedConversation && (
{tr("Conversation reprise", "Conversation resumed")} {tr( "Historique sauvegardé de cet agent. Vous pouvez poursuivre ci-dessous.", "Saved history for this agent. You can continue below.", )}
)} {resumedConversation && historyHasMore && ( )} {historyError && (

{historyError}

)} {opening && displayTurns.length === 0 ? (
{savedConversationLoadState === "loading" ? tr("Chargement de la conversation…", "Loading conversation…") : externalSessionId ? t("custom.resume") : t("custom.opening")}
) : savedConversationLoadFailure || unavailableLinkedTranscript ? (

{savedConversationLoadFailure === "notFound" || unavailableLinkedTranscript === "conversationNotFound" ? tr( "Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.", "This saved conversation could not be found. You can retry or open a new conversation.", ) : tr( "Impossible de charger cette conversation sauvegardée. Vos messages n'ont pas été supprimés. Réessayez ou ouvrez explicitement une nouvelle conversation.", "Unable to load this saved conversation. Your messages have not been deleted. Retry or explicitly open a new conversation.", )}

) : savedConversationLoadState === "loaded" && displayTurns.length === 0 ? (
{tr( "Cette conversation sauvegardée ne contient aucun message affichable.", "This saved conversation contains no displayable messages.", )}
) : displayTurns.length === 0 ? (
{tr("Nouvelle conversation", "New conversation")} {t("custom.empty")} {" "} {tr( "Vous pouvez aussi ouvrir une conversation passée depuis le menu Conversations.", "You can also open a past conversation from the Conversations menu.", )}
) : ( displayTurns.map((turn, index) => ( setExpandedProgressKeys((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }) } /> )) )}
{threadScroll.notice && ( )}
{queueAnnouncement}
{(queuedMessages.length > 0 || deletedQueueMessage) && (
{queuedMessages.length > 0 && ( )} {deletedQueueMessage && (
{tr("Message supprimé", "Message deleted")}
)} {queueOpen && queuedMessages.length > 0 && (
{tr("Messages en attente", "Queued messages")} · {queuedMessages.length} {clearQueueConfirmation ? (
) : ( )}
    {queuedMessages.map((message, index) => (
  1. {index === 0 ? tr("Prochain", "Next") : tr(`${index + 1}e`, `#${index + 1}`)}

    {message.text || tr("Pièce jointe", "Attachment")}

  2. ))}
)}
)} {attachments.length > 0 && (
{attachments.map((item) => (
{item.previewUrl && ( )} {t("custom.attachment")}: {item.label}
))}
)} {slashMenuOpen && slashCommands.length > 0 && (
{slashCommands.map((command, index) => { const available = isSlashCommandAvailable(command); const selected = index === slashActiveIndex; return ( ); })}
)} {composerHistoryWarning && (

{composerHistoryWarning}

)}