4318 lines
148 KiB
TypeScript
4318 lines
148 KiB
TypeScript
/**
|
||
* Custom agent CLI for structured/headless profiles (#147).
|
||
*
|
||
* This is an alternative human view for an agent cell, not a replacement for
|
||
* the native TUI. It uses the structured chat commands when available and
|
||
* deliberately does not try to parse PTY bytes.
|
||
*/
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ClipboardEvent,
|
||
type KeyboardEvent,
|
||
type MouseEvent as ReactMouseEvent,
|
||
type RefObject,
|
||
} from "react";
|
||
|
||
import type {
|
||
AgentProfile,
|
||
GatewayError,
|
||
ReplyChunk,
|
||
ReplyProgress,
|
||
ReplyProgressKind,
|
||
ReplyProgressSource,
|
||
ReplyProgressStage,
|
||
SlashCommand,
|
||
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);
|
||
// `freshOnOpen` is reserved for an explicit reset intent. Ordinary React
|
||
// remounts keep the durable conversation selected by the hosting cell.
|
||
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.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,
|
||
...(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);
|
||
}
|
||
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
|
||
timestamp: new Date().toISOString(),
|
||
execution,
|
||
sessionId: externalSessionId,
|
||
error: instrumentationError(e),
|
||
});
|
||
return;
|
||
}
|
||
} else {
|
||
const cacheKey = conversationCacheKey(
|
||
projectId,
|
||
agentId,
|
||
activeConversationIdRef.current,
|
||
);
|
||
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
|
||
if (cached) {
|
||
scrollbackChunkCountRef.current = cached.chunkCount;
|
||
applyChatViewState({
|
||
turns: cached.turns,
|
||
progressIndex: cached.progressIndex ?? new Map(),
|
||
progressScope: cached.progressScope ?? 0,
|
||
});
|
||
}
|
||
const savedConversationId = activeConversationIdRef.current;
|
||
if (!savedConversationId) {
|
||
setSavedConversationLoadState("idle");
|
||
setLiveSessionAttached(false);
|
||
if (freshOnOpen) {
|
||
await recoverStructuredSession({
|
||
applyScrollback: false,
|
||
retryAttachNotFound: true,
|
||
conversationId: null,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A cell can remount while its structured runtime remains live (for
|
||
// example after switching tabs). The layout session can temporarily
|
||
// be absent, but the conversation cache and live registry still carry
|
||
// the runtime identity. Reattach that runtime before considering the
|
||
// durable transcript: a conversation id alone is not a request to
|
||
// replace an active view with saved history (#370).
|
||
const cachedSessionId = cached?.sessionId;
|
||
if (cachedSessionId) {
|
||
try {
|
||
const attachedSessionId = await reattachStructuredSession(cachedSessionId, {
|
||
applyScrollback: true,
|
||
stableIdentity: true,
|
||
preserveViewOnEmptyScrollback: true,
|
||
});
|
||
if (cancelled) return;
|
||
publishSessionId(attachedSessionId);
|
||
setSavedConversationLoadState("idle");
|
||
setResumedConversation(false);
|
||
return;
|
||
} catch (e) {
|
||
if (!isNotFound(e)) throw e;
|
||
}
|
||
}
|
||
|
||
const live = agent.listLiveAgents
|
||
? (await agent.listLiveAgents(projectId)).find(
|
||
(candidate) => candidate.agentId === agentId && candidate.kind === "structured",
|
||
)
|
||
: undefined;
|
||
if (live) {
|
||
const rebound =
|
||
nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
|
||
? await agent.attachLiveAgent(projectId, agentId, nodeId)
|
||
: live;
|
||
const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
|
||
applyScrollback: true,
|
||
preserveViewOnEmptyScrollback: true,
|
||
});
|
||
if (cancelled) return;
|
||
publishSessionId(attachedSessionId);
|
||
setSavedConversationLoadState("idle");
|
||
setResumedConversation(false);
|
||
return;
|
||
}
|
||
|
||
setSavedConversationLoadState("loading");
|
||
setLiveSessionAttached(false);
|
||
try {
|
||
const page = await conversation.readPage(projectId, savedConversationId, {
|
||
direction: "backward",
|
||
limit: 50,
|
||
});
|
||
if (cancelled) return;
|
||
applyChatViewState({
|
||
...emptyChatViewState(),
|
||
turns: durableHistoryTurns(page.turns),
|
||
});
|
||
setResumedConversation(true);
|
||
setConversationNotice(null);
|
||
setHistoryHasMore(page.hasMore);
|
||
setHistoryNextAnchor(page.nextAnchor);
|
||
setHistoryError(null);
|
||
setSavedConversationLoadState("loaded");
|
||
} catch (e) {
|
||
if (cancelled) return;
|
||
setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error");
|
||
}
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.error("[ticket149] openOrAttach:error", {
|
||
timestamp: new Date().toISOString(),
|
||
execution,
|
||
sessionId: externalSessionId,
|
||
error: instrumentationError(e),
|
||
});
|
||
if (!cancelled && activeConversationIdRef.current && isNotFound(e)) {
|
||
setTranscriptUnavailableReason("conversationNotFound");
|
||
setCurrentSession(null);
|
||
publishSessionId(null);
|
||
} else if (!cancelled) {
|
||
setError({ message: describeOpenFailure(e), openFailure: true });
|
||
}
|
||
} finally {
|
||
if (!cancelled) setOpening(false);
|
||
}
|
||
}
|
||
|
||
void openOrAttach();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [
|
||
supported,
|
||
externalSessionId,
|
||
reattachStructuredSession,
|
||
recoverStructuredSession,
|
||
publishSessionId,
|
||
applyChatViewState,
|
||
conversation,
|
||
agent,
|
||
projectId,
|
||
agentId,
|
||
nodeId,
|
||
freshOnOpen,
|
||
openRetryNonce,
|
||
]);
|
||
|
||
const canSubmit = useMemo(
|
||
() =>
|
||
supported &&
|
||
(Boolean(draft.trim()) || attachments.length > 0) &&
|
||
!opening &&
|
||
savedConversationLoadState !== "error" &&
|
||
savedConversationLoadState !== "notFound",
|
||
[supported, draft, attachments.length, opening, savedConversationLoadState],
|
||
);
|
||
const unavailableLinkedTranscript =
|
||
displayTurns.length === 0 &&
|
||
(transcriptUnavailableReason === "missingTranscript" ||
|
||
transcriptUnavailableReason === "conversationNotFound")
|
||
? transcriptUnavailableReason
|
||
: null;
|
||
const savedConversationLoadFailure =
|
||
savedConversationLoadState === "error" || savedConversationLoadState === "notFound"
|
||
? savedConversationLoadState
|
||
: null;
|
||
|
||
function retryUnavailableTranscript() {
|
||
const sid = sessionRef.current;
|
||
setTranscriptUnavailableReason(null);
|
||
setSavedConversationLoadState(
|
||
!sid && activeConversationIdRef.current ? "loading" : "idle",
|
||
);
|
||
setError(null);
|
||
if (sid) {
|
||
// Force the next user-triggered pass through the attach path. Keeping the
|
||
// local session here would trip the #307/idempotency short-circuit.
|
||
setCurrentSession(null);
|
||
setExternalSessionId(sid);
|
||
}
|
||
setOpenRetryNonce((value) => value + 1);
|
||
}
|
||
|
||
const refreshConversationList = useCallback(async () => {
|
||
if (!agent.listCustomAgentConversations) {
|
||
setConversationListError(
|
||
tr("Liste des conversations indisponible.", "Conversation list unavailable."),
|
||
);
|
||
return;
|
||
}
|
||
setConversationListLoading(true);
|
||
setConversationListError(null);
|
||
try {
|
||
setConversationList(
|
||
await agent.listCustomAgentConversations(projectId, agentId, 50),
|
||
);
|
||
} catch (e) {
|
||
setConversationListError(describe(e));
|
||
} finally {
|
||
setConversationListLoading(false);
|
||
}
|
||
}, [agent, agentId, projectId, tr]);
|
||
|
||
useEffect(() => {
|
||
if (conversationMenuOpen) void refreshConversationList();
|
||
}, [conversationMenuOpen, refreshConversationList]);
|
||
|
||
async function reopenConversation(nextConversationId: string) {
|
||
if (effectiveBusy || opening || nextConversationId === activeConversationId) return;
|
||
setConversationMenuOpen(false);
|
||
setOpening(true);
|
||
setError(null);
|
||
try {
|
||
const sid = sessionRef.current;
|
||
if (sid) await agent.closeAgentChat?.(sid);
|
||
setCurrentSession(null);
|
||
setExternalSessionId(null);
|
||
setLiveSessionAttached(false);
|
||
publishSessionId(null);
|
||
publishConversationId(nextConversationId);
|
||
applyChatViewState(emptyChatViewState());
|
||
setSavedConversationLoadState("loading");
|
||
setResumedConversation(false);
|
||
setConversationNotice(null);
|
||
setHistoryHasMore(false);
|
||
setHistoryNextAnchor(undefined);
|
||
setOpenRetryNonce((value) => value + 1);
|
||
} catch (e) {
|
||
setError({ message: describe(e), openFailure: false });
|
||
} finally {
|
||
setOpening(false);
|
||
}
|
||
}
|
||
|
||
async function startNewConversation() {
|
||
if (effectiveBusy || opening) return;
|
||
setConversationMenuOpen(false);
|
||
await executeSlashCommand({ name: "/clean", arguments: [] });
|
||
}
|
||
|
||
async function loadOlderHistory() {
|
||
if (!activeConversationId || !historyNextAnchor || historyLoading) return;
|
||
setHistoryLoading(true);
|
||
setHistoryError(null);
|
||
try {
|
||
const page = await conversation.readPage(projectId, activeConversationId, {
|
||
anchor: historyNextAnchor,
|
||
direction: "backward",
|
||
limit: 50,
|
||
});
|
||
const older = durableHistoryTurns(page.turns);
|
||
updateChatViewState((previous) => ({
|
||
...previous,
|
||
turns: [...older, ...previous.turns],
|
||
}));
|
||
setHistoryHasMore(page.hasMore);
|
||
setHistoryNextAnchor(page.nextAnchor);
|
||
} catch (e) {
|
||
setHistoryError(describe(e));
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openProfileDialog() {
|
||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||
Boolean(candidate.structuredAdapter),
|
||
);
|
||
if (availableProfiles.length === 0) {
|
||
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
|
||
}
|
||
setDraft("");
|
||
setProfileDialog({
|
||
profiles: availableProfiles,
|
||
selectedProfileId:
|
||
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
|
||
availableProfiles[0].id,
|
||
confirmedReset: false,
|
||
busy: false,
|
||
error: null,
|
||
});
|
||
}
|
||
|
||
async function confirmProfileCommandChange() {
|
||
if (!profileDialog) return;
|
||
if (!agent.changeAgentProfile) {
|
||
setProfileDialog((prev) =>
|
||
prev ? { ...prev, error: tr("Changement de profil indisponible.", "Profile change unavailable.") } : prev,
|
||
);
|
||
return;
|
||
}
|
||
const selected = profileDialog.profiles.find(
|
||
(candidate) => candidate.id === profileDialog.selectedProfileId,
|
||
);
|
||
if (!selected) return;
|
||
|
||
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
|
||
try {
|
||
const { relaunchedSession } = await agent.changeAgentProfile(
|
||
projectId,
|
||
agentId,
|
||
selected.id,
|
||
24,
|
||
80,
|
||
);
|
||
applyChatViewState(emptyChatViewState());
|
||
setCurrentSession(null);
|
||
publishSessionId(null);
|
||
onConversationIdRef.current(null);
|
||
|
||
if (relaunchedSession?.sessionId) {
|
||
try {
|
||
setCurrentSession(relaunchedSession.sessionId);
|
||
publishSessionId(relaunchedSession.sessionId);
|
||
await reattachStructuredSession(relaunchedSession.sessionId, {
|
||
applyScrollback: false,
|
||
});
|
||
} catch {
|
||
await recoverStructuredSession({
|
||
applyScrollback: false,
|
||
retryAttachNotFound: true,
|
||
conversationId: null,
|
||
});
|
||
}
|
||
} else {
|
||
await recoverStructuredSession({
|
||
applyScrollback: false,
|
||
retryAttachNotFound: true,
|
||
conversationId: null,
|
||
});
|
||
}
|
||
|
||
applyChatViewState({
|
||
turns: [
|
||
{
|
||
role: "tool",
|
||
label: tr(`Profil changé vers ${selected.name}.`, `Profile changed to ${selected.name}.`),
|
||
receivedAtMs: null,
|
||
},
|
||
],
|
||
progressIndex: new Map(),
|
||
progressScope: 0,
|
||
});
|
||
setProfileDialog(null);
|
||
} catch (e) {
|
||
setProfileDialog((prev) =>
|
||
prev ? { ...prev, busy: false, error: describe(e) } : prev,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function executeSlashCommand(invocation: SlashCommandInvocation) {
|
||
if (!agent.executeSlashCommand) {
|
||
setError({
|
||
message: tr(`Commande ${invocation.name} indisponible dans ce runtime.`, `Command ${invocation.name} is unavailable in this runtime.`),
|
||
openFailure: false,
|
||
});
|
||
return;
|
||
}
|
||
setDraft("");
|
||
void clearPersistedDraft();
|
||
setError(null);
|
||
try {
|
||
const sid =
|
||
currentSession ??
|
||
(await recoverStructuredSession({
|
||
applyScrollback: false,
|
||
retryAttachNotFound: true,
|
||
...(invocation.name === "/clean" ? { conversationId: null } : {}),
|
||
}));
|
||
const result = await agent.executeSlashCommand(invocation.name, {
|
||
sessionId: sid,
|
||
...(invocation.arguments.length > 0
|
||
? { arguments: invocation.arguments }
|
||
: {}),
|
||
});
|
||
if (result.effect.kind === "help") {
|
||
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||
updateChatViewState((prev) => ({
|
||
...prev,
|
||
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: null }],
|
||
}));
|
||
return;
|
||
}
|
||
if (result.effect.kind === "cleanConversation") {
|
||
applyChatViewState(emptyChatViewState());
|
||
setCurrentSession(result.effect.sessionId);
|
||
setExternalSessionId(result.effect.sessionId);
|
||
publishSessionId(result.effect.sessionId);
|
||
publishConversationId(result.effect.conversationId ?? null);
|
||
setSavedConversationLoadState("idle");
|
||
setResumedConversation(false);
|
||
setHistoryHasMore(false);
|
||
setHistoryNextAnchor(undefined);
|
||
setHistoryError(null);
|
||
setConversationNotice(
|
||
tr("Nouvelle conversation prête.", "New conversation ready."),
|
||
);
|
||
return;
|
||
}
|
||
if (result.effect.kind === "profileSwitch") {
|
||
await openProfileDialog();
|
||
return;
|
||
}
|
||
if (result.effect.kind === "pluginCallback") {
|
||
await pluginRuntime.registry.runCommandStrict(
|
||
result.effect.pluginId,
|
||
result.effect.commandId,
|
||
...result.effect.arguments,
|
||
);
|
||
updateChatViewState((prev) => ({
|
||
...prev,
|
||
turns: [
|
||
...prev.turns,
|
||
{
|
||
role: "tool",
|
||
label: tr(`Commande plugin ${result.command.name} exécutée.`, `Plugin command ${result.command.name} executed.`),
|
||
receivedAtMs: null,
|
||
},
|
||
],
|
||
}));
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
const message = `${tr(`Commande ${invocation.name} échouée :`, `Command ${invocation.name} failed:`)} ${describe(e)}`;
|
||
setError({ message, openFailure: false });
|
||
updateChatViewState((prev) => ({
|
||
...prev,
|
||
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: null }],
|
||
}));
|
||
}
|
||
}
|
||
|
||
async function pickAttachment() {
|
||
setAttachmentPicking(true);
|
||
try {
|
||
const path = await system.pickFile();
|
||
if (path) {
|
||
setAttachments((prev) => [
|
||
...prev,
|
||
{
|
||
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||
label: pathBasename(path),
|
||
input: { path, sourceKind: "localFile" },
|
||
},
|
||
]);
|
||
}
|
||
} finally {
|
||
setAttachmentPicking(false);
|
||
}
|
||
}
|
||
|
||
function removeAttachment(item: AttachmentDraft) {
|
||
setAttachments((prev) =>
|
||
prev.filter((candidate) => candidate.id !== item.id),
|
||
);
|
||
if (item.token) {
|
||
editDraft(removeAttachmentToken(draftRef.current, item.token));
|
||
}
|
||
}
|
||
|
||
async function pasteClipboardFiles(
|
||
event: ClipboardEvent<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>
|
||
);
|
||
}
|