Files
IdeA/frontend/src/features/agents/CustomAgentChatView.tsx
Blomios 9ee06c7ec9 fix(domain,application,infrastructure,backend,web-server,app-tauri,frontend): statut/LED projet indexé par projectId, source LiveSessions/LiveStateStore/ReconcileLiveState — la fin d'un agent dans un projet non actif repasse désormais la LED du projet au repos — domain: DomainEvent::AgentBusyChanged porte project_id ; infrastructure input: busy émis au démarrage du tour et idle au mark_idle avec le projet possesseur ; backend: DTO agentBusyChanged sérialise project_id (busy_changed_keeps_owning_project_on_the_wire) ; application workstate: ReconcileLiveState exécuté aussi avant les lectures de projection (plus seulement à l'ouverture) et liveness évaluée par (projectId, agentId) — un même agent_id vivant dans un autre projet n'épargne plus un orphelin ; web-server: ws_send_chat libère le busy avant le Final visible et la route work-state projet reste au contrat tauri ; app-tauri: commandes work-state/busy alignées sur le projectId possesseur ; frontend: useProjectActivity agrège l'activité par projectId (busy_changed filtré par projet possesseur, plus le seul projet courant), wsLiveClient/wsLiveClientReconnect propagant project_id, AnnouncementsProvider/CustomAgentChatView/WebWorkspaceLive/LayoutGrid.cellControlsLayering alignés — #373 (QA verte: cargo check --tests -p domain -p application -p infrastructure -p backend -p app-tauri -p web-server, cargo test -p application workstate::reconcile::tests, cargo test -p infrastructure input::tests::busy_event_fires_on_turn_start_and_idle_on_mark_idle, cargo test -p backend events::tests::busy_changed_keeps_owning_project_on_the_wire, cargo test -p web-server websocket_chat_send_releases_busy_before_visible_final + authorized_get_project_work_state_returns_tauri_contract, npm test -- useProjectActivity wsLiveClient wsLiveClientReconnect workstate AnnouncementsProvider CustomAgentChatView WebWorkspaceLive LayoutGrid.cellControlsLayering, npm run typecheck, cargo fmt --all -- --check, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
2026-09-17 20:23:36 +02:00

4360 lines
150 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,
TurnView,
} from "@/domain";
import { useGateways } from "@/app/di";
import { useI18n } from "@/i18n";
import {
AnnouncementsPreview,
shortRestartFailure,
useAgentRestart,
} from "@/features/announcements";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import {
markProjectCliInteractionFinished,
markProjectCliInteractionStarted,
markProjectCliInteractionStopped,
} from "@/features/projects/projectCliInteractionActivity";
import {
Button,
EntryTimestamp,
Spinner,
cn,
normalizeTimestamp,
useThreadScrollController,
} from "@/shared";
import { onProjectIconChanged, ProjectIcon } from "@/features/projects/projectIcons";
import type {
ChatAttachmentInput,
ChatDraftScope,
ConversationTranscriptUnavailableReason,
CustomAgentConversationSummary,
ProjectIconRef,
} from "@/ports";
export interface CustomAgentChatViewProps {
projectId: string;
agentId: string;
agentName: string;
profile: AgentProfile;
cwd: string;
layoutId?: string;
nodeId?: string;
sessionId: string | null;
conversationId: string | null;
/** Start blank when there is no live session; durable threads reopen via the menu. */
freshOnOpen?: boolean;
composerHeightPx?: number;
composerDefaultHeightPx?: number;
onComposerHeightPxChange?: (heightPx: number) => void;
draftScope?: ChatDraftScope | null;
mobile?: boolean;
onOpenNativeTui?: () => void;
onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void;
}
type ChatTurn =
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number | null }
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number | null }
| { role: "tool"; label: string; receivedAtMs?: number | null }
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
| { role: "final"; text: string; receivedAtMs?: number | null }
| { role: "error"; text: string; receivedAtMs?: number | null }
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null }
| { role: "separator"; text: string }
| { role: "unknown"; text: string; receivedAtMs?: number | null };
interface CachedChatView {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
chunkCount: number;
sessionId?: string;
}
const chatSessionViewCache = new Map<string, CachedChatView>();
const chatConversationViewCache = new Map<string, CachedChatView>();
const chatAttachmentDraftCache = new Map<string, AttachmentDraft[]>();
const chatMessageQueueCache = new Map<string, QueuedChatMessage[]>();
const CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT = 24;
const CHAT_MESSAGE_QUEUE_CACHE_LIMIT = 24;
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
const MODEL_RESPONSE_NOT_FINALIZED_MESSAGE =
"La réponse du modèle n'a pas pu être finalisée.";
const TURN_FINISHED_WITHOUT_FINAL_MESSAGE =
"Tour terminé sans réponse finale.";
const COMPOSER_RESIZE_STEP = 12;
const FALLBACK_COMPOSER_HEIGHT = 64;
const COMPOSER_HISTORY_LIMIT = 100;
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
chatConversationViewCache.clear();
chatAttachmentDraftCache.clear();
chatMessageQueueCache.clear();
}
interface AttachmentDraft {
id: string;
label: string;
input: ChatAttachmentInput;
previewUrl?: string;
dedupeKey?: string;
token?: string;
}
interface QueuedChatMessage {
id: string;
text: string;
attachments: AttachmentDraft[];
createdAtMs: number;
}
function messageQueueCacheKey(
projectId: string,
agentId: string,
layoutId?: string,
nodeId?: string,
): string {
return `${projectId}\u0000${agentId}\u0000${layoutId ?? ""}\u0000${nodeId ?? ""}`;
}
function cacheMessageQueue(key: string, messages: QueuedChatMessage[]): void {
chatMessageQueueCache.delete(key);
if (messages.length > 0) chatMessageQueueCache.set(key, messages);
while (chatMessageQueueCache.size > CHAT_MESSAGE_QUEUE_CACHE_LIMIT) {
const oldest = chatMessageQueueCache.keys().next().value as string | undefined;
if (!oldest) break;
chatMessageQueueCache.delete(oldest);
}
}
export function customAgentChatQueueCount(
projectId: string,
agentId: string,
layoutId?: string,
nodeId?: string,
): number {
return chatMessageQueueCache.get(
messageQueueCacheKey(projectId, agentId, layoutId, nodeId),
)?.length ?? 0;
}
function attachmentDraftCacheKey(scope: ChatDraftScope | null): string | null {
return scope
? `${scope.projectId}\u0000${scope.layoutId}\u0000${scope.nodeId}`
: null;
}
function cacheAttachmentDraft(key: string, attachments: AttachmentDraft[]): void {
chatAttachmentDraftCache.delete(key);
if (attachments.length > 0) chatAttachmentDraftCache.set(key, attachments);
while (chatAttachmentDraftCache.size > CHAT_ATTACHMENT_DRAFT_CACHE_LIMIT) {
const oldest = chatAttachmentDraftCache.keys().next().value as string | undefined;
if (!oldest) break;
chatAttachmentDraftCache.delete(oldest);
}
}
interface ProgressIndexEntry {
firstOccurredAtMs: number;
sequence: number;
}
interface ProfileCommandDialogState {
profiles: AgentProfile[];
selectedProfileId: string;
confirmedReset: boolean;
busy: boolean;
error: string | null;
}
interface SlashCommandInvocation {
name: string;
arguments: unknown[];
}
interface ChatErrorState {
message: string;
openFailure: boolean;
}
interface RequesterDelegationState {
currentAgentBusy: boolean;
delegationTargetName: string | null;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function describeOpenFailure(e: unknown): string {
const message = describe(e).trim();
return message || "Cause technique non disponible.";
}
function workAgentDisplayName(agent: { agentId: string; name: string }): string {
return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`;
}
function structuredSessionLabel(
adapter: AgentProfile["structuredAdapter"],
): string | null {
switch (adapter) {
case "codex":
return "Session Codex";
case "claude":
return "Session Claude";
case "openCode":
return "Session OpenCode";
case "openAiCompatible":
return "Session HTTP";
default:
return null;
}
}
function instrumentationError(e: unknown): Record<string, unknown> {
if (e && typeof e === "object") {
const err = e as GatewayError & { name?: string };
return {
code: err.code,
message: err.message,
name: err.name,
raw: e,
};
}
return { message: String(e), raw: e };
}
function isNotFound(e: unknown): boolean {
if (e && typeof e === "object" && (e as GatewayError).code === "NOT_FOUND") {
return true;
}
const message =
typeof e === "string"
? e
: e && typeof e === "object" && "message" in e
? String((e as GatewayError).message)
: "";
return /^not found: structured session\b/i.test(message);
}
function unknownChunkLabel(chunk: unknown): string {
try {
return JSON.stringify(chunk);
} catch {
return String(chunk);
}
}
function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
}
function appendAgentDelta(
turns: ChatTurn[],
text: string,
receivedAtMs: number | null | undefined,
): ChatTurn[] {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") {
next[next.length - 1] = {
role: "agent",
text: last.text + text,
pending: true,
receivedAtMs: last.receivedAtMs ?? receivedAtMs,
};
return next;
}
next.push({ role: "agent", text, pending: true, receivedAtMs });
return next;
}
function attachmentLabels(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
: [];
}
function appendUserPrompt(
turns: ChatTurn[],
text: string,
attachments: string[] = [],
receivedAtMs?: number | null,
): { turns: ChatTurn[]; appended: boolean } {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "final" || turn.role === "error") break;
if (turn.role !== "user") continue;
if (
turn.text === text &&
JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments)
) {
if (turn.receivedAtMs == null && receivedAtMs != null) {
const next = [...turns];
next[index] = { ...turn, receivedAtMs };
return { turns: next, appended: false };
}
return { turns, appended: false };
}
break;
}
return {
turns: [
...turns,
{
role: "user",
text,
receivedAtMs,
...(attachments.length > 0 ? { attachments } : {}),
},
],
appended: true,
};
}
function isReplyProgressSource(value: unknown): value is ReplyProgressSource {
return value === "providerNative" || value === "ideaLocal";
}
function isReplyProgressKind(value: unknown): value is ReplyProgressKind {
return (
value === "turn" ||
value === "message" ||
value === "tool" ||
value === "mcp" ||
value === "other"
);
}
function isReplyProgressStage(value: unknown): value is ReplyProgressStage {
return (
value === "started" ||
value === "delta" ||
value === "completed" ||
value === "info"
);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeProgress(raw: unknown): ReplyProgress | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
const label = optionalString(record.label);
if (!label) return null;
return {
progressId: optionalString(record.progressId),
occurredAtMs:
typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs)
? record.occurredAtMs
: undefined,
source: isReplyProgressSource(record.source) ? record.source : "providerNative",
kind: isReplyProgressKind(record.kind) ? record.kind : "other",
stage: isReplyProgressStage(record.stage) ? record.stage : "info",
label,
text: optionalString(record.text),
provider: optionalString(record.provider),
nativeEvent: optionalString(record.nativeEvent),
toolName: optionalString(record.toolName),
};
}
function progressKey(progress: ReplyProgress): string {
return [
progress.source,
progress.kind,
progress.stage,
progress.toolName ?? "",
progress.label,
progress.text ?? "",
].join("\u0000");
}
function progressUpsertKey(progress: ReplyProgress, scope: number): string {
const progressId = progress.progressId?.trim();
if (progressId) return `progress:${scope}:${progressId}`;
return `legacy:${scope}:${progressDisplayKey(progress)}`;
}
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
return turn.progressKey ?? progressUpsertKey(turn.progress, 0);
}
function normalizedProgressToolName(progress: ReplyProgress): string | null {
const name = progress.toolName ?? progress.label;
const normalized = name.trim().toLowerCase();
return normalized || null;
}
function isDelegationProgress(progress: ReplyProgress): boolean {
const name = normalizedProgressToolName(progress);
return name === "idea_ask_agent" || name === "idea_ask_agents";
}
function progressEntityKey(progress: ReplyProgress): string | null {
const name = normalizedProgressToolName(progress);
if (!name) return null;
if (isDelegationProgress(progress)) return `delegation:${name}`;
if (progress.kind === "tool" || progress.kind === "mcp") return `tool:${name}`;
return null;
}
function progressStageBucket(progress: ReplyProgress): string {
if (!isCenteredToolProgress(progress)) return progress.stage;
if (progress.stage === "started" || progress.stage === "delta") return "started";
if (progress.stage === "completed") return "completed";
return "info";
}
function progressDisplayKey(progress: ReplyProgress): string {
const entity = progressEntityKey(progress);
if (entity) return `${entity}\u0000${progressStageBucket(progress)}`;
return progressKey(progress);
}
function legacyActiveProgressKey(progress: ReplyProgress, scope: number): string | null {
const entity = progressEntityKey(progress);
if (!entity) return null;
if (progress.stage !== "started" && progress.stage !== "delta") return null;
return `legacy:${scope}:${entity}\u0000started`;
}
function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
const entity = progressEntityKey(progress);
if (!entity || progress.stage !== "completed") return null;
if (isDelegationProgress(progress)) return entity;
return `${entity}\u0000${progress.text ?? ""}`;
}
function findLastProgressIndex(
turns: ChatTurn[],
predicate: (turn: Extract<ChatTurn, { role: "progress" }>) => boolean,
): number {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "progress" && predicate(turn)) return index;
}
return -1;
}
function legacyCompletedOccurrenceKey(
progress: ReplyProgress,
entry: ProgressIndexEntry,
scope: number,
): string {
return `legacy-completed:${scope}:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
}
interface ChatViewState {
turns: ChatTurn[];
progressIndex: Map<string, ProgressIndexEntry>;
progressScope: number;
}
function emptyChatViewState(): ChatViewState {
return { turns: [], progressIndex: new Map(), progressScope: 0 };
}
function durableHistoryTurns(history: TurnView[]): ChatTurn[] {
return history.flatMap((turn): ChatTurn[] => {
if (turn.role === "prompt") {
return [{ role: "user", text: turn.text, receivedAtMs: turn.atMs }];
}
if (turn.role === "response") {
return [{ role: "final", text: turn.text, receivedAtMs: turn.atMs }];
}
return [];
});
}
function conversationActivityLabel(atMs: number): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(atMs));
}
function conversationCacheKey(
projectId: string,
agentId: string,
conversationId: string | null,
): string | null {
return conversationId ? `${projectId}::${agentId}::${conversationId}` : null;
}
function composerHistoryStorageKey(
projectId: string,
agentId: string,
layoutId: string | undefined,
nodeId: string | undefined,
): string {
return [
"idea.custom-chat-history",
projectId,
layoutId ?? "default",
nodeId ?? "agent",
agentId,
].join(".");
}
function readComposerHistory(storageKey: string): string[] {
if (typeof window === "undefined") return [];
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.slice(-COMPOSER_HISTORY_LIMIT);
} catch {
return [];
}
}
function writeComposerHistory(storageKey: string, history: string[]): boolean {
if (typeof window === "undefined") return true;
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
);
return true;
} catch {
return false;
}
}
function appendComposerHistoryEntry(history: string[], text: string): string[] {
const entry = text.trim();
if (!entry) return history;
const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history;
return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT);
}
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
let key = progressUpsertKey(progress, state.progressScope);
let existingIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
);
const activeKey =
!progress.progressId && progress.stage === "completed"
? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope)
: null;
if (activeKey) {
const activeIndex = state.turns.findIndex(
(turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey,
);
if (activeIndex >= 0) {
existingIndex = activeIndex;
key = activeKey;
}
}
const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null;
if (completedDedupKey) {
existingIndex = findLastProgressIndex(
state.turns,
(turn) =>
turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) &&
legacyCompletedDedupKey(turn.progress) === completedDedupKey,
);
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
if (existingTurn?.role === "progress") {
key = turnProgressKey(existingTurn);
}
}
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
const existingProgress =
existingTurn?.role === "progress"
? existingTurn.progress
: null;
const merged =
existingProgress && progress.stage === "completed"
? mergeCompletedProgress(existingProgress, progress)
: progress;
const nextIndex = new Map(state.progressIndex);
const existingEntry = nextIndex.get(key);
const firstOccurredAtMs =
existingEntry?.firstOccurredAtMs ??
progress.occurredAtMs ??
Date.now();
const sequence = existingEntry?.sequence ?? nextIndex.size;
const nextEntry = { firstOccurredAtMs, sequence };
let nextKey = key;
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope);
nextIndex.delete(activeKey);
}
nextIndex.set(nextKey, nextEntry);
if (existingIndex >= 0) {
const nextTurns = [...state.turns];
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
return { ...state, turns: nextTurns, progressIndex: nextIndex };
}
const insertAt = state.turns.findIndex((turn) => {
if (turn.role !== "progress") return false;
const candidate = state.progressIndex.get(turnProgressKey(turn));
if (!candidate) return false;
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
return candidate.firstOccurredAtMs > firstOccurredAtMs;
}
return candidate.sequence > sequence;
});
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
if (insertAt < 0) {
return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex };
}
return {
...state,
turns: [
...state.turns.slice(0, insertAt),
nextTurn,
...state.turns.slice(insertAt),
],
progressIndex: nextIndex,
};
}
function mergeCompletedProgress(
running: ReplyProgress,
completed: ReplyProgress,
): ReplyProgress {
if (!isDelegationProgress(running) || !completed.text) return completed;
if (delegationSummary(completed).target) return completed;
return {
...completed,
text: running.text,
};
}
function progressShouldSettle(
turn: Extract<ChatTurn, { role: "progress" }>,
completedEntities: Set<string | null>,
): boolean {
if (
turn.progress.kind === "turn" ||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
) {
return false;
}
const entity = progressEntityKey(turn.progress);
return Boolean(entity && completedEntities.has(entity));
}
function shouldCompleteProgress(
turn: Extract<ChatTurn, { role: "progress" }>,
): boolean {
return (
turn.progress.kind !== "turn" &&
(turn.progress.stage === "started" || turn.progress.stage === "delta")
);
}
function completeChatViewProgress(state: ChatViewState): ChatViewState {
const completedEntities = new Set(
state.turns
.filter(
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
turn.role === "progress" &&
progressStageBucket(turn.progress) === "completed" &&
Boolean(progressEntityKey(turn.progress)),
)
.map((turn) => progressEntityKey(turn.progress)),
);
const nextIndex = new Map(state.progressIndex);
const turns = state.turns.map((turn) => {
if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn;
const previousKey = turnProgressKey(turn);
const previousEntry = nextIndex.get(previousKey);
const entry =
previousEntry ?? {
firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(),
sequence: nextIndex.size,
};
const completedProgress = {
...turn.progress,
stage: "completed" as const,
};
const nextKey = turn.progress.progressId
? progressUpsertKey(completedProgress, state.progressScope)
: legacyCompletedOccurrenceKey(completedProgress, entry, state.progressScope);
if (previousKey !== nextKey) nextIndex.delete(previousKey);
nextIndex.set(nextKey, entry);
if (progressShouldSettle(turn, completedEntities)) {
return {
...turn,
settled: true,
progress: completedProgress,
progressKey: nextKey,
};
}
return {
...turn,
progress: completedProgress,
progressKey: nextKey,
};
});
return { ...state, turns, progressIndex: nextIndex };
}
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
return (
turn?.role === "error" &&
(turn.text === EMPTY_MODEL_RESPONSE_MESSAGE ||
turn.text === TURN_FINISHED_WITHOUT_FINAL_MESSAGE)
);
}
function turnsIndicateBusy(state: ChatViewState): boolean {
let sawOpenActivity = false;
for (let index = state.turns.length - 1; index >= 0; index -= 1) {
const turn = state.turns[index];
if (turn.role === "final" || turn.role === "error") return false;
if (turn.role === "user") return sawOpenActivity;
if (turn.role === "agent" && turn.pending) sawOpenActivity = true;
if (
turn.role === "progress" &&
!turn.settled &&
progressStageBucket(turn.progress) !== "completed"
) {
sawOpenActivity = true;
}
}
return sawOpenActivity;
}
function insertTextAtSelection(
text: string,
insertion: string,
selectionStart: number,
selectionEnd: number,
): string {
return text.slice(0, selectionStart) + insertion + text.slice(selectionEnd);
}
function removeAttachmentToken(text: string, token: string): string {
const index = text.indexOf(token);
if (index < 0) return text;
const before = text.slice(0, index);
const after = text.slice(index + token.length);
if (/\s$/.test(before) && /^\s/.test(after)) {
return before + after.replace(/^\s+/, "");
}
return before + after;
}
function fileExtension(mime: string): string {
if (mime === "image/png") return "png";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/gif") return "gif";
if (mime === "image/webp") return "webp";
return "img";
}
function pathBasename(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
const mime = file.type || "application/octet-stream";
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
const filename =
file.name ||
(mime.startsWith("image/")
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
: `clipboard-file-${Date.now()}`);
return {
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: filename,
previewUrl: mime.startsWith("image/")
? `data:${mime};base64,${contentBase64}`
: undefined,
dedupeKey: file.name
? fileMetadataKey(file)
: `${mime}\u0000${file.size}\u0000${contentBase64}`,
input: {
filename,
contentBase64,
mime,
sourceKind: "clipboard",
},
};
}
function fileMetadataKey(file: File): string {
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
}
function dataTransferFiles(clipboardData: DataTransfer): File[] {
const clipboardItems = clipboardData.items
? Array.from(clipboardData.items)
: [];
const clipboardFiles = clipboardData.files
? Array.from(clipboardData.files)
: [];
const fromItems = clipboardItems
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const seen = new Set<string>();
return [...fromItems, ...clipboardFiles].filter((file) => {
const key = fileMetadataKey(file);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function asyncClipboardImageFiles(): Promise<File[]> {
if (
typeof navigator === "undefined" ||
typeof navigator.clipboard?.read !== "function"
) {
return [];
}
const items = await navigator.clipboard.read();
const files: File[] = [];
for (const item of items) {
for (const type of item.types) {
if (!type.startsWith("image/")) continue;
const blob = await item.getType(type);
files.push(new File([blob], "", { type, lastModified: 0 }));
}
}
return files;
}
function slashCommandPrefix(draft: string): string | null {
if (!draft.startsWith("/")) return null;
if (/\s/.test(draft)) return null;
return draft;
}
function slashCommandInvocation(text: string): SlashCommandInvocation | null {
if (!text.startsWith("/")) return null;
const [name, ...argumentParts] = text.split(/\s+/);
if (!name) return null;
const rawArguments = argumentParts.join(" ").trim();
return {
name,
arguments: rawArguments ? [rawArguments] : [],
};
}
function isSlashCommandAvailable(command: SlashCommand): boolean {
return command.availability.status === "available";
}
function formatSlashCommandHelp(commands: SlashCommand[]): string {
return commands
.map((command) => `${command.name} - ${command.shortDescription}`)
.join("\n");
}
function foldChunk(
state: ChatViewState,
raw: unknown,
receivedAtMs?: number | null,
): ChatViewState {
const { turns } = state;
if (!isReplyRecord(raw)) {
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
};
}
const emittedAtMs =
receivedAtMs ??
normalizeTimestamp(typeof raw.atMs === "number" ? raw.atMs : null);
const last = turns[turns.length - 1];
if (
(last?.role === "final" || last?.role === "error") &&
raw.kind !== "userPrompt" &&
raw.kind !== "UserPrompt"
) {
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs);
}
return state;
}
switch (raw.kind) {
case "textDelta":
return {
...state,
turns: appendAgentDelta(turns, String(raw.text ?? ""), emittedAtMs),
};
case "toolActivity":
return {
...state,
turns: [
...turns,
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs: emittedAtMs },
],
};
case "progress": {
const progress = normalizeProgress(raw.progress);
if (!progress) {
return {
...state,
turns: [
...turns,
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs },
],
};
}
return appendProgress(state, progress);
}
case "final": {
const content = String(raw.content ?? "");
const completedState = completeChatViewProgress(state);
const next = [...completedState.turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({
role: "final",
text: content,
receivedAtMs: emittedAtMs,
});
return { ...completedState, turns: next };
}
case "error": {
const completedState = completeChatViewProgress(state);
const next = [...completedState.turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({
role: "error",
text:
String(raw.message ?? "Erreur agent") === MODEL_RESPONSE_NOT_FINALIZED_MESSAGE
? TURN_FINISHED_WITHOUT_FINAL_MESSAGE
: String(raw.message ?? "Erreur agent"),
receivedAtMs: emittedAtMs,
});
return { ...completedState, turns: next };
}
case "userPrompt":
case "UserPrompt": {
const appended = appendUserPrompt(
turns,
String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments),
emittedAtMs,
);
return {
...state,
turns: appended.turns,
progressScope: appended.appended
? state.progressScope + 1
: state.progressScope,
};
}
default:
return {
...state,
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs: emittedAtMs }],
};
}
}
export function CustomAgentChatView({
projectId,
agentId,
agentName,
profile,
cwd,
layoutId,
nodeId,
sessionId,
conversationId,
freshOnOpen = false,
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
draftScope: explicitDraftScope,
mobile = false,
onOpenNativeTui,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, conversation, profile: profileGateway, project, system, workState } = useGateways();
const { t, tr } = useI18n();
const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey(
projectId,
agentId,
sessionId || !freshOnOpen ? conversationId : null,
);
const [turns, setTurns] = useState<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);
// A normal cell opening starts fresh when no live session exists. React
// remounts with a live session still preserve that runtime continuity.
const [activeConversationId, setActiveConversationId] = useState<string | null>(
sessionId || !freshOnOpen ? conversationId : null,
);
const activeConversationIdRef = useRef(activeConversationId);
activeConversationIdRef.current = activeConversationId;
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const initialDraftScope = explicitDraftScope ?? (
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
);
const initialAttachmentCacheKey = attachmentDraftCacheKey(initialDraftScope);
const initialMessageQueueKey = messageQueueCacheKey(
projectId,
agentId,
layoutId,
nodeId,
);
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>(() =>
initialAttachmentCacheKey
? (chatAttachmentDraftCache.get(initialAttachmentCacheKey) ?? [])
: [],
);
const [attachmentPicking, setAttachmentPicking] = useState(false);
const [queuedMessages, setQueuedMessages] = useState<QueuedChatMessage[]>(
() => chatMessageQueueCache.get(initialMessageQueueKey) ?? [],
);
const [queueOpen, setQueueOpen] = useState(false);
const [clearQueueConfirmation, setClearQueueConfirmation] = useState(false);
const [deletedQueueMessage, setDeletedQueueMessage] = useState<{
message: QueuedChatMessage;
index: number;
} | null>(null);
const [queueAnnouncement, setQueueAnnouncement] = useState("");
const [queueDrainReady, setQueueDrainReady] = useState(false);
const [terminalWorkStatePending, setTerminalWorkStatePending] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(
Boolean(sessionId || (!freshOnOpen && conversationId)),
);
const [liveSessionAttached, setLiveSessionAttached] = useState(false);
const [savedConversationLoadState, setSavedConversationLoadState] = useState<
"idle" | "loading" | "loaded" | "error" | "notFound"
>(!sessionId && !freshOnOpen && conversationId ? "loading" : "idle");
const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
>(null);
const [busy, setBusy] = useState(false);
const [transmissionActive, setTransmissionActive] = useState(false);
const [stopping, setStopping] = useState(false);
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
const [error, setError] = useState<ChatErrorState | null>(null);
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
const [conversationMenuOpen, setConversationMenuOpen] = useState(false);
const [conversationList, setConversationList] = useState<CustomAgentConversationSummary[]>([]);
const [conversationListLoading, setConversationListLoading] = useState(false);
const [conversationListError, setConversationListError] = useState<string | null>(null);
const [conversationNotice, setConversationNotice] = useState<string | null>(null);
const [resumedConversation, setResumedConversation] = useState(false);
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyNextAnchor, setHistoryNextAnchor] = useState<string | undefined>();
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null);
const turnsRef = useRef(turns);
const progressIndexRef = useRef(progressIndex);
const progressScopeRef = useRef(progressScope);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const scrollbackChunkCountRef = useRef(
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
);
const detachAgentChatRef = useRef<((sessionId: string) => Promise<void>) | undefined>(
agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined,
);
detachAgentChatRef.current = agent.detachAgentChat
? (sid) => agent.detachAgentChat!(sid)
: undefined;
const slashQuerySeqRef = useRef(0);
const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
const activeInteractionIdRef = useRef<string | null>(null);
const readModelSettledInteractionIdRef = useRef<string | null>(null);
const transmissionInFlightRef = useRef(false);
const cancelledInteractionIdsRef = useRef(new Set<string>());
const ignoreUnscopedChunksAfterStopRef = useRef(false);
const stopInFlightRef = useRef(false);
const stopTerminalOverrideRef = useRef(false);
const requesterRefreshSeqRef = useRef(0);
const draftHydratedRef = useRef(false);
const draftEditedBeforeHydrationRef = useRef(false);
const lastPersistedDraftRef = useRef("");
const draftRef = useRef("");
draftRef.current = draft;
const draftBeforeHistoryNavigationRef = useRef("");
const placeComposerAtEndAfterHistoryRef = useRef(false);
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
const attachmentCacheKeyRef = useRef(initialAttachmentCacheKey);
const queuedMessagesRef = useRef(queuedMessages);
queuedMessagesRef.current = queuedMessages;
const messageQueueKeyRef = useRef(initialMessageQueueKey);
const imageTokenSeqRef = useRef(0);
const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
const reconciledRestartIdsRef = useRef(new Set<string>());
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const onConversationIdRef = useRef(onConversationId);
onConversationIdRef.current = onConversationId;
const domId = nodeId ?? `agent-${agentId}`;
useEffect(() => {
let cancelled = false;
void project?.readProjectSettings(projectId).then((settings) => {
if (!cancelled) {
setProjectIcon(settings.icon ?? null);
}
}).catch(() => undefined);
const unsubscribeIcon = onProjectIconChanged((change) => {
if (change.projectId === projectId) setProjectIcon(change.icon);
});
return () => {
cancelled = true;
unsubscribeIcon();
};
}, [project, projectId]);
const clampComposerHeight = useCallback(
(heightPx: number): number => {
const visibleHeight =
chatRootRef.current?.getBoundingClientRect().height ??
composerRef.current?.closest<HTMLElement>("[data-custom-chat-shell]")
?.getBoundingClientRect().height ??
0;
const maxHeight =
visibleHeight > 0
? Math.max(composerDefaultHeightPx, Math.floor(visibleHeight * 0.5))
: Number.POSITIVE_INFINITY;
return Math.round(
Math.min(Math.max(heightPx, composerDefaultHeightPx), maxHeight),
);
},
[composerDefaultHeightPx],
);
const commitComposerHeight = useCallback(
(heightPx: number): void => {
onComposerHeightPxChange?.(clampComposerHeight(heightPx));
},
[clampComposerHeight, onComposerHeightPxChange],
);
const startComposerResize = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const startY = event.clientY;
const startHeight = composerHeightPx;
const onMove = (moveEvent: MouseEvent) => {
commitComposerHeight(startHeight + startY - moveEvent.clientY);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[commitComposerHeight, composerHeightPx],
);
const handleComposerResizeKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowUp") {
event.preventDefault();
commitComposerHeight(composerHeightPx + COMPOSER_RESIZE_STEP);
} else if (event.key === "ArrowDown") {
event.preventDefault();
commitComposerHeight(composerHeightPx - COMPOSER_RESIZE_STEP);
}
},
[commitComposerHeight, composerHeightPx],
);
const applyChatViewState = useCallback((next: ChatViewState) => {
turnsRef.current = next.turns;
progressIndexRef.current = next.progressIndex;
progressScopeRef.current = next.progressScope;
setTurns(next.turns);
setProgressIndex(next.progressIndex);
setProgressScope(next.progressScope);
}, []);
const updateChatViewState = useCallback(
(updater: (prev: ChatViewState) => ChatViewState) => {
applyChatViewState(
updater({
turns: turnsRef.current,
progressIndex: progressIndexRef.current,
progressScope: progressScopeRef.current,
}),
);
},
[applyChatViewState],
);
const supported = Boolean(
profile.structuredAdapter &&
agent.launchAgentChat &&
agent.reattachAgentChat &&
agent.sendAgentChat &&
agent.cancelAgentChat &&
agent.closeAgentChat,
);
const slashPrefix = slashCommandPrefix(draft);
const draftScope = useMemo<ChatDraftScope | null>(() => {
if (explicitDraftScope) return explicitDraftScope;
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
}, [explicitDraftScope, layoutId, nodeId, projectId]);
const attachmentCacheKey = attachmentDraftCacheKey(draftScope);
const messageQueueKey = useMemo(
() => messageQueueCacheKey(projectId, agentId, layoutId, nodeId),
[agentId, layoutId, nodeId, projectId],
);
const currentConversationCacheKey = useMemo(
() => conversationCacheKey(projectId, agentId, activeConversationId),
[activeConversationId, agentId, projectId],
);
const composerHistoryKey = useMemo(
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
[agentId, layoutId, nodeId, projectId],
);
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
readComposerHistory(composerHistoryKey),
);
const composerHistoryRef = useRef(composerHistory);
const [composerHistoryWarning, setComposerHistoryWarning] = useState<string | null>(null);
const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(null);
const displayTurns = turns;
const threadScroll = useThreadScrollController({
itemCount: displayTurns.length,
completedItemCount: displayTurns.filter(
(turn) => turn.role === "final" || turn.role === "error",
).length,
contentVersion: displayTurns
.map((turn) => {
if ("text" in turn) return `${turn.role}:${turn.text.length}`;
if (turn.role === "tool") return `${turn.role}:${turn.label.length}`;
return `${turn.role}:${JSON.stringify(turn.progress).length}:${Boolean(turn.settled)}`;
})
.join("|"),
});
const hasPreviousFinal = displayTurns.some((turn) => turn.role === "final");
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] =
useState<RequesterDelegationState>({
currentAgentBusy: false,
delegationTargetName: null,
});
const { restart, events: restartEvents } = useAgentRestart(agentId);
const delegationActive = requesterDelegation.delegationTargetName !== null;
const effectiveBusy =
busy ||
stopping ||
terminalWorkStatePending ||
Boolean(restart && restart.outcome !== "failed") ||
(!stopTerminalOverride &&
(requesterDelegation.currentAgentBusy || delegationActive));
const delegationFallbackText = requesterDelegation.delegationTargetName
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
: tr("Délégation en cours.", "Delegation in progress.");
const refreshRequesterDelegation = useCallback(async () => {
const refreshSeq = ++requesterRefreshSeqRef.current;
const interactionAtRefresh = activeInteractionIdRef.current;
if (!workState) {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
if (stopTerminalOverrideRef.current) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
}
return;
}
try {
const state = await workState.getProjectWorkState(projectId);
const current = state.agents.find((row) => row.agentId === agentId);
const currentAgentBusy = Boolean(
current?.busy.state === "busy" ||
current?.tickets.some((ticket) => ticket.status === "inProgress"),
);
const target = state.agents.find((row) =>
row.tickets.some(
(ticket) =>
ticket.status === "inProgress" &&
ticket.source.kind === "agent" &&
ticket.source.agentId === agentId,
),
);
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy,
delegationTargetName: target ? workAgentDisplayName(target) : null,
});
if (
interactionAtRefresh &&
activeInteractionIdRef.current === interactionAtRefresh &&
!currentAgentBusy &&
!target
) {
markProjectCliInteractionFinished(projectId, interactionAtRefresh);
activeInteractionIdRef.current = null;
readModelSettledInteractionIdRef.current = interactionAtRefresh;
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setBusy(false);
setQueueDrainReady(false);
updateChatViewState((previous) =>
foldChunk(previous, {
kind: "error",
message: TURN_FINISHED_WITHOUT_FINAL_MESSAGE,
}),
);
}
if (stopTerminalOverrideRef.current && !currentAgentBusy && !target) {
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
}
} catch {
if (refreshSeq !== requesterRefreshSeqRef.current) return;
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
}
}, [agentId, projectId, updateChatViewState, workState]);
const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return;
draftSaveSeqRef.current += 1;
lastPersistedDraftRef.current = "";
try {
if (agent.clearChatDraft) {
await agent.clearChatDraft(draftScope);
} else if (agent.saveChatDraft) {
await agent.saveChatDraft(draftScope, "");
}
} catch (e) {
console.warn("[chat-draft] clear failed", instrumentationError(e));
}
}, [agent, draftScope]);
const editDraft = useCallback((text: string) => {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
setDraft(text);
}, []);
const updateQueuedMessages = useCallback(
(
updater:
| QueuedChatMessage[]
| ((current: QueuedChatMessage[]) => QueuedChatMessage[]),
) => {
const next = typeof updater === "function"
? updater(queuedMessagesRef.current)
: updater;
queuedMessagesRef.current = next;
cacheMessageQueue(messageQueueKeyRef.current, next);
setQueuedMessages(next);
if (next.length === 0) {
setQueueOpen(false);
setClearQueueConfirmation(false);
}
},
[],
);
const recallComposerHistory = useCallback(
(direction: "previous" | "next") => {
if (composerHistory.length === 0) return;
placeComposerAtEndAfterHistoryRef.current = true;
setComposerHistoryIndex((current) => {
if (current === null && direction === "previous") {
draftBeforeHistoryNavigationRef.current = draftRef.current;
}
const nextIndex =
direction === "previous"
? current === null
? composerHistory.length - 1
: Math.max(0, current - 1)
: current === null
? null
: current >= composerHistory.length - 1
? null
: current + 1;
if (nextIndex === null) {
setDraft(draftBeforeHistoryNavigationRef.current);
draftBeforeHistoryNavigationRef.current = "";
} else {
setDraft(composerHistory[nextIndex] ?? "");
}
return nextIndex;
});
},
[composerHistory],
);
useEffect(() => {
if (!placeComposerAtEndAfterHistoryRef.current) return;
placeComposerAtEndAfterHistoryRef.current = false;
const composer = composerRef.current;
if (!composer) return;
const end = composer.value.length;
composer.setSelectionRange(end, end);
composer.scrollTop = composer.scrollHeight;
}, [composerHistoryIndex, draft]);
const handleComposerHistoryKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
) {
return false;
}
const target = event.currentTarget;
const caretIsAtTextEnd =
target.selectionStart === target.selectionEnd &&
target.selectionEnd === target.value.length;
if (!caretIsAtTextEnd) {
return false;
}
if (event.key === "ArrowUp" && composerHistory.length === 0) {
return false;
}
if (event.key === "ArrowDown" && composerHistoryIndex === null) {
return false;
}
event.preventDefault();
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
return true;
},
[composerHistory.length, composerHistoryIndex, recallComposerHistory],
);
useEffect(() => {
const restored = readComposerHistory(composerHistoryKey);
composerHistoryRef.current = restored;
setComposerHistory(restored);
setComposerHistoryWarning(null);
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}, [composerHistoryKey]);
// Attachments can be large, so keep draft bytes only in this renderer's
// bounded lifetime instead of serialising base64 into localStorage. The
// project/layout/node scope mirrors the persisted text draft contract.
useEffect(() => {
const key = attachmentCacheKeyRef.current;
if (key) cacheAttachmentDraft(key, attachments);
}, [attachments]);
useEffect(() => {
const previousKey = attachmentCacheKeyRef.current;
if (previousKey) {
cacheAttachmentDraft(previousKey, attachmentsRef.current);
}
attachmentCacheKeyRef.current = attachmentCacheKey;
setAttachments(
attachmentCacheKey
? (chatAttachmentDraftCache.get(attachmentCacheKey) ?? [])
: [],
);
}, [attachmentCacheKey]);
useEffect(() => {
const previousKey = messageQueueKeyRef.current;
cacheMessageQueue(previousKey, queuedMessagesRef.current);
messageQueueKeyRef.current = messageQueueKey;
const restored = chatMessageQueueCache.get(messageQueueKey) ?? [];
queuedMessagesRef.current = restored;
setQueuedMessages(restored);
setQueueOpen(false);
setClearQueueConfirmation(false);
setDeletedQueueMessage(null);
setQueueDrainReady(false);
}, [messageQueueKey]);
useEffect(() => {
void refreshRequesterDelegation();
}, [refreshRequesterDelegation]);
useEffect(() => {
if (!system?.onDomainEvent) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "agentBusyChanged" && event.projectId !== projectId) {
return;
}
if (
event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
event.type === "orchestratorRequestProcessed" ||
event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged"
) {
// Reconcile through the current work-state snapshot below. A domain
// event carries no turn id and may belong to a previously cancelled
// generation, so it must never settle a newer local turn directly.
void refreshRequesterDelegation();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [agentId, projectId, refreshRequesterDelegation, system]);
useEffect(() => {
if (restartEvents.length === 0) return;
updateChatViewState((previous) => {
const nextTurns = [...previous.turns];
let changed = false;
for (const event of restartEvents) {
const key = `${event.restartId}:${event.type}`;
if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) {
continue;
}
if (event.type === "agentRestartRequested") {
changed = true;
nextTurns.push({
role: "restart",
key,
text: tr("------- redémarrage de l’agent -------", "------- restarting agent -------"),
});
} else if (event.type === "agentRestartCompleted") {
changed = true;
setQueueDrainReady(true);
nextTurns.push({
role: "restart",
key,
text: tr("------- redémarrage de l’agent terminé -------", "------- agent restart completed -------"),
});
} else {
changed = true;
nextTurns.push({
role: "restart",
key,
text: tr("------- redémarrage de l’agent échoué -------", "------- agent restart failed -------"),
message: shortRestartFailure(event.message),
failed: true,
});
}
}
return changed ? { ...previous, turns: nextTurns } : previous;
});
}, [restartEvents, updateChatViewState]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
const sid = sessionRef.current;
if (!sid) return;
void detachAgentChatRef.current?.(sid).catch((e) => {
if (!isNotFound(e)) {
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
}
});
};
}, []);
const receive = useCallback(
(chunk: ReplyChunk, sourceInteractionId?: string) => {
if (sourceInteractionId) {
const isLateTerminalForReadModelSettledTurn =
activeInteractionIdRef.current === null &&
readModelSettledInteractionIdRef.current === sourceInteractionId &&
(chunk.kind === "final" || chunk.kind === "error");
if (
cancelledInteractionIdsRef.current.has(sourceInteractionId) ||
(activeInteractionIdRef.current !== sourceInteractionId &&
!isLateTerminalForReadModelSettledTurn)
) {
return;
}
}
if (!sourceInteractionId && ignoreUnscopedChunksAfterStopRef.current) {
if (chunk.kind === "userPrompt") {
ignoreUnscopedChunksAfterStopRef.current = false;
} else {
return;
}
}
setTranscriptUnavailableReason(null);
scrollbackChunkCountRef.current += 1;
const activeInteractionId = activeInteractionIdRef.current;
if (
activeInteractionId &&
chunk.kind === "progress" &&
chunk.progress.kind === "turn"
) {
if (
chunk.progress.stage === "started" ||
chunk.progress.stage === "delta"
) {
markProjectCliInteractionStarted(projectId, activeInteractionId);
}
}
updateChatViewState((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") {
if (sourceInteractionId) {
readModelSettledInteractionIdRef.current = null;
}
setQueueDrainReady(chunk.kind === "final");
if (activeInteractionIdRef.current) {
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
if (workState) {
setTerminalWorkStatePending(true);
void refreshRequesterDelegation().finally(() => {
if (mountedRef.current) setTerminalWorkStatePending(false);
});
} else {
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
}
}
},
[projectId, refreshRequesterDelegation, updateChatViewState, workState],
);
useEffect(() => {
if (currentSession) {
chatSessionViewCache.set(currentSession, {
turns,
progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current,
sessionId: currentSession,
});
}
if (currentConversationCacheKey) {
const previous = chatConversationViewCache.get(currentConversationCacheKey);
const cachedSessionId = currentSession ?? previous?.sessionId;
if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) {
chatConversationViewCache.set(currentConversationCacheKey, {
turns,
progressIndex,
progressScope,
chunkCount: scrollbackChunkCountRef.current,
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
});
}
}
}, [currentConversationCacheKey, currentSession, progressIndex, progressScope, turns]);
useEffect(() => {
draftSaveSeqRef.current += 1;
draftHydratedRef.current = false;
draftEditedBeforeHydrationRef.current = false;
lastPersistedDraftRef.current = "";
setDraft("");
if (!draftScope || !agent.readChatDraft) {
draftHydratedRef.current = true;
return;
}
let cancelled = false;
agent
.readChatDraft(draftScope)
.then((snapshot) => {
if (cancelled || !mountedRef.current) return;
const restored = snapshot.text ?? "";
lastPersistedDraftRef.current = restored;
if (!draftEditedBeforeHydrationRef.current) {
setDraft(restored);
}
})
.catch((e) => {
if (!cancelled) {
console.warn("[chat-draft] read failed", instrumentationError(e));
}
})
.finally(() => {
if (!cancelled) {
draftHydratedRef.current = true;
if (
draftEditedBeforeHydrationRef.current &&
agent.saveChatDraft &&
draftRef.current !== lastPersistedDraftRef.current
) {
const text = draftRef.current;
void agent
.saveChatDraft(draftScope, text)
.then(() => {
lastPersistedDraftRef.current = text;
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}
}
});
return () => {
cancelled = true;
};
}, [agent, draftScope]);
useEffect(() => {
if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return;
if (draft === lastPersistedDraftRef.current) return;
const seq = ++draftSaveSeqRef.current;
const timeout = window.setTimeout(() => {
if (draftSaveSeqRef.current !== seq) return;
agent
.saveChatDraft!(draftScope, draft)
.then(() => {
if (draftSaveSeqRef.current === seq) {
lastPersistedDraftRef.current = draft;
}
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}, 250);
return () => window.clearTimeout(timeout);
}, [agent, draft, draftScope]);
useEffect(() => {
if (
!supported ||
!agent.listSlashCommands ||
slashPrefix === null ||
effectiveBusy ||
opening
) {
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
return;
}
const seq = ++slashQuerySeqRef.current;
agent
.listSlashCommands(slashPrefix)
.then((commands) => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands(commands);
setSlashMenuOpen(commands.length > 0);
const firstAvailable = commands.findIndex(isSlashCommandAvailable);
setSlashActiveIndex(firstAvailable >= 0 ? firstAvailable : 0);
})
.catch(() => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands([]);
setSlashMenuOpen(false);
setSlashActiveIndex(0);
});
}, [agent, effectiveBusy, opening, slashPrefix, supported]);
const selectSlashCommand = useCallback((command: SlashCommand) => {
if (!isSlashCommandAvailable(command)) return;
editDraft(`${command.name} `);
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
requestAnimationFrame(() => composerRef.current?.focus());
}, [editDraft]);
const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId;
onSessionIdRef.current(nextSessionId);
}, []);
const publishConversationId = useCallback((nextConversationId: string | null) => {
activeConversationIdRef.current = nextConversationId;
setActiveConversationId(nextConversationId);
onConversationIdRef.current(nextConversationId);
}, []);
const reattachStructuredSession = useCallback(
async (
sid: string,
options: {
applyScrollback?: boolean;
stableIdentity?: boolean;
preserveRestartTurns?: boolean;
preserveViewOnEmptyScrollback?: boolean;
} = {},
) => {
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
console.debug("[ticket149] reattachStructuredSession:start", {
timestamp: new Date().toISOString(),
sessionId: sid,
options,
});
let reattached: Awaited<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);
setLiveSessionAttached(true);
if (reattached.sessionId !== sid) {
publishSessionId(reattached.sessionId);
}
if (reattached.conversationId) {
publishConversationId(reattached.conversationId);
}
if (
options.applyScrollback &&
(reattached.scrollback.length > 0 || !options.preserveViewOnEmptyScrollback)
) {
scrollbackChunkCountRef.current = reattached.scrollback.length;
let replayed = reattached.scrollback.reduce(
(state, chunk) => foldChunk(state, chunk),
emptyChatViewState(),
);
if (options.preserveRestartTurns) {
const restartTurns = turnsRef.current.filter((turn) => turn.role === "restart");
if (restartTurns.length > 0) {
replayed = { ...replayed, turns: [...replayed.turns, ...restartTurns] };
}
}
applyChatViewState(replayed);
setBusy(turnsIndicateBusy(replayed));
}
if (reattached.scrollback.length > 0) {
setTranscriptUnavailableReason(null);
} else if (reattached.transcriptUnavailableReason) {
setTranscriptUnavailableReason(reattached.transcriptUnavailableReason);
}
return reattached.sessionId;
},
[agent, agentId, applyChatViewState, projectId, publishConversationId, publishSessionId, receive],
);
useEffect(() => {
const completed = [...restartEvents]
.reverse()
.find((event) => event.type === "agentRestartCompleted");
if (!completed || reconciledRestartIdsRef.current.has(completed.restartId)) return;
const sid = sessionRef.current ?? externalSessionId;
if (!sid) return;
reconciledRestartIdsRef.current.add(completed.restartId);
let cancelled = false;
setOpening(true);
setError(null);
void reattachStructuredSession(sid, {
applyScrollback: true,
stableIdentity: true,
preserveRestartTurns: true,
})
.catch((e) => {
if (!cancelled) {
setError({ message: describeOpenFailure(e), openFailure: true });
}
})
.finally(() => {
if (!cancelled) setOpening(false);
});
return () => {
cancelled = true;
};
}, [externalSessionId, reattachStructuredSession, restartEvents]);
const recoverStructuredSession = useCallback(
async (
options: {
applyScrollback?: boolean;
retryAttachNotFound?: boolean;
conversationId?: string | null;
} = {},
) => {
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
console.debug("[ticket149] recoverStructuredSession:start", {
timestamp: new Date().toISOString(),
currentSession: sessionRef.current,
conversationId: activeConversationIdRef.current,
options,
});
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
for (;;) {
const launchRequest = {
cwd,
rows: 24,
cols: 80,
conversationId:
options.conversationId === undefined
? activeConversationIdRef.current ?? undefined
: options.conversationId ?? undefined,
...(options.conversationId === null ? { freshStart: true } : {}),
...(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) {
publishConversationId(launched.assignedConversationId);
}
if (launched.resumed) {
const replayed = {
...emptyChatViewState(),
turns: durableHistoryTurns(launched.history ?? []),
};
applyChatViewState(replayed);
setResumedConversation(true);
setConversationNotice(null);
setHistoryHasMore(Boolean(launched.historyHasMore));
setHistoryNextAnchor(launched.historyNextAnchor);
setHistoryError(null);
}
if (launched.transcriptUnavailableReason) {
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
}
try {
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
applyScrollback: launched.resumed ? false : options.applyScrollback,
});
console.debug("[ticket149] recoverStructuredSession:success", {
timestamp: new Date().toISOString(),
launchedSessionId: launched.sessionId,
recoveredSessionId,
});
return recoveredSessionId;
} catch (e) {
if (isNotFound(e) && retryAttachNotFound) {
retryAttachNotFound = false;
if (mountedRef.current) {
setCurrentSession(null);
publishSessionId(null);
}
continue;
}
console.error("[ticket149] recoverStructuredSession:reattach:error", {
timestamp: new Date().toISOString(),
launchedSessionId: launched.sessionId,
error: instrumentationError(e),
});
throw e;
}
}
},
[
agent,
projectId,
agentId,
cwd,
nodeId,
reattachStructuredSession,
applyChatViewState,
publishConversationId,
publishSessionId,
],
);
const prefersReducedMotion = useCallback(
() => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
[],
);
const scrollToLatest = useCallback(() => {
threadScroll.scrollToBottom(prefersReducedMotion() ? "auto" : "smooth");
}, [prefersReducedMotion, threadScroll]);
const scrollToPreviousFinal = useCallback(() => {
const element = threadScroll.scrollRef.current;
if (!element) return;
const finals = Array.from(
element.querySelectorAll<HTMLElement>("[data-chat-final]"),
);
if (finals.length === 0) return;
const currentTop = element.scrollTop;
const earlier = finals.filter((final) => final.offsetTop < currentTop - 8);
const target = earlier.at(-1) ?? finals.at(-1);
if (!target) return;
const top = Math.max(0, target.offsetTop - 8);
if (element.scrollTo) {
element.scrollTo({
top,
behavior: prefersReducedMotion() ? "auto" : "smooth",
});
} else {
element.scrollTop = top;
}
threadScroll.readHistory();
}, [prefersReducedMotion, threadScroll]);
useEffect(() => {
if (selfEmittedSessionIdRef.current === sessionId) {
console.debug("[ticket149] openOrAttach:self-session-echo:skip", {
timestamp: new Date().toISOString(),
sessionId,
});
selfEmittedSessionIdRef.current = undefined;
return;
}
setExternalSessionId(sessionId);
setLiveSessionAttached(false);
}, [sessionId]);
useEffect(() => {
if (!supported) return;
if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) {
setOpening(false);
setError(null);
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
return;
}
let cancelled = false;
async function openOrAttach() {
const execution = openOrAttachCountRef.current + 1;
openOrAttachCountRef.current = execution;
console.debug("[ticket149] openOrAttach:start", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
});
setOpening(true);
setError(null);
setTranscriptUnavailableReason(null);
try {
const localSession = sessionRef.current;
if (!externalSessionId && localSession) {
console.debug("[ticket149] openOrAttach:local-session-present:skip", {
timestamp: new Date().toISOString(),
execution,
currentSession: localSession,
});
return;
}
if (externalSessionId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
const cached = chatSessionViewCache.get(externalSessionId);
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
});
}
try {
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
if (cancelled) return;
console.debug("[ticket149] openOrAttach:reattach-existing:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
});
return;
} catch (e) {
// The backend contract for `reattach_agent_chat` (NOT_FOUND) is that
// the session is gone (closed/never live) and the caller falls back
// to a fresh launch — any other error still surfaces to the user.
if (!isNotFound(e)) throw e;
try {
const replacementSessionId = await reattachStructuredSession(externalSessionId, {
applyScrollback: true,
stableIdentity: true,
});
if (cancelled) return;
publishSessionId(replacementSessionId);
console.debug("[ticket295] openOrAttach:stable-reattach:success", {
timestamp: new Date().toISOString(),
execution,
requestedSessionId: externalSessionId,
replacementSessionId,
});
return;
} catch (stableError) {
if (!isNotFound(stableError)) throw stableError;
}
if (!cancelled) {
chatSessionViewCache.delete(externalSessionId);
const cacheKey = conversationCacheKey(
projectId,
agentId,
activeConversationIdRef.current,
);
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
if (cacheKey && cached?.sessionId === externalSessionId) {
chatConversationViewCache.set(cacheKey, {
turns: cached.turns,
progressIndex: cached.progressIndex,
progressScope: cached.progressScope,
chunkCount: cached.chunkCount,
});
}
setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null);
if (freshOnOpen) {
// After an application reboot, the leaf can still contain both
// ids even though the structured runtime no longer exists. A
// normal cell opening must not turn that stale runtime pivot
// into an implicit conversation resume (#372).
scrollbackChunkCountRef.current = 0;
applyChatViewState(emptyChatViewState());
publishConversationId(null);
setSavedConversationLoadState("idle");
setResumedConversation(false);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setHistoryError(null);
}
}
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
error: instrumentationError(e),
});
return;
}
} else {
const cacheKey = conversationCacheKey(
projectId,
agentId,
activeConversationIdRef.current,
);
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount;
applyChatViewState({
turns: cached.turns,
progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0,
});
}
const savedConversationId = activeConversationIdRef.current;
if (!savedConversationId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
if (freshOnOpen) {
// A missing layout session can be a transient remount while this
// agent is still active. Preserve that live continuity without
// treating the leaf's persisted conversation id as a UI choice.
const live = agent.listLiveAgents
? (await agent.listLiveAgents(projectId)).find(
(candidate) =>
candidate.agentId === agentId && candidate.kind === "structured",
)
: undefined;
if (live) {
const rebound =
nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
? await agent.attachLiveAgent(projectId, agentId, nodeId)
: live;
const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
applyScrollback: true,
preserveViewOnEmptyScrollback: true,
});
if (cancelled) return;
publishSessionId(attachedSessionId);
setResumedConversation(false);
return;
}
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
return;
}
// A cell can remount while its structured runtime remains live (for
// example after switching tabs). The layout session can temporarily
// be absent, but the conversation cache and live registry still carry
// the runtime identity. Reattach that runtime before considering the
// durable transcript: a conversation id alone is not a request to
// replace an active view with saved history (#370).
const cachedSessionId = cached?.sessionId;
if (cachedSessionId) {
try {
const attachedSessionId = await reattachStructuredSession(cachedSessionId, {
applyScrollback: true,
stableIdentity: true,
preserveViewOnEmptyScrollback: true,
});
if (cancelled) return;
publishSessionId(attachedSessionId);
setSavedConversationLoadState("idle");
setResumedConversation(false);
return;
} catch (e) {
if (!isNotFound(e)) throw e;
}
}
const live = agent.listLiveAgents
? (await agent.listLiveAgents(projectId)).find(
(candidate) => candidate.agentId === agentId && candidate.kind === "structured",
)
: undefined;
if (live) {
const rebound =
nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
? await agent.attachLiveAgent(projectId, agentId, nodeId)
: live;
const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
applyScrollback: true,
preserveViewOnEmptyScrollback: true,
});
if (cancelled) return;
publishSessionId(attachedSessionId);
setSavedConversationLoadState("idle");
setResumedConversation(false);
return;
}
setSavedConversationLoadState("loading");
setLiveSessionAttached(false);
try {
const page = await conversation.readPage(projectId, savedConversationId, {
direction: "backward",
limit: 50,
});
if (cancelled) return;
applyChatViewState({
...emptyChatViewState(),
turns: durableHistoryTurns(page.turns),
});
setResumedConversation(true);
setConversationNotice(null);
setHistoryHasMore(page.hasMore);
setHistoryNextAnchor(page.nextAnchor);
setHistoryError(null);
setSavedConversationLoadState("loaded");
} catch (e) {
if (cancelled) return;
setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error");
}
return;
}
} catch (e) {
console.error("[ticket149] openOrAttach:error", {
timestamp: new Date().toISOString(),
execution,
sessionId: externalSessionId,
error: instrumentationError(e),
});
if (!cancelled && activeConversationIdRef.current && isNotFound(e)) {
setTranscriptUnavailableReason("conversationNotFound");
setCurrentSession(null);
publishSessionId(null);
} else if (!cancelled) {
setError({ message: describeOpenFailure(e), openFailure: true });
}
} finally {
if (!cancelled) setOpening(false);
}
}
void openOrAttach();
return () => {
cancelled = true;
};
}, [
supported,
externalSessionId,
reattachStructuredSession,
recoverStructuredSession,
publishSessionId,
publishConversationId,
applyChatViewState,
conversation,
agent,
projectId,
agentId,
nodeId,
freshOnOpen,
openRetryNonce,
]);
const canSubmit = useMemo(
() =>
supported &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!opening &&
savedConversationLoadState !== "error" &&
savedConversationLoadState !== "notFound",
[supported, draft, attachments.length, opening, savedConversationLoadState],
);
const unavailableLinkedTranscript =
displayTurns.length === 0 &&
(transcriptUnavailableReason === "missingTranscript" ||
transcriptUnavailableReason === "conversationNotFound")
? transcriptUnavailableReason
: null;
const savedConversationLoadFailure =
savedConversationLoadState === "error" || savedConversationLoadState === "notFound"
? savedConversationLoadState
: null;
function retryUnavailableTranscript() {
const sid = sessionRef.current;
setTranscriptUnavailableReason(null);
setSavedConversationLoadState(
!sid && activeConversationIdRef.current ? "loading" : "idle",
);
setError(null);
if (sid) {
// Force the next user-triggered pass through the attach path. Keeping the
// local session here would trip the #307/idempotency short-circuit.
setCurrentSession(null);
setExternalSessionId(sid);
}
setOpenRetryNonce((value) => value + 1);
}
const refreshConversationList = useCallback(async () => {
if (!agent.listCustomAgentConversations) {
setConversationListError(
tr("Liste des conversations indisponible.", "Conversation list unavailable."),
);
return;
}
setConversationListLoading(true);
setConversationListError(null);
try {
setConversationList(
await agent.listCustomAgentConversations(projectId, agentId, 50),
);
} catch (e) {
setConversationListError(describe(e));
} finally {
setConversationListLoading(false);
}
}, [agent, agentId, projectId, tr]);
useEffect(() => {
if (conversationMenuOpen) void refreshConversationList();
}, [conversationMenuOpen, refreshConversationList]);
async function reopenConversation(nextConversationId: string) {
if (effectiveBusy || opening || nextConversationId === activeConversationId) return;
setConversationMenuOpen(false);
setOpening(true);
setError(null);
try {
const sid = sessionRef.current;
if (sid) await agent.closeAgentChat?.(sid);
setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null);
publishConversationId(nextConversationId);
applyChatViewState(emptyChatViewState());
setSavedConversationLoadState("loading");
setResumedConversation(false);
setConversationNotice(null);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setOpenRetryNonce((value) => value + 1);
} catch (e) {
setError({ message: describe(e), openFailure: false });
} finally {
setOpening(false);
}
}
async function startNewConversation() {
if (effectiveBusy || opening) return;
setConversationMenuOpen(false);
await executeSlashCommand({ name: "/clean", arguments: [] });
}
async function loadOlderHistory() {
if (!activeConversationId || !historyNextAnchor || historyLoading) return;
setHistoryLoading(true);
setHistoryError(null);
try {
const page = await conversation.readPage(projectId, activeConversationId, {
anchor: historyNextAnchor,
direction: "backward",
limit: 50,
});
const older = durableHistoryTurns(page.turns);
updateChatViewState((previous) => ({
...previous,
turns: [...older, ...previous.turns],
}));
setHistoryHasMore(page.hasMore);
setHistoryNextAnchor(page.nextAnchor);
} catch (e) {
setHistoryError(describe(e));
} finally {
setHistoryLoading(false);
}
}
async function openProfileDialog() {
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
Boolean(candidate.structuredAdapter),
);
if (availableProfiles.length === 0) {
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
}
setDraft("");
setProfileDialog({
profiles: availableProfiles,
selectedProfileId:
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
availableProfiles[0].id,
confirmedReset: false,
busy: false,
error: null,
});
}
async function confirmProfileCommandChange() {
if (!profileDialog) return;
if (!agent.changeAgentProfile) {
setProfileDialog((prev) =>
prev ? { ...prev, error: tr("Changement de profil indisponible.", "Profile change unavailable.") } : prev,
);
return;
}
const selected = profileDialog.profiles.find(
(candidate) => candidate.id === profileDialog.selectedProfileId,
);
if (!selected) return;
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
try {
const { relaunchedSession } = await agent.changeAgentProfile(
projectId,
agentId,
selected.id,
24,
80,
);
applyChatViewState(emptyChatViewState());
setCurrentSession(null);
publishSessionId(null);
onConversationIdRef.current(null);
if (relaunchedSession?.sessionId) {
try {
setCurrentSession(relaunchedSession.sessionId);
publishSessionId(relaunchedSession.sessionId);
await reattachStructuredSession(relaunchedSession.sessionId, {
applyScrollback: false,
});
} catch {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
} else {
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
applyChatViewState({
turns: [
{
role: "tool",
label: tr(`Profil changé vers ${selected.name}.`, `Profile changed to ${selected.name}.`),
receivedAtMs: null,
},
],
progressIndex: new Map(),
progressScope: 0,
});
setProfileDialog(null);
} catch (e) {
setProfileDialog((prev) =>
prev ? { ...prev, busy: false, error: describe(e) } : prev,
);
}
}
async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) {
setError({
message: tr(`Commande ${invocation.name} indisponible dans ce runtime.`, `Command ${invocation.name} is unavailable in this runtime.`),
openFailure: false,
});
return;
}
setDraft("");
void clearPersistedDraft();
setError(null);
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
...(invocation.name === "/clean" ? { conversationId: null } : {}),
}));
const result = await agent.executeSlashCommand(invocation.name, {
sessionId: sid,
...(invocation.arguments.length > 0
? { arguments: invocation.arguments }
: {}),
});
if (result.effect.kind === "help") {
const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: null }],
}));
return;
}
if (result.effect.kind === "cleanConversation") {
applyChatViewState(emptyChatViewState());
setCurrentSession(result.effect.sessionId);
setExternalSessionId(result.effect.sessionId);
publishSessionId(result.effect.sessionId);
publishConversationId(result.effect.conversationId ?? null);
setSavedConversationLoadState("idle");
setResumedConversation(false);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setHistoryError(null);
setConversationNotice(
tr("Nouvelle conversation prête.", "New conversation ready."),
);
return;
}
if (result.effect.kind === "profileSwitch") {
await openProfileDialog();
return;
}
if (result.effect.kind === "pluginCallback") {
await pluginRuntime.registry.runCommandStrict(
result.effect.pluginId,
result.effect.commandId,
...result.effect.arguments,
);
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
role: "tool",
label: tr(`Commande plugin ${result.command.name} exécutée.`, `Plugin command ${result.command.name} executed.`),
receivedAtMs: null,
},
],
}));
return;
}
} catch (e) {
const message = `${tr(`Commande ${invocation.name} échouée :`, `Command ${invocation.name} failed:`)} ${describe(e)}`;
setError({ message, openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: null }],
}));
}
}
async function pickAttachment() {
setAttachmentPicking(true);
try {
const path = await system.pickFile();
if (path) {
setAttachments((prev) => [
...prev,
{
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: pathBasename(path),
input: { path, sourceKind: "localFile" },
},
]);
}
} finally {
setAttachmentPicking(false);
}
}
function removeAttachment(item: AttachmentDraft) {
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
);
if (item.token) {
editDraft(removeAttachmentToken(draftRef.current, item.token));
}
}
async function pasteClipboardFiles(
event: ClipboardEvent<HTMLTextAreaElement>,
) {
const target = event.currentTarget;
const pastedText = event.clipboardData.getData("text/plain");
const selectionStart = target.selectionStart;
const selectionEnd = target.selectionEnd;
const filesFromEvent = dataTransferFiles(event.clipboardData);
try {
const files =
filesFromEvent.length > 0 ? filesFromEvent : await asyncClipboardImageFiles();
if (files.length === 0) return;
if (filesFromEvent.length > 0) event.preventDefault();
const nextAttachments = await Promise.all(
files.map(clipboardFileToAttachment),
);
const known = new Set(
attachmentsRef.current
.map((attachment) => attachment.dedupeKey)
.filter((key): key is string => Boolean(key)),
);
const uniqueAttachments = nextAttachments
.filter((attachment) => {
if (!attachment.dedupeKey) return true;
if (known.has(attachment.dedupeKey)) return false;
known.add(attachment.dedupeKey);
return true;
})
.map((attachment) => {
if (!attachment.previewUrl) return attachment;
const token = `[Image ${++imageTokenSeqRef.current}]`;
return { ...attachment, token };
});
if (uniqueAttachments.length > 0) {
setAttachments((prev) => [...prev, ...uniqueAttachments]);
}
const imageTokens = uniqueAttachments
.map((attachment) => attachment.token)
.filter((token): token is string => Boolean(token));
const insertionParts = [
...(pastedText ? [pastedText] : []),
...(imageTokens.length > 0 ? [imageTokens.join(" ")] : []),
];
if (insertionParts.length > 0) {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setDraft((prev) =>
insertTextAtSelection(
prev,
insertionParts.join(" "),
selectionStart,
selectionEnd,
),
);
}
} catch (e) {
setError({ message: describe(e), openFailure: false });
}
}
function rememberComposerText(text: string) {
if (text) {
const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text);
composerHistoryRef.current = nextHistory;
const persisted = writeComposerHistory(composerHistoryKey, nextHistory);
setComposerHistory(nextHistory);
setComposerHistoryWarning(
persisted ? null : tr("Historique des prompts non sauvegardé.", "Prompt history was not saved."),
);
setComposerHistoryIndex(null);
draftBeforeHistoryNavigationRef.current = "";
}
}
function clearComposerAfterSubmit() {
setDraft("");
setAttachments([]);
imageTokenSeqRef.current = 0;
void clearPersistedDraft();
}
function enqueueComposerMessage(text: string) {
const message: QueuedChatMessage = {
id: `${agentId}:queued:${Date.now()}:${Math.random().toString(36).slice(2)}`,
text,
attachments: [...attachments],
createdAtMs: Date.now(),
};
updateQueuedMessages((current) => [...current, message]);
clearComposerAfterSubmit();
setDeletedQueueMessage(null);
setQueueAnnouncement(
tr("Message ajouté à la file d’attente", "Message added to the queue"),
);
}
async function transmitMessage(message: QueuedChatMessage) {
const text = message.text;
if (!agent.sendAgentChat || transmissionInFlightRef.current) return;
setQueueDrainReady(false);
const slashInvocation = slashCommandInvocation(text);
if (slashInvocation && message.id.endsWith(":composer")) {
await executeSlashCommand(slashInvocation);
return;
}
transmissionInFlightRef.current = true;
setTransmissionActive(true);
const outgoingAttachments = message.attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
stopTerminalOverrideRef.current = false;
setStopTerminalOverride(false);
activeInteractionIdRef.current = interactionId;
readModelSettledInteractionIdRef.current = null;
markProjectCliInteractionStarted(projectId, interactionId);
const failInteraction = () => {
if (activeInteractionIdRef.current !== interactionId) return;
markProjectCliInteractionFinished(projectId, interactionId);
activeInteractionIdRef.current = null;
if (readModelSettledInteractionIdRef.current === interactionId) {
readModelSettledInteractionIdRef.current = null;
}
};
threadScroll.followLatest();
setBusy(true);
setError(null);
const continuingResumedConversation = resumedConversation;
if (continuingResumedConversation) {
setResumedConversation(false);
setConversationNotice(null);
}
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
...(continuingResumedConversation
? [{ role: "separator" as const, text: tr("Conversation poursuivie", "Conversation continued") }]
: []),
{
role: "user",
text: displayText,
receivedAtMs: null,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
},
],
progressScope: prev.progressScope + 1,
}));
const sendTurn = (sid: string) =>
attachmentInputs.length > 0
? agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId), {
attachments: attachmentInputs,
})
: agent.sendAgentChat!(sid, text, (chunk) => receive(chunk, interactionId));
try {
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
await sendTurn(sid);
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
} catch (e) {
if (cancelledInteractionIdsRef.current.has(interactionId)) return;
if (isNotFound(e)) {
try {
setCurrentSession(null);
publishSessionId(null);
const recoveredSession = await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
});
await sendTurn(recoveredSession);
return;
} catch (recoveryError) {
failInteraction();
setBusy(false);
setQueueDrainReady(false);
setError({ message: describe(recoveryError), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{ role: "error", text: describe(recoveryError), receivedAtMs: null },
],
}));
return;
}
}
failInteraction();
setBusy(false);
setQueueDrainReady(false);
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: null }],
}));
} finally {
transmissionInFlightRef.current = false;
setTransmissionActive(false);
}
}
async function send() {
const text = draft.trim();
if (!canSubmit || !agent.sendAgentChat) return;
rememberComposerText(text);
if (effectiveBusy || transmissionInFlightRef.current) {
enqueueComposerMessage(text);
return;
}
const message: QueuedChatMessage = {
id: `${agentId}:${++interactionSeqRef.current}:composer`,
text,
attachments: [...attachments],
createdAtMs: Date.now(),
};
clearComposerAfterSubmit();
await transmitMessage(message);
}
async function stopCurrentRequest() {
const sid = sessionRef.current;
if (
!sid ||
!agent.cancelAgentChat ||
stopping ||
stopInFlightRef.current ||
stopTerminalOverrideRef.current
) {
return;
}
stopInFlightRef.current = true;
setQueueDrainReady(false);
ignoreUnscopedChunksAfterStopRef.current = true;
const interactionBeingCancelled = activeInteractionIdRef.current;
readModelSettledInteractionIdRef.current = null;
if (interactionBeingCancelled) {
cancelledInteractionIdsRef.current.add(interactionBeingCancelled);
}
setStopping(true);
setError(null);
const settleStoppedRequest = (alreadyStopped: boolean) => {
if (stopTerminalOverrideRef.current) return;
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
if (activeInteractionIdRef.current) {
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
setBusy(false);
setOpening(false);
updateChatViewState((prev) => {
const completed = completeChatViewProgress(prev);
return {
...completed,
turns: [
...completed.turns,
{
role: "tool",
label: alreadyStopped
? tr("Demande déjà arrêtée.", "Request already stopped.")
: tr("Demande arrêtée.", "Request stopped."),
receivedAtMs: null,
},
],
};
});
void refreshRequesterDelegation();
};
try {
const result = await agent.cancelAgentChat(sid);
settleStoppedRequest(result.outcome !== "cancelled");
} catch (e) {
if (isNotFound(e)) {
settleStoppedRequest(true);
return;
}
if (interactionBeingCancelled) {
cancelledInteractionIdsRef.current.delete(interactionBeingCancelled);
}
ignoreUnscopedChunksAfterStopRef.current = false;
setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({
...prev,
turns: [
...prev.turns,
{
role: "error",
text: `${tr("Échec de l’arrêt de la demande", "Failed to stop request")}: ${describe(e)}`,
receivedAtMs: null,
},
],
}));
} finally {
stopInFlightRef.current = false;
setStopping(false);
}
}
function editQueuedMessage(message: QueuedChatMessage) {
updateQueuedMessages((current) =>
current.filter((candidate) => candidate.id !== message.id),
);
editDraft(message.text);
setAttachments(message.attachments);
setDeletedQueueMessage(null);
setQueueAnnouncement(
tr("Message remis dans le champ de saisie", "Message returned to the composer"),
);
window.setTimeout(() => composerRef.current?.focus(), 0);
}
function deleteQueuedMessage(message: QueuedChatMessage) {
const index = queuedMessagesRef.current.findIndex(
(candidate) => candidate.id === message.id,
);
if (index < 0) return;
updateQueuedMessages((current) =>
current.filter((candidate) => candidate.id !== message.id),
);
setDeletedQueueMessage({ message, index });
setQueueAnnouncement(tr("Message supprimé", "Message deleted"));
}
function undoDeleteQueuedMessage() {
if (!deletedQueueMessage) return;
updateQueuedMessages((current) => {
const next = [...current];
next.splice(
Math.min(deletedQueueMessage.index, next.length),
0,
deletedQueueMessage.message,
);
return next;
});
setDeletedQueueMessage(null);
setQueueAnnouncement(tr("Suppression annulée", "Deletion undone"));
}
function clearQueuedMessages() {
updateQueuedMessages([]);
setDeletedQueueMessage(null);
setQueueAnnouncement(
tr("File d’attente supprimée", "Queue cleared"),
);
}
useEffect(() => {
if (
!queueDrainReady ||
effectiveBusy ||
opening ||
!supported ||
transmissionActive ||
queuedMessages.length === 0
) {
return;
}
const next = queuedMessages[0];
updateQueuedMessages((current) =>
current[0]?.id === next.id
? current.slice(1)
: current.filter((candidate) => candidate.id !== next.id),
);
setQueueAnnouncement(
tr("Envoi du prochain message en attente", "Sending the next queued message"),
);
void transmitMessage(next);
}, [effectiveBusy, opening, queueDrainReady, queuedMessages, supported, transmissionActive]);
const projectFallbackLabel = cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? projectId;
return (
<div
ref={chatRootRef}
data-testid="custom-agent-chat-view"
className="custom-agent-chat-view relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
>
<div
role="toolbar"
aria-label={t("custom.actions")}
data-testid="custom-agent-chat-header"
className="custom-agent-chat-header relative z-20 shrink-0 border-b border-border"
>
<div
data-testid="custom-agent-chat-identity"
className="custom-agent-chat-identity min-w-0 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
data-testid="custom-agent-chat-conversations"
className="custom-agent-chat-conversations relative shrink-0"
>
<Button
size="sm"
variant="ghost"
className="custom-agent-chat-conversations-button"
aria-expanded={conversationMenuOpen}
aria-haspopup="menu"
onClick={() => setConversationMenuOpen((open) => !open)}
>
{tr("Conversations", "Conversations")}
</Button>
{conversationMenuOpen && (
<div
role="menu"
aria-label={tr("Conversations", "Conversations")}
data-testid="custom-agent-chat-conversations-menu"
className="custom-agent-chat-conversations-menu absolute top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-raised shadow-xl"
>
{effectiveBusy && (
<p className="border-b border-border bg-warning/10 px-3 py-2 text-xs text-warning">
{tr(
"Arrêtez la réponse avant de changer de conversation.",
"Stop the response before changing conversations.",
)}
</p>
)}
<button
type="button"
role="menuitem"
disabled={effectiveBusy || opening}
className="flex min-h-11 w-full items-center px-3 text-left text-sm font-semibold text-content hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void startNewConversation()}
>
{tr("Nouvelle conversation", "New conversation")}
</button>
<div className="border-t border-border" />
<div className="max-h-72 overflow-y-auto py-1">
{conversationListLoading ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted" role="status">
<Spinner size={14} />
{tr("Chargement des conversations…", "Loading conversations…")}
</div>
) : conversationListError ? (
<div className="flex flex-col gap-2 px-3 py-3 text-xs text-danger" role="alert">
<span>{tr("Impossible de charger les conversations.", "Unable to load conversations.")}</span>
<button
type="button"
className="self-start font-semibold text-content"
onClick={() => void refreshConversationList()}
>
{t("common.retry")}
</button>
</div>
) : conversationList.length === 0 ? (
<p className="px-3 py-3 text-xs text-muted">
{tr("Aucune conversation passée.", "No past conversations.")}
</p>
) : (
conversationList.map((item) => {
const isOpen = item.conversationId === activeConversationId;
return (
<button
key={item.conversationId}
type="button"
role="menuitem"
disabled={effectiveBusy || opening || isOpen}
className="flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void reopenConversation(item.conversationId)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content">{item.title}</span>
<span className="block text-[11px] text-muted">
{conversationActivityLabel(item.updatedAtMs)}
</span>
</span>
{isOpen && (
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
{tr("Ouverte", "Open")}
</span>
)}
</button>
);
})
)}
</div>
</div>
)}
</div>
</div>
{hasPreviousFinal && (
<button
type="button"
aria-label={tr("Aller au Final précédent", "Go to previous Final")}
className="absolute left-1/2 top-12 z-20 flex h-11 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-raised/95 text-lg text-content shadow-md hover:border-border-strong"
onClick={scrollToPreviousFinal}
>
↑
</button>
)}
{!supported && (
<p role="alert" className="relative z-10 m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
{t("custom.unsupported")}
</p>
)}
{error && (
<div
role="alert"
className="relative z-10 m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
<div className="font-medium">{t("custom.startFailure")}</div>
{(!mobile || !/invalid node id/i.test(error.message)) && (
<div className="break-words">
{tr("Cause", "Cause")}: {error.message}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
{t("common.retry")}
</Button>
{onOpenNativeTui && (
<Button
size="sm"
variant="secondary"
onClick={onOpenNativeTui}
>
{t("custom.openTui")}
</Button>
)}
</div>
</div>
) : (
error.message
)}
</div>
)}
{profileDialog && (
<div
role="dialog"
aria-modal="true"
aria-labelledby={`profile-command-title-${domId}`}
className={cn(
"relative z-30 border border-border bg-surface p-3 shadow-lg",
mobile
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "m-3 rounded-md",
)}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2
id={`profile-command-title-${domId}`}
className="text-sm font-semibold text-content"
>
{t("custom.profile.change")}
</h2>
<p className="mt-1 text-xs text-muted">
{t("custom.profile.warning")}
</p>
</div>
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
{t("common.close")}
</Button>
</div>
<div className="mt-3 flex min-w-0 flex-col gap-2">
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
{t("custom.profile")}
<select
aria-label={tr("Profil cible", "Target profile")}
value={profileDialog.selectedProfileId}
disabled={profileDialog.busy}
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
onChange={(event) =>
setProfileDialog((prev) =>
prev
? {
...prev,
selectedProfileId: event.target.value,
confirmedReset: false,
}
: prev,
)
}
>
{profileDialog.profiles.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
</option>
))}
</select>
</label>
<label className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={profileDialog.confirmedReset}
disabled={profileDialog.busy}
onChange={(event) =>
setProfileDialog((prev) =>
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
)
}
/>
<span>{tr("Je confirme que la session courante sera réinitialisée.", "I confirm that the current session will be reset.")}</span>
</label>
{profileDialog.error && (
<p role="alert" className="text-xs text-danger">
{profileDialog.error}
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={profileDialog.busy}
onClick={() => setProfileDialog(null)}
>
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
disabled={!profileDialog.confirmedReset || profileDialog.busy}
loading={profileDialog.busy}
onClick={() => void confirmProfileCommandChange()}
>
{tr("Réinitialiser et changer le profil", "Reset and change profile")}
</Button>
</div>
</div>
</div>
)}
<div className="project-cell-illustration-area relative z-10 flex min-h-0 flex-1 basis-0 overflow-hidden">
<div
data-testid="project-icon-watermark"
aria-hidden="true"
className="project-cell-illustration pointer-events-none absolute z-0 flex items-center justify-center overflow-hidden"
style={{ pointerEvents: "none" }}
>
<ProjectIcon
icon={projectIcon}
fallbackLabel={projectFallbackLabel}
surface="watermark"
size="100%"
decorative
className="project-cell-illustration__glyph"
/>
</div>
<div
ref={threadScroll.scrollRef}
data-testid="custom-agent-chat-scroll"
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
onScroll={threadScroll.onScroll}
>
{liveSessionAttached && delegationActive && (
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive
fallbackText={delegationFallbackText}
/>
</div>
)}
{conversationNotice && (
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
{conversationNotice}
</p>
)}
{resumedConversation && (
<div className="shrink-0 rounded-md border border-border bg-raised/75 px-3 py-2 text-xs text-muted">
<strong className="block text-content">
{tr("Conversation reprise", "Conversation resumed")}
</strong>
<span>
{tr(
"Historique sauvegardé de cet agent. Vous pouvez poursuivre ci-dessous.",
"Saved history for this agent. You can continue below.",
)}
</span>
</div>
)}
{resumedConversation && historyHasMore && (
<button
type="button"
className="self-center rounded-md px-3 py-1 text-xs font-semibold text-primary hover:bg-raised disabled:opacity-50"
disabled={historyLoading}
onClick={() => void loadOlderHistory()}
>
{historyLoading
? tr("Chargement…", "Loading…")
: tr("Charger les messages précédents", "Load earlier messages")}
</button>
)}
{historyError && (
<p role="alert" className="text-center text-xs text-danger">{historyError}</p>
)}
{opening && displayTurns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{savedConversationLoadState === "loading"
? tr("Chargement de la conversation…", "Loading conversation…")
: externalSessionId
? t("custom.resume")
: t("custom.opening")}
</span>
</div>
) : savedConversationLoadFailure || 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">
{savedConversationLoadFailure === "notFound" ||
unavailableLinkedTranscript === "conversationNotFound"
? tr(
"Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.",
"This saved conversation could not be found. You can retry or open a new conversation.",
)
: tr(
"Impossible de charger cette conversation sauvegardée. Vos messages n'ont pas été supprimés. Réessayez ou ouvrez explicitement une nouvelle conversation.",
"Unable to load this saved conversation. Your messages have not been deleted. Retry or explicitly open a new conversation.",
)}
</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={() => void startNewConversation()}>
{tr("Nouvelle conversation", "New conversation")}
</Button>
</div>
</div>
) : savedConversationLoadState === "loaded" && displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
{tr(
"Cette conversation sauvegardée ne contient aucun message affichable.",
"This saved conversation contains no displayable messages.",
)}
</div>
) : displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
<strong className="block text-sm text-content">
{tr("Nouvelle conversation", "New conversation")}
</strong>
<span>
{t("custom.empty")}
</span>{" "}
<span>
{tr(
"Vous pouvez aussi ouvrir une conversation passée depuis le menu Conversations.",
"You can also open a past conversation from the Conversations menu.",
)}
</span>
</div>
) : (
displayTurns.map((turn, index) => (
<ChatBubble
key={index}
turn={turn}
requesterName={agentName}
sessionLabel={sessionLabel}
expandedProgressKeys={expandedProgressKeys}
onToggleProgressExpanded={(key) =>
setExpandedProgressKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
})
}
/>
))
)}
</div>
</div>
<div
data-testid="custom-agent-chat-composer"
className={cn(
"relative z-10 flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
{threadScroll.notice && (
<button
type="button"
aria-label={tr("Aller au dernier message", "Go to latest message")}
className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 rounded-full border border-border bg-raised/95 px-3 py-1 text-xs text-content shadow-md hover:border-border-strong"
onClick={scrollToLatest}
>
{threadScroll.notice === "new"
? tr("Nouveaux messages ↓", "New messages ↓")
: tr("Revenir en bas ↓", "Back to bottom ↓")}
</button>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{queueAnnouncement}
</div>
{(queuedMessages.length > 0 || deletedQueueMessage) && (
<div className="relative flex min-w-0 items-center gap-2">
{queuedMessages.length > 0 && (
<button
type="button"
className="flex min-h-11 items-center rounded-md border border-border bg-surface px-3 text-xs font-semibold text-content hover:border-border-strong"
aria-expanded={queueOpen}
aria-controls={`message-queue-${domId}`}
onClick={() => setQueueOpen((open) => !open)}
>
{tr("En attente", "Queued")} · {queuedMessages.length}
</button>
)}
{deletedQueueMessage && (
<div role="status" className="flex min-h-11 min-w-0 items-center gap-2 text-xs text-muted">
<span>{tr("Message supprimé", "Message deleted")}</span>
<button
type="button"
className="min-h-11 px-2 font-semibold text-primary"
onClick={undoDeleteQueuedMessage}
>
{tr("Annuler", "Undo")}
</button>
</div>
)}
{queueOpen && queuedMessages.length > 0 && (
<div
id={`message-queue-${domId}`}
role="region"
aria-label={tr("File d’attente des messages", "Message queue")}
className="absolute bottom-full left-0 z-30 mb-2 flex max-h-[min(22rem,60vh)] w-[min(34rem,calc(100vw-2rem))] max-w-full flex-col overflow-hidden rounded-md border border-border bg-raised shadow-xl"
>
<div className="flex min-h-11 items-center justify-between gap-2 border-b border-border px-3">
<strong className="text-xs">
{tr("Messages en attente", "Queued messages")} · {queuedMessages.length}
</strong>
{clearQueueConfirmation ? (
<div className="flex items-center gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs text-muted"
onClick={() => setClearQueueConfirmation(false)}
>
{tr("Annuler", "Cancel")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-danger"
onClick={clearQueuedMessages}
>
{tr("Confirmer", "Confirm")}
</button>
</div>
) : (
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => setClearQueueConfirmation(true)}
>
{tr("Tout supprimer", "Clear all")}
</button>
)}
</div>
<ol className="min-h-0 overflow-y-auto p-1">
{queuedMessages.map((message, index) => (
<li key={message.id} className="flex min-w-0 gap-2 rounded px-2 py-2 hover:bg-surface">
<span className="w-14 shrink-0 pt-1 text-[11px] font-semibold text-primary">
{index === 0
? tr("Prochain", "Next")
: tr(`${index + 1}e`, `#${index + 1}`)}
</span>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-content">
{message.text || tr("Pièce jointe", "Attachment")}
</p>
<div className="mt-1 flex flex-wrap gap-1">
<button
type="button"
className="min-h-11 px-2 text-xs font-semibold text-primary"
onClick={() => editQueuedMessage(message)}
>
{tr("Modifier", "Edit")}
</button>
<button
type="button"
className="min-h-11 px-2 text-xs text-danger"
onClick={() => deleteQueuedMessage(message)}
>
{tr("Supprimer", "Delete")}
</button>
</div>
</div>
</li>
))}
</ol>
</div>
)}
</div>
)}
{attachments.length > 0 && (
<div className="flex min-w-0 flex-wrap gap-2">
{attachments.map((item) => (
<div
key={item.id}
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
>
{item.previewUrl && (
<img
src={item.previewUrl}
alt=""
data-testid={`attachment-preview-${item.label}`}
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
<span className="min-w-0 truncate">{t("custom.attachment")}: {item.label}</span>
<button
type="button"
aria-label={tr(`Retirer ${item.label}`, `Remove ${item.label}`)}
className="shrink-0 text-content"
onClick={() => removeAttachment(item)}
>
{t("custom.removeAttachment")}
</button>
</div>
))}
</div>
)}
{slashMenuOpen && slashCommands.length > 0 && (
<div
id={`slash-command-menu-${domId}`}
role="listbox"
aria-label={t("custom.slashSuggestions")}
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
>
{slashCommands.map((command, index) => {
const available = isSlashCommandAvailable(command);
const selected = index === slashActiveIndex;
return (
<button
key={command.name}
type="button"
id={`slash-command-${domId}-${index}`}
role="option"
aria-selected={selected}
aria-disabled={!available}
className={cn(
"flex w-full min-w-0 items-start gap-3 px-2.5 py-1.5 text-left text-xs",
selected ? "bg-primary/15 text-content" : "text-content",
!available && "cursor-not-allowed opacity-55",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSlashCommand(command)}
>
<span className="shrink-0 font-semibold">{command.name}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{command.shortDescription}
</span>
{command.requiresConfirmation && (
<span className="shrink-0 text-muted">{tr("confirmation", "confirm")}</span>
)}
</button>
);
})}
</div>
)}
{composerHistoryWarning && (
<p role="status" className="mb-1 text-[11px] text-warning">
{composerHistoryWarning}
</p>
)}
<div className="custom-agent-chat-composer flex min-w-0 items-end gap-2">
<div className="custom-agent-chat-composer-editor 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 || Boolean(savedConversationLoadFailure)}
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>
<div
className="custom-agent-chat-composer-actions flex shrink-0 items-end gap-2"
data-busy={effectiveBusy ? "true" : "false"}
>
<Button
size="md"
variant="secondary"
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
aria-label={tr("Joindre un fichier", "Attach a file")}
disabled={!supported || opening || attachmentPicking || Boolean(savedConversationLoadFailure)}
loading={attachmentPicking}
onClick={() => void pickAttachment()}
>
{attachmentPicking ? tr("Ajout…", "Adding…") : t("custom.attach")}
</Button>
{effectiveBusy && (
<Button
size="md"
variant="danger"
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
disabled={!supported || !currentSession || !agent.cancelAgentChat || stopping}
loading={stopping}
title={t("custom.stop")}
aria-label={t("custom.stop")}
onClick={() => void stopCurrentRequest()}
>
{stopping ? t("custom.stopping") : t("custom.stop")}
</Button>
)}
<Button
size="md"
variant="primary"
className="custom-agent-chat-composer-action min-h-11 whitespace-nowrap"
disabled={!canSubmit}
onClick={() => void send()}
>
{effectiveBusy
? tr("Mettre en attente", "Queue message")
: t("custom.send")}
</Button>
</div>
</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 === "separator") {
return (
<div className="flex items-center gap-2 py-1 text-[11px] text-muted" role="separator">
<span className="h-px flex-1 bg-border" />
<span>{turn.text}</span>
<span className="h-px flex-1 bg-border" />
</div>
);
}
if (turn.role === "restart") {
return (
<div
data-testid="agent-restart-separator"
className={cn(
"flex min-w-0 flex-col items-center gap-1 py-1 text-xs",
turn.failed ? "text-danger" : "text-muted",
)}
>
<div className="flex items-baseline gap-2">
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</div>
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
</div>
);
}
if (turn.role === "tool") {
return (
<div className="flex min-w-0 justify-center">
<span className="flex max-w-[80%] items-baseline gap-2 truncate rounded-md border border-border bg-raised/80 px-2 py-1 text-xs text-muted">
<span className="truncate">{turn.label === "Activité" ? tr("Activité", "Activity") : turn.label}</span>
<EntryTimestamp atMs={turn.receivedAtMs} />
</span>
</div>
);
}
if (turn.role === "progress") {
const key = turnProgressKey(turn);
return (
<ProgressBubble
progress={turn.progress}
requesterName={requesterName}
progressKey={key}
expanded={expandedProgressKeys.has(key)}
onToggleExpanded={() => onToggleProgressExpanded(key)}
/>
);
}
if (turn.role === "final") {
return (
<div data-chat-final="true" className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<ChatMessageMeta
label={tr("Terminé", "Completed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-success"
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
);
}
if (turn.role === "error" || turn.role === "unknown") {
return (
<div className="flex min-w-0 justify-center">
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "error" && (
<ChatMessageMeta
label={tr("Échec", "Failed")}
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-danger"
/>
)}
{turn.role === "error" && sessionLabel && (
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
)}
{turn.role === "unknown" ? tr("Fragment inconnu reçu : ", "Unknown chunk received: ") : ""}
<span className="whitespace-pre-wrap break-words">{turn.text === "Erreur agent" ? tr("Erreur agent", "Agent error") : turn.text}</span>
</div>
</div>
);
}
const user = turn.role === "user";
return (
<div className="flex min-w-0 justify-start">
<div
className={cn(
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised/85" : "border-primary/25 bg-primary/10",
)}
>
<ChatMessageMeta
label={user ? tr("Vous", "You") : requesterName}
receivedAtMs={turn.receivedAtMs}
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => (
<p key={attachment} className="truncate">
{tr("Fichier", "File")}: {attachment}
</p>
))}
</div>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
{tr("Réponse en cours", "Reply in progress")}
</span>
)}
</div>
</div>
);
}
function ChatMessageMeta({
label,
receivedAtMs,
labelClassName,
}: {
label: string;
receivedAtMs: number | null | undefined;
labelClassName?: string;
}) {
return (
<div className="mb-1 flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className={cn("min-w-0 truncate font-medium text-muted", labelClassName)}>
{label}
</span>
<EntryTimestamp atMs={receivedAtMs} />
</div>
);
}
type Tr = (french: string, english: string) => string;
function progressTitle(progress: ReplyProgress, tr: Tr): string {
if (isDelegationProgress(progress)) return tr("Délégation", "Delegation");
if (progress.kind === "mcp") return "MCP";
if (progress.kind === "tool") return tr("Outil", "Tool");
if (progress.kind === "message") return tr("Progression", "Progress");
if (progress.kind === "turn") return tr("Tour", "Turn");
return tr("Activité", "Activity");
}
function progressSourceLabel(progress: ReplyProgress, tr: Tr): string {
if (progress.source === "ideaLocal") return "IdeA";
return progress.provider ?? tr("Fournisseur", "Provider");
}
function progressStageLabel(stage: ReplyProgressStage, tr: Tr): string {
switch (stage) {
case "started":
return tr("En cours", "In progress");
case "delta":
return tr("En cours", "In progress");
case "completed":
return tr("Terminé", "Completed");
case "info":
return "info";
}
}
function progressTone(progress: ReplyProgress): string {
if (isDelegationProgress(progress)) {
return "border-primary/45 bg-primary/10 text-content";
}
if (progress.kind === "mcp") {
return "border-warning/45 bg-warning/10 text-content";
}
if (progress.kind === "tool") {
return "border-border bg-raised/70 text-content";
}
return "border-border/80 bg-surface/70 text-muted";
}
function isCenteredToolProgress(progress: ReplyProgress): boolean {
return (
progress.kind === "tool" ||
progress.kind === "mcp" ||
isDelegationProgress(progress)
);
}
function isTimelineProgress(progress: ReplyProgress): boolean {
return progress.kind === "turn";
}
function compactToolName(progress: ReplyProgress): string {
return progress.toolName ?? progress.label;
}
function previewText(text: string, maxLineLength = 160): string {
return text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 4)
.map((line) =>
line.length > maxLineLength
? `${line.slice(0, Math.max(0, maxLineLength - 3))}...`
: line,
)
.join("\n");
}
function parseKeyValue(text: string, key: string): string | null {
const match = new RegExp(
`(?:^|\\s)${key}=([^\\n]+?)(?=\\s\\w+=|$)`,
"i",
).exec(text);
return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null;
}
function delegationSummary(progress: ReplyProgress): {
requester: string | null;
target: string | null;
detail: string | null;
excerpt: string | null;
} {
const text = progress.text ?? "";
const requester = parseKeyValue(text, "requester");
const target = parseKeyValue(text, "target");
const task = parseKeyValue(text, "task");
const fallback = text && !target && !task ? text : null;
return {
requester,
target,
detail: task ?? fallback,
excerpt: task
? previewText(task, 92)
: fallback
? previewText(fallback, 92)
: null,
};
}
const fourLineClamp = {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 4,
} as const;
function useIsTextTruncated(
ref: RefObject<HTMLElement>,
content: string,
expanded: boolean,
) {
const [truncated, setTruncated] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => {
const previousDisplay = element.style.display;
const previousBoxOrient = element.style.webkitBoxOrient;
const previousLineClamp = element.style.webkitLineClamp;
if (expanded) {
element.style.display = "-webkit-box";
element.style.webkitBoxOrient = "vertical";
element.style.webkitLineClamp = "4";
}
setTruncated(element.scrollHeight > element.clientHeight + 1);
if (expanded) {
element.style.display = previousDisplay;
element.style.webkitBoxOrient = previousBoxOrient;
element.style.webkitLineClamp = previousLineClamp;
}
};
measure();
const frame = window.requestAnimationFrame(measure);
window.addEventListener("resize", measure);
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(element);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", measure);
observer?.disconnect();
};
}, [content, expanded, ref]);
return truncated;
}
function ExpandableProgressDetail({
content,
className,
expanded,
onToggle,
}: {
content: string;
className?: string;
expanded: boolean;
onToggle: () => void;
}) {
const { tr } = useI18n();
const contentId = useId();
const detailRef = useRef<HTMLParagraphElement>(null);
const truncated = useIsTextTruncated(detailRef, content, expanded);
const showToggle = truncated;
return (
<div className="mt-1 min-w-0">
<p
id={contentId}
ref={detailRef}
data-testid="chat-progress-detail"
className={cn(
"overflow-hidden whitespace-pre-wrap break-words text-[11px] text-content",
className,
)}
style={expanded ? undefined : fourLineClamp}
>
{content}
</p>
{showToggle && (
<button
type="button"
className={cn(
"mt-1 text-[11px] font-medium text-primary underline-offset-2 hover:underline",
className?.includes("text-center") && "mx-auto block",
)}
aria-expanded={expanded}
aria-controls={contentId}
onClick={onToggle}
>
{expanded ? tr("Afficher moins", "Show less") : tr("Afficher plus", "Show more")}
</button>
)}
</div>
);
}
function TurnMarker({ progress }: { progress: ReplyProgress }) {
const { tr } = useI18n();
const stage = progressStageLabel(progress.stage, tr);
const label = progress.label.trim() || tr("Tour", "Turn");
return (
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
<span className="shrink-0 rounded-full border border-border bg-surface px-2 py-0.5">
{stage}
</span>
<span className="truncate">{label}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
</div>
</div>
);
}
function ToolProgressCard({
progress,
requesterName,
progressKey,
expanded,
onToggleExpanded,
}: {
progress: ReplyProgress;
requesterName: string;
progressKey: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { tr } = useI18n();
const stage = progressStageLabel(progress.stage, tr);
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const detail = delegation
? delegation.detail
: progress.text?.trim() || null;
const title = delegation
? delegation.target
? `${delegation.requester ?? requesterName} ${
progress.stage === "completed" ? "←" : "→"
} ${delegation.target}`
: tr("Appel inter-agent", "Inter-agent call")
: progressTitle(progress, tr);
return (
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
<div
className={cn(
"min-w-0 max-w-[72%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress),
)}
>
<div className="flex min-w-0 items-center justify-center gap-1.5">
<span className="shrink-0 font-semibold">{title}</span>
{!delegation && (
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
)}
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
{delegation?.excerpt && !detail && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
{delegation.excerpt}
</p>
)}
{delegation && (
<p className="mt-0.5 truncate text-center text-[11px] text-muted">
{compactToolName(progress)}
</p>
)}
{(progress.source || progress.nativeEvent || progress.provider) && (
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{progressSourceLabel(progress, tr)}</span>
{progress.nativeEvent && (
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
)}
{detail && (
<ExpandableProgressDetail
key={progressKey}
content={detail}
className="text-center"
expanded={expanded}
onToggle={onToggleExpanded}
/>
)}
</div>
</div>
);
}
function ProgressBubble({
progress,
requesterName,
progressKey,
expanded,
onToggleExpanded,
}: {
progress: ReplyProgress;
requesterName: string;
progressKey: string;
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { tr } = useI18n();
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) {
return (
<ToolProgressCard
progress={progress}
requesterName={requesterName}
progressKey={progressKey}
expanded={expanded}
onToggleExpanded={onToggleExpanded}
/>
);
}
const title = progressTitle(progress, tr);
const stage = progressStageLabel(progress.stage, tr);
const source = progressSourceLabel(progress, tr);
const detail = progress.text?.trim() || null;
return (
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
<div
className={cn(
"min-w-0 max-w-[58%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress),
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0 font-semibold">{title}</span>
<span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span>
<EntryTimestamp atMs={progress.occurredAtMs} />
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{source}</span>
{progress.nativeEvent && (
<span className="truncate">{tr("événement", "event")}: {progress.nativeEvent}</span>
)}
</div>
{detail && (
<ExpandableProgressDetail
key={progressKey}
content={detail}
expanded={expanded}
onToggle={onToggleExpanded}
/>
)}
</div>
</div>
);
}