Files
IdeA/frontend/src/features/agents/CustomAgentChatView.tsx
Blomios 50e395b602 fix(application,backend,app-tauri,frontend): réconcilie l'état idle et les Finals après Stop — #357 (QA verte)
Cause: après un cancel manuel de tour, le ticket médié busy/FIFO n'était pas
retiré (l'agent restait Busy, bouton Stop persistant) et un terminal tardif du
tour annulé pouvait bloquer le tour suivant (Final non affiché).

Backend: StructuredSessions lie chaque génération de tour à son ticket médié
(bind_turn_accounting) et request_turn_cancel restitue l'identité (agent,
ticket) exacte observée avant annulation; CancelAgentChat.with_turn_accounting
retire le ticket exact (cancel_head + mark_idle) seulement s'il est toujours
le ticket courant — jamais un successeur déjà remplacé;
StructuredHumanTurnGuard expose accounting_identity et agent_send (app-tauri)
lie la comptabilité au démarrage du tour humain.

Frontend: CustomAgentChatView réconcilie l'interaction active depuis le
work-state (agent idle sans délégation -> interaction terminée, fallback
« Tour terminé sans réponse finale. ») au lieu de laisser un agentBusyChanged
sans tour-id clore un tour plus récent; les terminaux tardifs (final/error) du
tour read-model-settled remplacent le fallback; tour suivant isolé des
callbacks tardifs de la génération précédente.

Tests: cancel_agent_chat (retrait du ticket exact avant le prompt suivant,
nettoyage tardif sans idle sur ticket successeur), CustomAgentChatView
(isolation tour suivant, réconciliation work-state + remplacement fallback par
Final tardif).

Réserve non bloquante: alignement fixture skills préexistant porté par le
commit test dédié 83659d9a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 15:13:03 +02:00

3861 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { 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,
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;
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: "unknown"; text: string; receivedAtMs?: number | null };
interface CachedChatView {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
chunkCount: number;
sessionId?: string;
}
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
const chatMessageQueueCache = new Map<string, QueuedChatMessage[]>();
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<string, unknown> {
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<string, unknown> {
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<string, unknown>;
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<ChatTurn, { role: "progress" }>): 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<ChatTurn, { role: "progress" }>) => 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<string, ProgressIndexEntry>;
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[]): 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<ChatTurn, { role: "progress" }>,
completedEntities: Set<string | null>,
): 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<ChatTurn, { role: "progress" }>,
): 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<ChatTurn, { role: "progress" }> =>
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<AttachmentDraft> {
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<string>();
return [...fromItems, ...clipboardFiles].filter((file) => {
const key = fileMetadataKey(file);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function asyncClipboardImageFiles(): Promise<File[]> {
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,
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
draftScope: explicitDraftScope,
mobile = false,
onOpenNativeTui,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, project, system, workState } = useGateways();
const { t, tr } = useI18n();
const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey(
projectId,
agentId,
conversationId,
);
const [turns, setTurns] = useState<ChatTurn[]>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.turns ?? [])
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? [])
: [],
);
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map())
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
: new Map(),
);
const [progressScope, setProgressScope] = useState<number>(
() =>
sessionId
? (chatSessionViewCache.get(sessionId)?.progressScope ?? 0)
: initialConversationCacheKey
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressScope ?? 0)
: 0,
);
const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>(
() => new Set(),
);
const [currentSession, setCurrentSession] = useState(sessionId);
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<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
() => 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 [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
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<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns);
const progressIndexRef = useRef(progressIndex);
const progressScopeRef = useRef(progressScope);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const scrollbackChunkCountRef = useRef(
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
);
const detachAgentChatRef = useRef<((sessionId: string) => Promise<void>) | 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<string | null>(null);
const readModelSettledInteractionIdRef = useRef<string | null>(null);
const transmissionInFlightRef = useRef(false);
const cancelledInteractionIdsRef = useRef(new Set<string>());
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<AttachmentDraft[]>([]);
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<string | null | undefined>(undefined);
const reconciledRestartIdsRef = useRef(new Set<string>());
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<HTMLElement>("[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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<ChatDraftScope | null>(() => {
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, conversationId),
[agentId, conversationId, projectId],
);
const composerHistoryKey = useMemo(
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
[agentId, layoutId, nodeId, projectId],
);
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
readComposerHistory(composerHistoryKey),
);
const composerHistoryRef = useRef(composerHistory);
const [composerHistoryWarning, setComposerHistoryWarning] = useState<string | null>(null);
const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(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<RequesterDelegationState>({
currentAgentBusy: false,
delegationTargetName: null,
});
const { restart, events: restartEvents } = useAgentRestart(agentId);
const delegationActive = requesterDelegation.delegationTargetName !== null;
const effectiveBusy =
busy ||
stopping ||
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<HTMLTextAreaElement>): 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;
}
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
setBusy(false);
void refreshRequesterDelegation();
}
},
[projectId, refreshRequesterDelegation, 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<ReturnType<NonNullable<typeof agent.reattachAgentChat>>>;
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));
}
if (reattached.scrollback.length > 0) {
setTranscriptUnavailableReason(null);
} else if (reattached.transcriptUnavailableReason) {
setTranscriptUnavailableReason(reattached.transcriptUnavailableReason);
}
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<ReturnType<NonNullable<typeof agent.launchAgentChat>>>;
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);
}
if (launched.transcriptUnavailableReason) {
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
}
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,
],
);
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<HTMLElement>("[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);
}, [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);
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) {
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 && conversationId && 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,
currentConversationCacheKey,
conversationId,
openRetryNonce,
]);
const canSubmit = useMemo(
() =>
supported &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!opening,
[supported, draft, attachments.length, opening],
);
const unavailableLinkedTranscript =
displayTurns.length === 0 &&
(transcriptUnavailableReason === "missingTranscript" ||
transcriptUnavailableReason === "conversationNotFound")
? transcriptUnavailableReason
: null;
function unlinkUnavailableConversation() {
onConversationIdRef.current(null);
setTranscriptUnavailableReason("emptyConversation");
setError(null);
}
function retryUnavailableTranscript() {
const sid = sessionRef.current;
setTranscriptUnavailableReason(null);
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);
}
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,
}));
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());
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() {
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<HTMLTextAreaElement>,
) {
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);
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
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 (
<div
ref={chatRootRef}
data-testid="custom-agent-chat-view"
className="custom-agent-chat-view relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
>
<div
role="toolbar"
aria-label={t("custom.actions")}
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-sm font-medium">{agentName}</div>
<div className="truncate text-xs text-muted">
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
</div>
</div>
</div>
{hasPreviousFinal && (
<button
type="button"
aria-label={tr("Aller au Final précédent", "Go to previous Final")}
className="absolute left-1/2 top-12 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
onClick={scrollToPreviousFinal}
>
↑
</button>
)}
{!supported && (
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
{t("custom.unsupported")}
</p>
)}
{error && (
<div
role="alert"
className="relative z-10 m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
<div className="font-medium">{t("custom.startFailure")}</div>
{(!mobile || !/invalid node id/i.test(error.message)) && (
<div className="break-words">
{tr("Cause", "Cause")}: {error.message}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
{t("common.retry")}
</Button>
{onOpenNativeTui && (
<Button
size="sm"
variant="secondary"
onClick={onOpenNativeTui}
>
{t("custom.openTui")}
</Button>
)}
</div>
</div>
) : (
error.message
)}
</div>
)}
{profileDialog && (
<div
role="dialog"
aria-modal="true"
aria-labelledby={`profile-command-title-${domId}`}
className={cn(
"relative z-30 border border-border bg-surface p-3 shadow-lg",
mobile
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "m-3 rounded-md",
)}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2
id={`profile-command-title-${domId}`}
className="text-sm font-semibold text-content"
>
{t("custom.profile.change")}
</h2>
<p className="mt-1 text-xs text-muted">
{t("custom.profile.warning")}
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
{t("common.close")}
</Button>
</div>
<div className="mt-3 flex min-w-0 flex-col gap-2">
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
{t("custom.profile")}
<select
aria-label={tr("Profil cible", "Target profile")}
value={profileDialog.selectedProfileId}
disabled={profileDialog.busy}
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
onChange={(event) =>
setProfileDialog((prev) =>
prev
? {
...prev,
selectedProfileId: event.target.value,
confirmedReset: false,
}
: prev,
)
}
>
{profileDialog.profiles.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</label>
<label className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={profileDialog.confirmedReset}
disabled={profileDialog.busy}
onChange={(event) =>
setProfileDialog((prev) =>
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
)
}
/>
<span>{tr("Je confirme que la session courante sera réinitialisée.", "I confirm that the current session will be reset.")}</span>
</label>
{profileDialog.error && (
<p role="alert" className="text-xs text-danger">
{profileDialog.error}
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
disabled={!profileDialog.confirmedReset || profileDialog.busy}
loading={profileDialog.busy}
onClick={() => void confirmProfileCommandChange()}
>
{tr("Réinitialiser et changer le profil", "Reset and change profile")}
</Button>
</div>
</div>
</div>
)}
<div className="project-cell-illustration-area relative z-10 flex min-h-0 flex-1 basis-0 overflow-hidden">
<div
data-testid="project-icon-watermark"
aria-hidden="true"
className="project-cell-illustration pointer-events-none absolute z-0 flex items-center justify-center overflow-hidden"
style={{ pointerEvents: "none" }}
>
<ProjectIcon
icon={projectIcon}
fallbackLabel={projectFallbackLabel}
surface="watermark"
size="100%"
decorative
className="project-cell-illustration__glyph"
/>
</div>
<div
ref={threadScroll.scrollRef}
data-testid="custom-agent-chat-scroll"
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
onScroll={threadScroll.onScroll}
>
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive={delegationActive}
fallbackText={delegationFallbackText}
/>
</div>
{opening && displayTurns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{externalSessionId ? t("custom.resume") : t("custom.opening")}
</span>
</div>
) : unavailableLinkedTranscript ? (
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
<p className="text-sm font-medium text-content">
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")}
</p>
<p className="text-xs text-muted">
{unavailableLinkedTranscript === "conversationNotFound"
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={retryUnavailableTranscript}
>
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
{tr("Délier la conversation", "Unlink conversation")}
</Button>
</div>
</div>
) : displayTurns.length === 0 ? (
<p className="project-cell-empty-label text-xs text-muted">
{t("custom.empty")}
</p>
) : (
displayTurns.map((turn, index) => (
<ChatBubble
key={index}
turn={turn}
requesterName={agentName}
sessionLabel={sessionLabel}
expandedProgressKeys={expandedProgressKeys}
onToggleProgressExpanded={(key) =>
setExpandedProgressKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
})
}
/>
))
)}
</div>
</div>
<div
data-testid="custom-agent-chat-composer"
className={cn(
"relative z-10 flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
{threadScroll.notice && (
<button
type="button"
aria-label={tr("Aller au dernier message", "Go to latest message")}
className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 rounded-full border border-border bg-raised/95 px-3 py-1 text-xs text-content shadow-md hover:border-border-strong"
onClick={scrollToLatest}
>
{threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{queueAnnouncement}
</div>
{(queuedMessages.length > 0 || deletedQueueMessage) && (
<div className="relative flex min-w-0 items-center gap-2">
{queuedMessages.length > 0 && (
<button
type="button"
className="flex min-h-11 items-center rounded-md border border-border bg-surface px-3 text-xs font-semibold text-content hover:border-border-strong"
aria-expanded={queueOpen}
aria-controls={`message-queue-${domId}`}
onClick={() => setQueueOpen((open) => !open)}
>
{tr("En attente", "Queued")} · {queuedMessages.length}
</button>
)}
{deletedQueueMessage && (
<div role="status" className="flex min-h-11 min-w-0 items-center gap-2 text-xs text-muted">
<span>{tr("Message supprimé", "Message deleted")}</span>
<button
type="button"
className="min-h-11 px-2 font-semibold text-primary"
onClick={undoDeleteQueuedMessage}
>
{tr("Annuler", "Undo")}
</button>
</div>
)}
{queueOpen && queuedMessages.length > 0 && (
<div
id={`message-queue-${domId}`}
role="region"
aria-label={tr("File d’attente des messages", "Message queue")}
className="absolute bottom-full left-0 z-30 mb-2 flex max-h-[min(22rem,60vh)] w-[min(34rem,calc(100vw-2rem))] max-w-full flex-col overflow-hidden rounded-md border border-border bg-raised shadow-xl"
>
<div className="flex min-h-11 items-center justify-between gap-2 border-b border-border px-3">
<strong className="text-xs">
{tr("Messages en attente", "Queued messages")} · {queuedMessages.length}
</strong>
{clearQueueConfirmation ? (
<div className="flex items-center gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs text-muted"
onClick={() => setClearQueueConfirmation(false)}
>
{tr("Annuler", "Cancel")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-danger"
onClick={clearQueuedMessages}
>
{tr("Confirmer", "Confirm")}
</button>
</div>
) : (
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => setClearQueueConfirmation(true)}
>
{tr("Tout supprimer", "Clear all")}
</button>
)}
</div>
<ol className="min-h-0 overflow-y-auto p-1">
{queuedMessages.map((message, index) => (
<li key={message.id} className="flex min-w-0 gap-2 rounded px-2 py-2 hover:bg-surface">
<span className="w-14 shrink-0 pt-1 text-[11px] font-semibold text-primary">
{index === 0
? tr("Prochain", "Next")
: tr(`${index + 1}e`, `#${index + 1}`)}
</span>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-content">
{message.text || tr("Pièce jointe", "Attachment")}
</p>
<div className="mt-1 flex flex-wrap gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-primary"
onClick={() => editQueuedMessage(message)}
>
{tr("Modifier", "Edit")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => deleteQueuedMessage(message)}
>
{tr("Supprimer", "Delete")}
</button>
</div>
</div>
</li>
))}
</ol>
</div>
)}
</div>
)}
{attachments.length > 0 && (
<div className="flex min-w-0 flex-wrap gap-2">
{attachments.map((item) => (
<div
key={item.id}
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
>
{item.previewUrl && (
<img
src={item.previewUrl}
alt=""
data-testid={`attachment-preview-${item.label}`}
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
<span className="min-w-0 truncate">{t("custom.attachment")}: {item.label}</span>
<button
type="button"
aria-label={tr(`Retirer ${item.label}`, `Remove ${item.label}`)}
className="shrink-0 text-content"
onClick={() => removeAttachment(item)}
>
{t("custom.removeAttachment")}
</button>
</div>
))}
</div>
)}
{slashMenuOpen && slashCommands.length > 0 && (
<div
id={`slash-command-menu-${domId}`}
role="listbox"
aria-label={t("custom.slashSuggestions")}
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
>
{slashCommands.map((command, index) => {
const available = isSlashCommandAvailable(command);
const selected = index === slashActiveIndex;
return (
<button
key={command.name}
type="button"
id={`slash-command-${domId}-${index}`}
role="option"
aria-selected={selected}
aria-disabled={!available}
className={cn(
"flex w-full min-w-0 items-start gap-3 px-2.5 py-1.5 text-left text-xs",
selected ? "bg-primary/15 text-content" : "text-content",
!available && "cursor-not-allowed opacity-55",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSlashCommand(command)}
>
<span className="shrink-0 font-semibold">{command.name}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{command.shortDescription}
</span>
{command.requiresConfirmation && (
<span className="shrink-0 text-muted">{tr("confirmation", "confirm")}</span>
)}
</button>
);
})}
</div>
)}
{composerHistoryWarning && (
<p role="status" className="mb-1 text-[11px] text-warning">
{composerHistoryWarning}
</p>
)}
<div className="flex min-w-0 items-end gap-2">
<div className="relative flex min-w-0 flex-1">
<textarea
ref={composerRef}
aria-label={`${tr("Message CLI personnalisée", "Custom CLI message")} ${domId}`}
aria-controls={slashMenuOpen ? `slash-command-menu-${domId}` : undefined}
aria-activedescendant={
slashMenuOpen ? `slash-command-${domId}-${slashActiveIndex}` : undefined
}
className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 pr-7 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
value={draft}
disabled={!supported || opening}
placeholder={t("custom.message")}
style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)}
onPaste={(e) => void pasteClipboardFiles(e)}
onKeyDown={(e) => {
if (slashMenuOpen && slashCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashActiveIndex(
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuOpen(false);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const command = slashCommands[slashActiveIndex];
if (command) selectSlashCommand(command);
return;
}
}
if (handleComposerHistoryKeyDown(e)) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<button
type="button"
aria-label={`${tr("Redimensionner l’éditeur", "Resize composer")} ${domId}`}
title={t("custom.resize")}
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
style={{ cursor: "ns-resize" }}
onMouseDown={startComposerResize}
onDoubleClick={(e) => {
e.preventDefault();
commitComposerHeight(composerDefaultHeightPx);
}}
onKeyDown={handleComposerResizeKeyDown}
>
↕
</button>
</div>
<Button
size="sm"
variant="ghost"
className="shrink-0 whitespace-nowrap"
disabled={!supported || opening}
onClick={() => void pickAttachment()}
>
{t("custom.attach")}
</Button>
{effectiveBusy && (
<Button
size="sm"
variant="danger"
className="min-h-11 shrink-0 whitespace-nowrap"
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
loading={stopping}
title={t("custom.stop")}
aria-label={t("custom.stop")}
onClick={() => void stopCurrentRequest()}
>
{stopping ? t("custom.stopping") : t("custom.stop")}
</Button>
)}
<Button
size="sm"
className="min-h-11 shrink-0 whitespace-nowrap"
disabled={!canSubmit}
onClick={() => void send()}
>
{effectiveBusy
? tr("Mettre en attente", "Queue message")
: t("custom.send")}
</Button>
</div>
</div>
</div>
);
}
function ChatBubble({
turn,
requesterName,
sessionLabel,
expandedProgressKeys,
onToggleProgressExpanded,
}: {
turn: ChatTurn;
requesterName: string;
sessionLabel: string | null;
expandedProgressKeys: Set<string>;
onToggleProgressExpanded: (key: string) => void;
}) {
const { tr } = useI18n();
if (turn.role === "restart") {
return (
<div
data-testid="agent-restart-separator"
className={cn(
"flex min-w-0 flex-col items-center gap-1 py-1 text-xs",
turn.failed ? "text-danger" : "text-muted",
)}
>
<div className="flex items-baseline gap-2">
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</div>
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
</div>
);
}
if (turn.role === "tool") {
return (
<div className="flex min-w-0 justify-center">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised/80 px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</span>
</div>
);
}
if (turn.role === "progress") {
const key = turnProgressKey(turn);
return (
<ProgressBubble
progress={turn.progress}
requesterName={requesterName}
progressKey={key}
expanded={expandedProgressKeys.has(key)}
onToggleExpanded={() => onToggleProgressExpanded(key)}
/>
);
}
if (turn.role === "final") {
return (
<div data-chat-final="true" className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<ChatMessageMeta
label={tr("Terminé", "Completed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-success"
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
);
}
if (turn.role === "error" || turn.role === "unknown") {
return (
<div className="flex min-w-0 justify-center">
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "error" && (
<ChatMessageMeta
label={tr("Échec", "Failed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-danger"
/>
)}
{turn.role === "error" && sessionLabel && (
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
)}
{turn.role === "unknown" ? tr("Fragment inconnu reçu : ", "Unknown chunk received: ") : ""}
<span className="whitespace-pre-wrap break-words">{turn.text === "Erreur agent" ? tr("Erreur agent", "Agent error") : turn.text}</span>
</div>
</div>
);
}
const user = turn.role === "user";
return (
<div className="flex min-w-0 justify-start">
<div
className={cn(
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised/85" : "border-primary/25 bg-primary/10",
)}
>
<ChatMessageMeta
label={user ? tr("Vous", "You") : requesterName}
receivedAtMs={turn.receivedAtMs}
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => (
<p key={attachment} className="truncate">
{tr("Fichier", "File")}: {attachment}
</p>
))}
</div>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
{tr("Réponse en cours", "Reply in progress")}
</span>
)}
</div>
</div>
);
}
function ChatMessageMeta({
label,
receivedAtMs,
labelClassName,
}: {
label: string;
receivedAtMs: number | null | undefined;
labelClassName?: string;
}) {
return (
<div className="mb-1 flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className={cn("min-w-0 truncate font-medium text-muted", labelClassName)}>
{label}
</span>
<EntryTimestamp atMs={receivedAtMs} />
</div>
);
}
type Tr = (french: string, english: string) => string;
function progressTitle(progress: ReplyProgress, tr: Tr): string {
if (isDelegationProgress(progress)) return tr("Délégation", "Delegation");
if (progress.kind === "mcp") return "MCP";
if (progress.kind === "tool") return tr("Outil", "Tool");
if (progress.kind === "message") return tr("Progression", "Progress");
if (progress.kind === "turn") return tr("Tour", "Turn");
return tr("Activité", "Activity");
}
function progressSourceLabel(progress: ReplyProgress, tr: Tr): string {
if (progress.source === "ideaLocal") return "IdeA";
return progress.provider ?? tr("Fournisseur", "Provider");
}
function progressStageLabel(stage: ReplyProgressStage, tr: Tr): string {
switch (stage) {
case "started":
return tr("En cours", "In progress");
case "delta":
return tr("En cours", "In progress");
case "completed":
return tr("Terminé", "Completed");
case "info":
return "info";
}
}
function progressTone(progress: ReplyProgress): string {
if (isDelegationProgress(progress)) {
return "border-primary/45 bg-primary/10 text-content";
}
if (progress.kind === "mcp") {
return "border-warning/45 bg-warning/10 text-content";
}
if (progress.kind === "tool") {
return "border-border bg-raised/70 text-content";
}
return "border-border/80 bg-surface/70 text-muted";
}
function isCenteredToolProgress(progress: ReplyProgress): boolean {
return (
progress.kind === "tool" ||
progress.kind === "mcp" ||
isDelegationProgress(progress)
);
}
function isTimelineProgress(progress: ReplyProgress): boolean {
return progress.kind === "turn";
}
function compactToolName(progress: ReplyProgress): string {
return progress.toolName ?? progress.label;
}
function previewText(text: string, maxLineLength = 160): string {
return text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 4)
.map((line) =>
line.length > maxLineLength
? `${line.slice(0, Math.max(0, maxLineLength - 3))}...`
: line,
)
.join("\n");
}
function parseKeyValue(text: string, key: string): string | null {
const match = new RegExp(
`(?:^|\\s)${key}=([^\\n]+?)(?=\\s\\w+=|$)`,
"i",
).exec(text);
return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null;
}
function delegationSummary(progress: ReplyProgress): {
requester: string | null;
target: string | null;
detail: string | null;
excerpt: string | null;
} {
const text = progress.text ?? "";
const requester = parseKeyValue(text, "requester");
const target = parseKeyValue(text, "target");
const task = parseKeyValue(text, "task");
const fallback = text && !target && !task ? text : null;
return {
requester,
target,
detail: task ?? fallback,
excerpt: task
? previewText(task, 92)
: fallback
? previewText(fallback, 92)
: null,
};
}
const fourLineClamp = {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 4,
} as const;
function useIsTextTruncated(
ref: RefObject<HTMLElement>,
content: string,
expanded: boolean,
) {
const [truncated, setTruncated] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => {
const previousDisplay = element.style.display;
const previousBoxOrient = element.style.webkitBoxOrient;
const previousLineClamp = element.style.webkitLineClamp;
if (expanded) {
element.style.display = "-webkit-box";
element.style.webkitBoxOrient = "vertical";
element.style.webkitLineClamp = "4";
}
setTruncated(element.scrollHeight > element.clientHeight + 1);
if (expanded) {
element.style.display = previousDisplay;
element.style.webkitBoxOrient = previousBoxOrient;
element.style.webkitLineClamp = previousLineClamp;
}
};
measure();
const frame = window.requestAnimationFrame(measure);
window.addEventListener("resize", measure);
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(element);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", measure);
observer?.disconnect();
};
}, [content, expanded, ref]);
return truncated;
}
function ExpandableProgressDetail({
content,
className,
expanded,
onToggle,
}: {
content: string;
className?: string;
expanded: boolean;
onToggle: () => void;
}) {
const { tr } = useI18n();
const contentId = useId();
const detailRef = useRef<HTMLParagraphElement>(null);
const truncated = useIsTextTruncated(detailRef, content, expanded);
const showToggle = truncated;
return (
<div className="mt-1 min-w-0">
<p
id={contentId}
ref={detailRef}
data-testid="chat-progress-detail"
className={cn(
"overflow-hidden whitespace-pre-wrap break-words text-[11px] text-content",
className,
)}
style={expanded ? undefined : fourLineClamp}
>
{content}
</p>
{showToggle && (
<button
type="button"
className={cn(
"mt-1 text-[11px] font-medium text-primary underline-offset-2 hover:underline",
className?.includes("text-center") && "mx-auto block",
)}
aria-expanded={expanded}
aria-controls={contentId}
onClick={onToggle}
>
{expanded ? tr("Afficher moins", "Show less") : tr("Afficher plus", "Show more")}
</button>
)}
</div>
);
}
function TurnMarker({ progress }: { progress: ReplyProgress }) {
const { tr } = useI18n();
const stage = progressStageLabel(progress.stage, tr);
const label = progress.label.trim() || tr("Tour", "Turn");
return (
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
<span className="shrink-0 rounded-full border border-border bg-surface px-2 py-0.5">
{stage}
</span>
<span className="truncate">{label}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
</div>
</div>
);
}
function ToolProgressCard({
progress,
requesterName,
progressKey,
expanded,
onToggleExpanded,
}: {
progress: ReplyProgress;
requesterName: string;
progressKey: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { tr } = useI18n();
const stage = progressStageLabel(progress.stage, tr);
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const detail = delegation
? delegation.detail
: progress.text?.trim() || null;
const title = delegation
? delegation.target
? `${delegation.requester ?? requesterName} ${
progress.stage === "completed" ? "←" : "→"
} ${delegation.target}`
: tr("Appel inter-agent", "Inter-agent call")
: progressTitle(progress, tr);
return (
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
<div
className={cn(
"min-w-0 max-w-[72%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress),
)}
>
<div className="flex min-w-0 items-center justify-center gap-1.5">
<span className="shrink-0 font-semibold">{title}</span>
{!delegation && (
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
)}
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
{delegation?.excerpt && !detail && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
{delegation.excerpt}
</p>
)}
{delegation && (
<p className="mt-0.5 truncate text-center text-[11px] text-muted">
{compactToolName(progress)}
</p>
)}
{(progress.source || progress.nativeEvent || progress.provider) && (
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{progressSourceLabel(progress, tr)}</span>
{progress.nativeEvent && (
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
)}
{detail && (
<ExpandableProgressDetail
key={progressKey}
content={detail}
className="text-center"
expanded={expanded}
onToggle={onToggleExpanded}
/>
)}
</div>
</div>
);
}
function ProgressBubble({
progress,
requesterName,
progressKey,
expanded,
onToggleExpanded,
}: {
progress: ReplyProgress;
requesterName: string;
progressKey: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { tr } = useI18n();
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) {
return (
<ToolProgressCard
progress={progress}
requesterName={requesterName}
progressKey={progressKey}
expanded={expanded}
onToggleExpanded={onToggleExpanded}
/>
);
}
const title = progressTitle(progress, tr);
const stage = progressStageLabel(progress.stage, tr);
const source = progressSourceLabel(progress, tr);
const detail = progress.text?.trim() || null;
return (
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
<div
className={cn(
"min-w-0 max-w-[58%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress),
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 font-semibold">{title}</span>
<span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{source}</span>
{progress.nativeEvent && (
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
{detail && (
<ExpandableProgressDetail
key={progressKey}
content={detail}
expanded={expanded}
onToggle={onToggleExpanded}
/>
)}
</div>
</div>
);
}