Files
IdeA/frontend/src/features/agents/CustomAgentChatView.tsx
Blomios 1b1476de52 fix(chat): le transcript s'affiche à nouveau après redémarrage automatique d'un agent — #295 (app-tauri ChatBridge: cartes successors/owners, rebind_session déplace sink+scrolloback du logical chat vers la session de remplacement et canonical_session résout les écritures avec l'ancien id sur toutes les opérations register/scrollback/detach/send_output; backend stream: ReplayOutputBridge::rekey prépare le scrollback source devant le destination et invalide les générations pré-rekey des deux pumps; commands: agent_send binde l'owner stable (projectId,agentId) au tour humain et rebind avant le flux de continuation au handoff restart; reattach_agent_chat résout le successeur du bridge puis fallback identité stable projectId/agentId explicite ou owner bindé, renvoie sessionId effectif + conversationId, NOT_FOUND sinon; web-server chat.attach même fallback stale→live par project/agent; DTO ReattachChatDto/ReattachResultDto documentent l'id effectif post-restart; frontend: ReattachAgentChatOptions, conversationId dans ChatAttachedPayload, CustomAgentChatView réattache sur agentRestartCompleted (dédupe par restartId, scrollback appliqué, tours restart préservés), retry identité stable sur NOT_FOUND dans openOrAttach avant fallback launch frais, publication sessionId effectif + conversationId, état vide «Conversation liée» quand une conversation/run/workstate lié existe; QA verte reconfirmée par Git: git diff --check, cargo fmt --all --check, app-tauri chat_bridge 34/34 dont 3 nouveaux restart_rebind + dto_chat 24/24, cargo check -p app-tauri, backend 91+7 ignored permissions socket, web-server websocket_chat_attach 3/3, npm test 77/77 CustomAgentChatView+agentGateway, npm run build; réserves non bloquantes: warnings Vite esbuild/oxc et chunk >500kB connus)
2026-09-09 18:44:01 +02:00

3231 lines
102 KiB
TypeScript

/**
* Custom agent CLI for structured/headless profiles (#147).
*
* This is an alternative human view for an agent cell, not a replacement for
* the native TUI. It uses the structured chat commands when available and
* deliberately does not try to parse PTY bytes.
*/
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
type MouseEvent as ReactMouseEvent,
type RefObject,
} from "react";
import type {
AgentProfile,
GatewayError,
ReplyChunk,
ReplyProgress,
ReplyProgressKind,
ReplyProgressSource,
ReplyProgressStage,
SlashCommand,
} from "@/domain";
import { useGateways } from "@/app/di";
import {
AnnouncementsPreview,
shortRestartFailure,
useAgentRestart,
} from "@/features/announcements";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import {
markProjectCliInteractionFinished,
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity";
import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
export interface CustomAgentChatViewProps {
projectId: string;
agentId: string;
agentName: string;
profile: AgentProfile;
cwd: string;
layoutId?: string;
nodeId?: string;
sessionId: string | null;
conversationId: string | null;
composerHeightPx?: number;
composerDefaultHeightPx?: number;
onComposerHeightPxChange?: (heightPx: number) => void;
draftScope?: ChatDraftScope | null;
mobile?: boolean;
onOpenNativeTui?: () => void;
onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void;
}
type ChatTurn =
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
| { role: "tool"; label: string; receivedAtMs?: number }
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
| { role: "final"; text: string; receivedAtMs?: number }
| { role: "error"; text: string; receivedAtMs?: number }
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean }
| { role: "unknown"; text: string; receivedAtMs?: number };
interface CachedChatView {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
chunkCount: number;
sessionId?: string;
}
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
const COMPOSER_HISTORY_LIMIT = 100;
const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1);
const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
);
const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
);
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
chatConversationViewCache.clear();
}
interface AttachmentDraft {
id: string;
label: string;
input: ChatAttachmentInput;
previewUrl?: string;
dedupeKey?: string;
token?: string;
}
interface ProgressIndexEntry {
firstOccurredAtMs: number;
sequence: number;
}
interface ProfileCommandDialogState {
profiles: AgentProfile[];
selectedProfileId: string;
confirmedReset: boolean;
busy: boolean;
error: string | null;
}
interface SlashCommandInvocation {
name: string;
arguments: unknown[];
}
interface ChatErrorState {
message: string;
openFailure: boolean;
}
interface RequesterDelegationState {
currentAgentBusy: boolean;
delegationTargetName: string | null;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function describeOpenFailure(e: unknown): string {
const message = describe(e).trim();
return message || "Cause technique non disponible.";
}
function workAgentDisplayName(agent: { agentId: string; name: string }): string {
return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`;
}
function structuredSessionLabel(
adapter: AgentProfile["structuredAdapter"],
): string | null {
switch (adapter) {
case "codex":
return "Session Codex";
case "claude":
return "Session Claude";
case "openCode":
return "Session OpenCode";
case "openAiCompatible":
return "Session HTTP";
default:
return null;
}
}
function instrumentationError(e: unknown): Record<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,
): ChatTurn[] {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") {
next[next.length - 1] = {
role: "agent",
text: last.text + text,
pending: true,
receivedAtMs: last.receivedAtMs ?? receivedAtMs,
};
return next;
}
next.push({ role: "agent", text, pending: true, receivedAtMs });
return next;
}
function attachmentLabels(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
: [];
}
function appendUserPrompt(
turns: ChatTurn[],
text: string,
attachments: string[] = [],
receivedAtMs = Date.now(),
): { turns: ChatTurn[]; appended: boolean } {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "final" || turn.role === "error") break;
if (turn.role !== "user") continue;
if (
turn.text === text &&
JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments)
) {
return { turns, appended: false };
}
break;
}
return {
turns: [
...turns,
{
role: "user",
text,
receivedAtMs,
...(attachments.length > 0 ? { attachments } : {}),
},
],
appended: true,
};
}
function isReplyProgressSource(value: unknown): value is ReplyProgressSource {
return value === "providerNative" || value === "ideaLocal";
}
function isReplyProgressKind(value: unknown): value is ReplyProgressKind {
return (
value === "turn" ||
value === "message" ||
value === "tool" ||
value === "mcp" ||
value === "other"
);
}
function isReplyProgressStage(value: unknown): value is ReplyProgressStage {
return (
value === "started" ||
value === "delta" ||
value === "completed" ||
value === "info"
);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeProgress(raw: unknown): ReplyProgress | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<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[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
);
} catch {
/* local history only */
}
}
function appendComposerHistoryEntry(history: string[], text: string): string[] {
const entry = text.trim();
if (!entry) return history;
const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history;
return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT);
}
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
let key = progressUpsertKey(progress, state.progressScope);
let existingIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
);
const activeKey =
!progress.progressId && progress.stage === "completed"
? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope)
: null;
if (activeKey) {
const activeIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey,
);
if (activeIndex >= 0) {
existingIndex = activeIndex;
key = activeKey;
}
}
const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null;
if (completedDedupKey) {
existingIndex = findLastProgressIndex(
state.turns,
(turn) =>
turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) &&
legacyCompletedDedupKey(turn.progress) === completedDedupKey,
);
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
if (existingTurn?.role === "progress") {
key = turnProgressKey(existingTurn);
}
}
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
const existingProgress =
existingTurn?.role === "progress"
? existingTurn.progress
: null;
const merged =
existingProgress && progress.stage === "completed"
? mergeCompletedProgress(existingProgress, progress)
: progress;
const nextIndex = new Map(state.progressIndex);
const existingEntry = nextIndex.get(key);
const firstOccurredAtMs =
existingEntry?.firstOccurredAtMs ??
progress.occurredAtMs ??
Date.now();
const sequence = existingEntry?.sequence ?? nextIndex.size;
const nextEntry = { firstOccurredAtMs, sequence };
let nextKey = key;
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope);
nextIndex.delete(activeKey);
}
nextIndex.set(nextKey, nextEntry);
if (existingIndex >= 0) {
const nextTurns = [...state.turns];
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
return { ...state, turns: nextTurns, progressIndex: nextIndex };
}
const insertAt = state.turns.findIndex((turn) => {
if (turn.role !== "progress") return false;
const candidate = state.progressIndex.get(turnProgressKey(turn));
if (!candidate) return false;
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
return candidate.firstOccurredAtMs > firstOccurredAtMs;
}
return candidate.sequence > sequence;
});
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
if (insertAt < 0) {
return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex };
}
return {
...state,
turns: [
...state.turns.slice(0, insertAt),
nextTurn,
...state.turns.slice(insertAt),
],
progressIndex: nextIndex,
};
}
function mergeCompletedProgress(
running: ReplyProgress,
completed: ReplyProgress,
): ReplyProgress {
if (!isDelegationProgress(running) || !completed.text) return completed;
if (delegationSummary(completed).target) return completed;
return {
...completed,
text: running.text,
};
}
function progressShouldSettle(
turn: Extract<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;
}
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 lastAgentReceivedAtMs(turns: ChatTurn[]): number | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "agent") return turn.receivedAtMs;
if (turn.role === "user" || turn.role === "final" || turn.role === "error") {
return undefined;
}
}
return undefined;
}
function foldChunk(
state: ChatViewState,
raw: unknown,
receivedAtMs = Date.now(),
): ChatViewState {
const { turns } = state;
if (!isReplyRecord(raw)) {
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
};
}
const last = turns[turns.length - 1];
if (
(last?.role === "final" || last?.role === "error") &&
raw.kind !== "userPrompt" &&
raw.kind !== "UserPrompt"
) {
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs);
}
return state;
}
switch (raw.kind) {
case "textDelta":
return {
...state,
turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
};
case "toolActivity":
return {
...state,
turns: [
...turns,
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
],
};
case "progress": {
const progress = normalizeProgress(raw.progress);
if (!progress) {
return {
...state,
turns: [
...turns,
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
],
};
}
return appendProgress(state, progress);
}
case "final": {
const content = String(raw.content ?? "");
const completedState = completeChatViewProgress(state);
const next = [...completedState.turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({
role: "final",
text: content,
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
});
return { ...completedState, turns: next };
}
case "error": {
const completedState = completeChatViewProgress(state);
const next = [...completedState.turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({
role: "error",
text: String(raw.message ?? "Erreur agent"),
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
});
return { ...completedState, turns: next };
}
case "userPrompt":
case "UserPrompt": {
const appended = appendUserPrompt(
turns,
String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments),
receivedAtMs,
);
return {
...state,
turns: appended.turns,
progressScope: appended.appended
? state.progressScope + 1
: state.progressScope,
};
}
default:
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
};
}
}
export function CustomAgentChatView({
projectId,
agentId,
agentName,
profile,
cwd,
layoutId,
nodeId,
sessionId,
conversationId,
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
draftScope: explicitDraftScope,
mobile = false,
onOpenNativeTui,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, system, workState } = useGateways();
const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey(
projectId,
agentId,
conversationId,
);
const [turns, setTurns] = useState<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 [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
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 [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const scrollRef = useRef<HTMLDivElement | 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 draftHydratedRef = useRef(false);
const draftEditedBeforeHydrationRef = useRef(false);
const lastPersistedDraftRef = useRef("");
const draftRef = useRef("");
draftRef.current = draft;
const draftBeforeHistoryNavigationRef = useRef("");
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
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}`;
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 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 [, setComposerHistoryIndex] = useState<number | null>(null);
const displayTurns = turns;
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") ||
requesterDelegation.currentAgentBusy ||
delegationActive;
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
: "Délégation en cours.";
const refreshRequesterDelegation = useCallback(async () => {
if (!workState) {
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
return;
}
try {
const state = await workState.getProjectWorkState(projectId);
const current = state.agents.find((row) => row.agentId === agentId);
const currentAgentBusy = Boolean(
current?.busy.state === "busy" ||
current?.tickets.some((ticket) => ticket.status === "inProgress"),
);
const target = state.agents.find((row) =>
row.tickets.some(
(ticket) =>
ticket.status === "inProgress" &&
ticket.source.kind === "agent" &&
ticket.source.agentId === agentId,
),
);
setRequesterDelegation({
currentAgentBusy,
delegationTargetName: target ? workAgentDisplayName(target) : null,
});
} catch {
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
}
}, [agentId, projectId, workState]);
const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return;
draftSaveSeqRef.current += 1;
lastPersistedDraftRef.current = "";
try {
if (agent.clearChatDraft) {
await agent.clearChatDraft(draftScope);
} else if (agent.saveChatDraft) {
await agent.saveChatDraft(draftScope, "");
}
} catch (e) {
console.warn("[chat-draft] clear failed", instrumentationError(e));
}
}, [agent, draftScope]);
const editDraft = useCallback((text: string) => {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
setDraft(text);
}, []);
const recallComposerHistory = useCallback(
(direction: "previous" | "next") => {
if (composerHistory.length === 0) return;
setComposerHistoryIndex((current) => {
if (current === null && direction === "previous") {
draftBeforeHistoryNavigationRef.current = draftRef.current;
}
const nextIndex =
direction === "previous"
? current === null
? composerHistory.length - 1
: Math.max(0, current - 1)
: current === null
? null
: current >= composerHistory.length - 1
? null
: current + 1;
if (nextIndex === null) {
setDraft(draftBeforeHistoryNavigationRef.current);
draftBeforeHistoryNavigationRef.current = "";
} else {
setDraft(composerHistory[nextIndex] ?? "");
}
return nextIndex;
});
},
[composerHistory],
);
const handleComposerHistoryKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
) {
return false;
}
const target = event.currentTarget;
if (
event.key === "ArrowUp" &&
target.value.slice(0, target.selectionStart).includes("\n")
) {
return false;
}
if (
event.key === "ArrowDown" &&
target.value.slice(target.selectionEnd).includes("\n")
) {
return false;
}
event.preventDefault();
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
return true;
},
[recallComposerHistory],
);
useEffect(() => {
const restored = readComposerHistory(composerHistoryKey);
setComposerHistory(restored);
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}, [composerHistoryKey]);
useEffect(() => {
writeComposerHistory(composerHistoryKey, composerHistory);
}, [composerHistory, composerHistoryKey]);
useEffect(() => {
void refreshRequesterDelegation();
}, [refreshRequesterDelegation]);
useEffect(() => {
if (!system?.onDomainEvent) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (
event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
event.type === "orchestratorRequestProcessed" ||
event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged"
) {
if (
event.type === "agentBusyChanged" &&
event.agentId === agentId &&
!event.busy
) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
}
void refreshRequesterDelegation();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [agentId, projectId, refreshRequesterDelegation, system]);
useEffect(() => {
if (restartEvents.length === 0) return;
updateChatViewState((previous) => {
const nextTurns = [...previous.turns];
let changed = false;
for (const event of restartEvents) {
const key = `${event.restartId}:${event.type}`;
if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) {
continue;
}
if (event.type === "agentRestartRequested") {
changed = true;
nextTurns.push({
role: "restart",
key,
text: "------- redemarrage de l'agent -------",
});
} else if (event.type === "agentRestartCompleted") {
changed = true;
nextTurns.push({
role: "restart",
key,
text: "------- redemarrage de l'agent termine -------",
});
} else {
changed = true;
nextTurns.push({
role: "restart",
key,
text: "------- redemarrage de l'agent echoue -------",
message: shortRestartFailure(event.message),
failed: true,
});
}
}
return changed ? { ...previous, turns: nextTurns } : previous;
});
}, [restartEvents, updateChatViewState]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
const sid = sessionRef.current;
if (!sid) return;
void detachAgentChatRef.current?.(sid).catch((e) => {
if (!isNotFound(e)) {
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
}
});
};
}, []);
const receive = useCallback(
(chunk: ReplyChunk) => {
scrollbackChunkCountRef.current += 1;
const activeInteractionId = activeInteractionIdRef.current;
if (
activeInteractionId &&
chunk.kind === "progress" &&
chunk.progress.kind === "turn"
) {
if (
chunk.progress.stage === "started" ||
chunk.progress.stage === "delta"
) {
markProjectCliInteractionStarted(projectId, activeInteractionId);
}
if (chunk.progress.stage === "completed") {
markProjectCliInteractionFinished(projectId, activeInteractionId);
activeInteractionIdRef.current = null;
}
}
updateChatViewState((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") {
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
}
},
[projectId, updateChatViewState],
);
useEffect(() => {
if (currentSession) {
chatSessionViewCache.set(currentSession, {
turns,
progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current,
sessionId: currentSession,
});
}
if (currentConversationCacheKey) {
const previous = chatConversationViewCache.get(currentConversationCacheKey);
const cachedSessionId = currentSession ?? previous?.sessionId;
if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) {
chatConversationViewCache.set(currentConversationCacheKey, {
turns,
progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current,
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
});
}
}
}, [currentConversationCacheKey, currentSession, progressIndex, progressScope, turns]);
useEffect(() => {
draftSaveSeqRef.current += 1;
draftHydratedRef.current = false;
draftEditedBeforeHydrationRef.current = false;
lastPersistedDraftRef.current = "";
setDraft("");
if (!draftScope || !agent.readChatDraft) {
draftHydratedRef.current = true;
return;
}
let cancelled = false;
agent
.readChatDraft(draftScope)
.then((snapshot) => {
if (cancelled || !mountedRef.current) return;
const restored = snapshot.text ?? "";
lastPersistedDraftRef.current = restored;
if (!draftEditedBeforeHydrationRef.current) {
setDraft(restored);
}
})
.catch((e) => {
if (!cancelled) {
console.warn("[chat-draft] read failed", instrumentationError(e));
}
})
.finally(() => {
if (!cancelled) {
draftHydratedRef.current = true;
if (
draftEditedBeforeHydrationRef.current &&
agent.saveChatDraft &&
draftRef.current !== lastPersistedDraftRef.current
) {
const text = draftRef.current;
void agent
.saveChatDraft(draftScope, text)
.then(() => {
lastPersistedDraftRef.current = text;
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}
}
});
return () => {
cancelled = true;
};
}, [agent, draftScope]);
useEffect(() => {
if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return;
if (draft === lastPersistedDraftRef.current) return;
const seq = ++draftSaveSeqRef.current;
const timeout = window.setTimeout(() => {
if (draftSaveSeqRef.current !== seq) return;
agent
.saveChatDraft!(draftScope, draft)
.then(() => {
if (draftSaveSeqRef.current === seq) {
lastPersistedDraftRef.current = draft;
}
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}, 250);
return () => window.clearTimeout(timeout);
}, [agent, draft, draftScope]);
useEffect(() => {
if (
!supported ||
!agent.listSlashCommands ||
slashPrefix === null ||
effectiveBusy ||
opening
) {
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
return;
}
const seq = ++slashQuerySeqRef.current;
agent
.listSlashCommands(slashPrefix)
.then((commands) => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands(commands);
setSlashMenuOpen(commands.length > 0);
const firstAvailable = commands.findIndex(isSlashCommandAvailable);
setSlashActiveIndex(firstAvailable >= 0 ? firstAvailable : 0);
})
.catch(() => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands([]);
setSlashMenuOpen(false);
setSlashActiveIndex(0);
});
}, [agent, effectiveBusy, opening, slashPrefix, supported]);
const selectSlashCommand = useCallback((command: SlashCommand) => {
if (!isSlashCommandAvailable(command)) return;
editDraft(`${command.name} `);
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
requestAnimationFrame(() => composerRef.current?.focus());
}, [editDraft]);
const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId;
onSessionIdRef.current(nextSessionId);
}, []);
const reattachStructuredSession = useCallback(
async (
sid: string,
options: {
applyScrollback?: boolean;
stableIdentity?: boolean;
preserveRestartTurns?: boolean;
} = {},
) => {
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
console.debug("[ticket149] reattachStructuredSession:start", {
timestamp: new Date().toISOString(),
sessionId: sid,
options,
});
let reattached: Awaited<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));
}
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);
}
try {
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
applyScrollback: options.applyScrollback,
});
console.debug("[ticket149] recoverStructuredSession:success", {
timestamp: new Date().toISOString(),
launchedSessionId: launched.sessionId,
recoveredSessionId,
});
return recoveredSessionId;
} catch (e) {
if (isNotFound(e) && retryAttachNotFound) {
retryAttachNotFound = false;
if (mountedRef.current) {
setCurrentSession(null);
publishSessionId(null);
}
continue;
}
console.error("[ticket149] recoverStructuredSession:reattach:error", {
timestamp: new Date().toISOString(),
launchedSessionId: launched.sessionId,
error: instrumentationError(e),
});
throw e;
}
}
},
[
agent,
projectId,
agentId,
cwd,
nodeId,
conversationId,
reattachStructuredSession,
publishSessionId,
],
);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [displayTurns]);
useEffect(() => {
if (selfEmittedSessionIdRef.current === sessionId) {
console.debug("[ticket149] openOrAttach:self-session-echo:skip", {
timestamp: new Date().toISOString(),
sessionId,
});
selfEmittedSessionIdRef.current = undefined;
return;
}
setExternalSessionId(sessionId);
}, [sessionId]);
useEffect(() => {
if (!supported) return;
let cancelled = false;
async function openOrAttach() {
const execution = openOrAttachCountRef.current + 1;
openOrAttachCountRef.current = execution;
console.debug("[ticket149] openOrAttach:start", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
});
setOpening(true);
setError(null);
try {
const localSession = sessionRef.current;
if (!externalSessionId && localSession) {
console.debug("[ticket149] openOrAttach:local-session-present:skip", {
timestamp: new Date().toISOString(),
execution,
currentSession: localSession,
});
return;
}
if (externalSessionId) {
const cached = chatSessionViewCache.get(externalSessionId);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
});
}
try {
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
if (cancelled) return;
console.debug("[ticket149] openOrAttach:reattach-existing:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
});
return;
} catch (e) {
// The backend contract for `reattach_agent_chat` (NOT_FOUND) is that
// the session is gone (closed/never live) and the caller falls back
// to a fresh launch — any other error still surfaces to the user.
if (!isNotFound(e)) throw e;
try {
const replacementSessionId = await reattachStructuredSession(externalSessionId, {
applyScrollback: true,
stableIdentity: true,
});
if (cancelled) return;
publishSessionId(replacementSessionId);
console.debug("[ticket295] openOrAttach:stable-reattach:success", {
timestamp: new Date().toISOString(),
execution,
requestedSessionId: externalSessionId,
replacementSessionId,
});
return;
} catch (stableError) {
if (!isNotFound(stableError)) throw stableError;
}
if (!cancelled) {
setCurrentSession(null);
publishSessionId(null);
}
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
error: instrumentationError(e),
});
}
} else if (currentConversationCacheKey) {
const cached = chatConversationViewCache.get(currentConversationCacheKey);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
});
if (cached.sessionId) {
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
applyScrollback: true,
});
if (cancelled) return;
publishSessionId(reattachedSessionId);
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: reattachedSessionId,
});
return;
}
return;
}
}
await recoverStructuredSession({
applyScrollback: Boolean(conversationId),
retryAttachNotFound: true,
});
if (cancelled) return;
console.debug("[ticket149] openOrAttach:recover:success", {
timestamp: new Date().toISOString(),
execution,
receivedSessionId: externalSessionId,
});
} catch (e) {
console.error("[ticket149] openOrAttach:error", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
error: instrumentationError(e),
});
if (!cancelled) {
setError({ message: describeOpenFailure(e), openFailure: true });
}
} finally {
if (!cancelled) setOpening(false);
}
}
void openOrAttach();
return () => {
cancelled = true;
};
}, [
supported,
externalSessionId,
reattachStructuredSession,
recoverStructuredSession,
publishSessionId,
applyChatViewState,
currentConversationCacheKey,
conversationId,
openRetryNonce,
]);
const canSend = useMemo(
() =>
supported &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!effectiveBusy &&
!opening,
[supported, draft, attachments.length, effectiveBusy, opening],
);
const hasLinkedConversation = Boolean(
conversationId ||
requesterDelegation.currentAgentBusy ||
restart ||
delegationActive,
);
async function openProfileDialog() {
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
Boolean(candidate.structuredAdapter),
);
if (availableProfiles.length === 0) {
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
}
setDraft("");
setProfileDialog({
profiles: availableProfiles,
selectedProfileId:
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
availableProfiles[0].id,
confirmedReset: false,
busy: false,
error: null,
});
}
async function confirmProfileCommandChange() {
if (!profileDialog) return;
if (!agent.changeAgentProfile) {
setProfileDialog((prev) =>
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
);
return;
}
const selected = profileDialog.profiles.find(
(candidate) => candidate.id === profileDialog.selectedProfileId,
);
if (!selected) return;
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
try {
const { relaunchedSession } = await agent.changeAgentProfile(
projectId,
agentId,
selected.id,
24,
80,
);
applyChatViewState(emptyChatViewState());
setCurrentSession(null);
publishSessionId(null);
onConversationIdRef.current(null);
if (relaunchedSession?.sessionId) {
try {
setCurrentSession(relaunchedSession.sessionId);
publishSessionId(relaunchedSession.sessionId);
await reattachStructuredSession(relaunchedSession.sessionId, {
applyScrollback: false,
});
} catch {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
} else {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
applyChatViewState({
turns: [
{
role: "tool",
label: `Profil changé vers ${selected.name}.`,
receivedAtMs: Date.now(),
},
],
progressIndex: new Map(),
progressScope: 0,
});
setProfileDialog(null);
} catch (e) {
setProfileDialog((prev) =>
prev ? { ...prev, busy: false, error: describe(e) } : prev,
);
}
}
async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) {
setError({
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
openFailure: false,
});
return;
}
setDraft("");
void clearPersistedDraft();
setError(null);
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
const result = await agent.executeSlashCommand(invocation.name, {
sessionId: sid,
...(invocation.arguments.length > 0
? { arguments: invocation.arguments }
: {}),
});
if (result.effect.kind === "help") {
const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
}));
return;
}
if (result.effect.kind === "cleanConversation") {
applyChatViewState(emptyChatViewState());
return;
}
if (result.effect.kind === "profileSwitch") {
await openProfileDialog();
return;
}
if (result.effect.kind === "pluginCallback") {
await pluginRuntime.registry.runCommandStrict(
result.effect.pluginId,
result.effect.commandId,
...result.effect.arguments,
);
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
role: "tool",
label: `Commande plugin ${result.command.name} exécutée.`,
receivedAtMs: Date.now(),
},
],
}));
return;
}
} catch (e) {
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError({ message, openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
}));
}
}
async function pickAttachment() {
const path = await system.pickFile();
if (path) {
setAttachments((prev) => [
...prev,
{
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: pathBasename(path),
input: { path, sourceKind: "localFile" },
},
]);
}
}
function removeAttachment(item: AttachmentDraft) {
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
);
if (item.token) {
editDraft(removeAttachmentToken(draftRef.current, item.token));
}
}
async function pasteClipboardFiles(
event: ClipboardEvent<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 });
}
}
async function send() {
const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return;
const slashInvocation = slashCommandInvocation(text);
if (slashInvocation) {
await executeSlashCommand(slashInvocation);
return;
}
const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
activeInteractionIdRef.current = interactionId;
markProjectCliInteractionStarted(projectId, interactionId);
const finishInteraction = () => {
if (activeInteractionIdRef.current !== interactionId) return;
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
};
if (text) {
setComposerHistory((prev) => appendComposerHistoryEntry(prev, text));
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}
setDraft("");
setAttachments([]);
imageTokenSeqRef.current = 0;
void clearPersistedDraft();
setBusy(true);
setError(null);
const sentAtMs = Date.now();
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
role: "user",
text: displayText,
receivedAtMs: sentAtMs,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
},
],
progressScope: prev.progressScope + 1,
}));
const sendTurn = (sid: string) =>
attachmentInputs.length > 0
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
: agent.sendAgentChat!(sid, text, receive);
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
await sendTurn(sid);
finishInteraction();
} catch (e) {
if (isNotFound(e)) {
try {
setCurrentSession(null);
publishSessionId(null);
const recoveredSession = await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
});
await sendTurn(recoveredSession);
finishInteraction();
return;
} catch (recoveryError) {
finishInteraction();
setBusy(false);
setError({ message: describe(recoveryError), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
],
}));
return;
}
}
finishInteraction();
setBusy(false);
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
}));
}
}
async function stopCurrentRequest() {
const sid = sessionRef.current;
if (!sid || !agent.cancelAgentChat || stopping) return;
setStopping(true);
setError(null);
try {
await agent.cancelAgentChat(sid);
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
setOpening(false);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
...completed,
turns: [
...completed.turns,
{
role: "tool",
label: "Request stopped.",
receivedAtMs: Date.now(),
},
],
};
});
} catch (e) {
if (isNotFound(e)) {
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
setOpening(false);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
...completed,
turns: [
...completed.turns,
{ role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() },
],
};
});
return;
}
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
role: "error",
text: `Arrêt de la demande échoué: ${describe(e)}`,
receivedAtMs: Date.now(),
},
],
}));
} finally {
setStopping(false);
}
}
return (
<div
ref={chatRootRef}
data-testid="custom-agent-chat-view"
className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
>
<div
role="toolbar"
aria-label="custom agent chat 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">
CLI custom · {profile.name}
</div>
</div>
</div>
{!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
</p>
)}
{error && (
<div
role="alert"
className="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">Le CLI custom n'a pas pu démarrer.</div>
<div className="break-words">
Cause: {error.message}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
Réessayer
</Button>
{onOpenNativeTui && (
<Button
size="sm"
variant="secondary"
onClick={onOpenNativeTui}
>
Ouvrir en TUI native
</Button>
)}
</div>
</div>
) : (
error.message
)}
</div>
)}
{profileDialog && (
<div
role="dialog"
aria-modal="true"
aria-labelledby={`profile-command-title-${domId}`}
className={cn(
"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"
>
Changer de profil
</h2>
<p className="mt-1 text-xs text-muted">
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
Fermer
</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">
Profil
<select
aria-label="profil cible"
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>Je confirme que la session courante sera 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)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
disabled={!profileDialog.confirmedReset || profileDialog.busy}
loading={profileDialog.busy}
onClick={() => void confirmProfileCommandChange()}
>
Reset et changer le profil
</Button>
</div>
</div>
</div>
)}
<div
ref={scrollRef}
data-testid="custom-agent-chat-scroll"
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
>
<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 ? "Reprise en cours..." : "Ouverture de la session structurée…"}
</span>
</div>
) : displayTurns.length === 0 && hasLinkedConversation ? (
<p className="m-auto text-xs text-muted">
Conversation liée — récupération du transcript en cours.
</p>
) : displayTurns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Envoyez un message pour démarrer la conversation structurée.
</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
data-testid="custom-agent-chat-composer"
className={cn(
"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))]",
)}
>
{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">Fichier joint: {item.label}</span>
<button
type="button"
aria-label={`retirer ${item.label}`}
className="shrink-0 text-content"
onClick={() => removeAttachment(item)}
>
Retirer
</button>
</div>
))}
</div>
)}
{slashMenuOpen && slashCommands.length > 0 && (
<div
id={`slash-command-menu-${domId}`}
role="listbox"
aria-label="suggestions commandes slash"
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">confirm</span>
)}
</button>
);
})}
</div>
)}
<div className="flex min-w-0 items-end gap-2">
<div className="relative flex min-w-0 flex-1">
<textarea
ref={composerRef}
aria-label={`message CLI custom ${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 || effectiveBusy}
placeholder="Message à l'agent…"
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={`redimensionner le composer ${domId}`}
title="Redimensionner le composer"
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 || effectiveBusy}
onClick={() => void pickAttachment()}
>
Joindre
</Button>
<Button
size="sm"
variant={effectiveBusy ? "danger" : undefined}
className="shrink-0 whitespace-nowrap"
disabled={
effectiveBusy
? !supported || !currentSession || !agent.cancelAgentChat || stopping
: !canSend
}
loading={stopping}
title={effectiveBusy ? "Arrêter la demande en cours" : undefined}
aria-label={effectiveBusy ? "Stop" : undefined}
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
>
{stopping ? "Stopping..." : effectiveBusy ? "Stop" : "Envoyer"}
</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;
}) {
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",
)}
>
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
{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="max-w-[80%] truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
{turn.label}
</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 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="Terminé"
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="Échec"
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" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{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" : "border-primary/25 bg-primary/10",
)}
>
<ChatMessageMeta
label={user ? "Vous" : 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">
Fichier: {attachment}
</p>
))}
</div>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
Réponse en cours
</span>
)}
</div>
</div>
);
}
function normalizeChatReceivedAtMs(receivedAtMs: number | undefined): number | undefined {
if (!Number.isFinite(receivedAtMs) || receivedAtMs === undefined || receivedAtMs <= 0) {
return undefined;
}
if (
receivedAtMs >= MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS &&
receivedAtMs < MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS
) {
return receivedAtMs * 1000;
}
if (
receivedAtMs < MIN_REASONABLE_CHAT_TIMESTAMP_MS ||
receivedAtMs >= MAX_REASONABLE_CHAT_TIMESTAMP_MS
) {
return undefined;
}
return receivedAtMs;
}
function formatChatReceivedTime(receivedAtMs: number | undefined): string {
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
if (!normalizedAtMs) return "";
try {
const parts = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(normalizedAtMs));
const hour = parts.find((part) => part.type === "hour")?.value;
const minute = parts.find((part) => part.type === "minute")?.value;
return hour && minute ? `${hour}:${minute}` : "";
} catch {
return "";
}
}
function ChatMessageMeta({
label,
receivedAtMs,
labelClassName,
}: {
label: string;
receivedAtMs: number | undefined;
labelClassName?: string;
}) {
const time = formatChatReceivedTime(receivedAtMs);
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
const dateTime = time && normalizedAtMs ? new Date(normalizedAtMs).toISOString() : undefined;
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>
{time && (
<time
dateTime={dateTime}
className="shrink-0 text-faint"
>
{time}
</time>
)}
</div>
);
}
function progressTitle(progress: ReplyProgress): string {
if (isDelegationProgress(progress)) return "Délégation";
if (progress.kind === "mcp") return "MCP";
if (progress.kind === "tool") return "Tool";
if (progress.kind === "message") return "Progress";
if (progress.kind === "turn") return "Tour";
return "Activité";
}
function progressSourceLabel(progress: ReplyProgress): string {
if (progress.source === "ideaLocal") return "IdeA";
return progress.provider ?? "Provider";
}
function progressStageLabel(stage: ReplyProgressStage): string {
switch (stage) {
case "started":
return "En cours";
case "delta":
return "En cours";
case "completed":
return "Terminé";
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 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 ? "Afficher moins" : "Afficher plus"}
</button>
)}
</div>
);
}
function TurnMarker({ progress }: { progress: ReplyProgress }) {
const stage = progressStageLabel(progress.stage);
const label = progress.label.trim() || "Tour";
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>
<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 stage = progressStageLabel(progress.stage);
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}`
: "Appel inter-agent"
: progressTitle(progress);
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>
</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)}</span>
{progress.nativeEvent && (
<span className="truncate">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;
}) {
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);
const stage = progressStageLabel(progress.stage);
const source = progressSourceLabel(progress);
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>
</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">event: {progress.nativeEvent}</span>
)}
</div>
{detail && (
<ExpandableProgressDetail
key={progressKey}
content={detail}
expanded={expanded}
onToggle={onToggleExpanded}
/>
)}
</div>
</div>
);
}