Files
IdeA/frontend/src/features/agents/CustomAgentChatView.tsx
Blomios ff1d9af53a feat(domain,frontend): remplace le catalogue d'icônes projet par des emojis Unicode système — #345 (QA verte)
Reprend à zéro la liste des icônes projet: catalogue d'emojis Unicode rendus
par la police système, sans embarquer d'assets graphiques propriétaires.
- domain: variante `emoji` ajoutée à ProjectIconSet (serde kebab-case)
- ports TS: union ProjectIconSet alignée avec "emoji"
- frontend: catalogue réécrit en 7 groupes (smileys, nature, food,
  activities, travel, objects, symbols), registres légaux et métadonnées
  supprimés, assets SVG projet retirés (angular.svg, NOTICE.md)
- watermark CLI custom: rendu emoji direct avec libellé de repli dérivé du
  nom de dossier du cwd
- i18n FR/EN des groupes du picker et tests mis à jour (domain settings,
  adapter projet, picker, panneau settings, chat custom)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 10:52:29 +02:00

3414 lines
112 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 } 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 CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
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;
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
chatConversationViewCache.clear();
chatAttachmentDraftCache.clear();
}
interface AttachmentDraft {
id: string;
label: string;
input: ChatAttachmentInput;
previewUrl?: string;
dedupeKey?: string;
token?: string;
}
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;
}
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"),
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 [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
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 [stopping, setStopping] = useState(false);
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | 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 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 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 unsubscribe = onProjectIconChanged((change) => {
if (change.projectId === projectId) setProjectIcon(change.icon);
});
return () => { cancelled = true; unsubscribe(); };
}, [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 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 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;
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 (stopTerminalOverrideRef.current && !currentAgentBusy && !target) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
}
} catch {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
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;
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(() => {
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: tr("------- redémarrage de l’agent -------", "------- restarting agent -------"),
});
} else if (event.type === "agentRestartCompleted") {
changed = 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 &&
cancelledInteractionIdsRef.current.has(sourceInteractionId)
) {
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);
}
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));
}
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,
],
);
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);
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 canSend = useMemo(
() =>
supported &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!effectiveBusy &&
!opening,
[supported, draft, attachments.length, effectiveBusy, 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 });
}
}
async function send() {
const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return;
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 = "";
}
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}`;
ignoreUnscopedChunksAfterStopRef.current = false;
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;
markProjectCliInteractionStarted(projectId, interactionId);
const finishInteraction = () => {
if (activeInteractionIdRef.current !== interactionId) return;
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
};
setDraft("");
setAttachments([]);
imageTokenSeqRef.current = 0;
void clearPersistedDraft();
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;
finishInteraction();
} 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);
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: null },
],
}));
return;
}
}
finishInteraction();
setBusy(false);
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }],
}));
}
}
async function stopCurrentRequest() {
const sid = sessionRef.current;
if (
!sid ||
!agent.cancelAgentChat ||
stopping ||
stopInFlightRef.current ||
stopTerminalOverrideRef.current
) {
return;
}
stopInFlightRef.current = true;
ignoreUnscopedChunksAfterStopRef.current = true;
const interactionBeingCancelled = activeInteractionIdRef.current;
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);
}
}
const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId;
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
data-testid="project-icon-watermark"
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center overflow-hidden text-content opacity-[0.035]"
style={{ pointerEvents: "none", containerType: "size" }}
>
<ProjectIcon
icon={projectIcon}
fallbackLabel={projectFallbackLabel}
surface="watermark"
size="92%"
decorative
style={{ fontSize: "min(34cqw, 34cqh)", maxWidth: "92cqh", maxHeight: "92cqw" }}
/>
</div>
<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>
{!supported && (
<p role="alert" className="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="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(
"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
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 ? 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="m-auto 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
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">{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 || effectiveBusy}
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 || effectiveBusy}
onClick={() => void pickAttachment()}
>
{t("custom.attach")}
</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 ? t("custom.stop") : undefined}
aria-label={effectiveBusy ? t("custom.stop") : undefined}
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
>
{stopping ? t("custom.stopping") : effectiveBusy ? t("custom.stop") : 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 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 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" : "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>
);
}