Corrige la perte de session à la navigation: le démontage du CustomAgentChatView appelait close_agent_session (destructeur) au lieu de simplement détacher le transport. Désormais: - nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le canal sans tuer la session ni le scrollback retenu; distinct de close_agent_session qui reste le close destructeur explicite; - le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un cache de session (turns + chunkCount) à la réattache; - message « Reprise effectuée: N événements rattrapés… » quand la réattache rejoue plus de chunks que le cache local n'en avait conservé. Enrichit le rendu tools/MCP/progress: - ProgressSettler (chat bridge) émet un progress completed synthétique quand un spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini les spinners fantômes; - les adapters claude/codex/opencode/openai_compat extraient un aperçu du payload (arguments/input/command/message/params) attaché au progress; - bounded_reply_progress_preview (domain) garde les 4 premières lignes non vides, normalisation CR/LF; - previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent affichés sous le titre; suppression du flag settled et du Spinner inline devenus redondants (l'état est porté par le stage + le settler). Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage reply_progress, item_preview/tool_preview par adapter, réattache avec cache et notice de rattrapage.
1928 lines
60 KiB
TypeScript
1928 lines
60 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,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ClipboardEvent,
|
|
} from "react";
|
|
|
|
import type {
|
|
AgentProfile,
|
|
GatewayError,
|
|
ReplyChunk,
|
|
ReplyProgress,
|
|
ReplyProgressKind,
|
|
ReplyProgressSource,
|
|
ReplyProgressStage,
|
|
SlashCommand,
|
|
} from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
|
import { Button, Spinner, cn } from "@/shared";
|
|
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
|
|
|
export interface CustomAgentChatViewProps {
|
|
projectId: string;
|
|
agentId: string;
|
|
agentName: string;
|
|
profile: AgentProfile;
|
|
cwd: string;
|
|
layoutId?: string;
|
|
nodeId: string;
|
|
sessionId: string | null;
|
|
conversationId: string | null;
|
|
onSessionId: (sessionId: string | null) => void;
|
|
onConversationId: (conversationId: string | null) => void;
|
|
}
|
|
|
|
type ChatTurn =
|
|
| { role: "user"; text: string; attachments?: string[] }
|
|
| { role: "agent"; text: string; pending?: boolean }
|
|
| { role: "tool"; label: string }
|
|
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
|
| { role: "final"; text: string }
|
|
| { role: "error"; text: string }
|
|
| { role: "unknown"; text: string };
|
|
|
|
interface CachedChatView {
|
|
turns: ChatTurn[];
|
|
chunkCount: number;
|
|
}
|
|
|
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
|
|
|
export function __clearCustomAgentChatViewCacheForTests() {
|
|
chatSessionViewCache.clear();
|
|
}
|
|
|
|
interface AttachmentDraft {
|
|
id: string;
|
|
label: string;
|
|
input: ChatAttachmentInput;
|
|
previewUrl?: string;
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
interface ProfileCommandDialogState {
|
|
profiles: AgentProfile[];
|
|
selectedProfileId: string;
|
|
confirmedReset: boolean;
|
|
busy: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
interface SlashCommandInvocation {
|
|
name: string;
|
|
arguments: unknown[];
|
|
}
|
|
|
|
function describe(e: unknown): string {
|
|
if (e && typeof e === "object" && "message" in e) {
|
|
return String((e as GatewayError).message);
|
|
}
|
|
return String(e);
|
|
}
|
|
|
|
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): 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,
|
|
};
|
|
return next;
|
|
}
|
|
next.push({ role: "agent", text, pending: true });
|
|
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[] = [],
|
|
): ChatTurn[] {
|
|
const last = turns[turns.length - 1];
|
|
if (
|
|
last?.role === "user" &&
|
|
last.text === text &&
|
|
JSON.stringify(last.attachments ?? []) === JSON.stringify(attachments)
|
|
) {
|
|
return turns;
|
|
}
|
|
return [
|
|
...turns,
|
|
{
|
|
role: "user",
|
|
text,
|
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
},
|
|
];
|
|
}
|
|
|
|
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 {
|
|
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 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 appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] {
|
|
const entity = progressEntityKey(progress);
|
|
if (entity && progress.stage === "completed") {
|
|
let runningIndex = -1;
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (
|
|
turn.role === "progress" &&
|
|
progressEntityKey(turn.progress) === entity &&
|
|
(turn.progress.stage === "started" || turn.progress.stage === "delta")
|
|
) {
|
|
runningIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
if (runningIndex >= 0) {
|
|
const next = [...turns];
|
|
const current = next[runningIndex];
|
|
if (current.role === "progress") {
|
|
next[runningIndex] = {
|
|
role: "progress",
|
|
progress: mergeCompletedProgress(current.progress, progress),
|
|
};
|
|
}
|
|
return next;
|
|
}
|
|
}
|
|
const key = progressDisplayKey(progress);
|
|
if (
|
|
turns.some(
|
|
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
|
)
|
|
) {
|
|
return turns;
|
|
}
|
|
return [...turns, { role: "progress", progress }];
|
|
}
|
|
|
|
function reattachNotice(missedChunks: number): ChatTurn | null {
|
|
if (missedChunks <= 0) return null;
|
|
return {
|
|
role: "tool",
|
|
label:
|
|
missedChunks === 1
|
|
? "Reprise effectuée: 1 événement rattrapé pendant l'absence."
|
|
: `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`,
|
|
};
|
|
}
|
|
|
|
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 completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
|
const completedEntities = new Set(
|
|
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)),
|
|
);
|
|
return turns.map((turn) => {
|
|
if (
|
|
turn.role !== "progress" ||
|
|
turn.progress.kind === "turn" ||
|
|
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
|
|
) {
|
|
return turn;
|
|
}
|
|
const entity = progressEntityKey(turn.progress);
|
|
if (entity && completedEntities.has(entity)) {
|
|
return {
|
|
...turn,
|
|
settled: true,
|
|
progress: {
|
|
...turn.progress,
|
|
stage: "completed",
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...turn,
|
|
progress: {
|
|
...turn.progress,
|
|
stage: "completed",
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
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(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
|
if (!isReplyRecord(raw)) {
|
|
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
|
}
|
|
const last = turns[turns.length - 1];
|
|
if (
|
|
(last?.role === "final" || last?.role === "error") &&
|
|
raw.kind !== "userPrompt" &&
|
|
raw.kind !== "UserPrompt"
|
|
) {
|
|
return turns;
|
|
}
|
|
switch (raw.kind) {
|
|
case "textDelta":
|
|
return appendAgentDelta(turns, String(raw.text ?? ""));
|
|
case "toolActivity":
|
|
return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }];
|
|
case "progress": {
|
|
const progress = normalizeProgress(raw.progress);
|
|
if (!progress) return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
|
return appendProgress(turns, progress);
|
|
}
|
|
case "final": {
|
|
const content = String(raw.content ?? "");
|
|
const next = completeRunningProgress(turns);
|
|
const last = next[next.length - 1];
|
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
|
next.push({ role: "final", text: content });
|
|
return next;
|
|
}
|
|
case "error": {
|
|
const next = completeRunningProgress(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") });
|
|
return next;
|
|
}
|
|
case "userPrompt":
|
|
case "UserPrompt":
|
|
return appendUserPrompt(
|
|
turns,
|
|
String(raw.text ?? raw.prompt ?? ""),
|
|
attachmentLabels(raw.attachments),
|
|
);
|
|
default:
|
|
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
|
}
|
|
}
|
|
|
|
export function CustomAgentChatView({
|
|
projectId,
|
|
agentId,
|
|
agentName,
|
|
profile,
|
|
cwd,
|
|
layoutId,
|
|
nodeId,
|
|
sessionId,
|
|
conversationId,
|
|
onSessionId,
|
|
onConversationId,
|
|
}: CustomAgentChatViewProps) {
|
|
const { agent, profile: profileGateway, system } = useGateways();
|
|
const pluginRuntime = usePluginRuntime();
|
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
|
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
|
);
|
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
|
const [draft, setDraft] = useState("");
|
|
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
|
const [profileDialog, setProfileDialog] =
|
|
useState<ProfileCommandDialogState | null>(null);
|
|
const [opening, setOpening] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [stopping, setStopping] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const sessionRef = useRef<string | null>(sessionId);
|
|
sessionRef.current = currentSession;
|
|
const scrollbackChunkCountRef = useRef(
|
|
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
|
|
);
|
|
const detachAgentChatRef = useRef(agent.detachAgentChat);
|
|
detachAgentChatRef.current = agent.detachAgentChat;
|
|
const slashQuerySeqRef = useRef(0);
|
|
const draftSaveSeqRef = useRef(0);
|
|
const draftHydratedRef = useRef(false);
|
|
const draftEditedBeforeHydrationRef = useRef(false);
|
|
const lastPersistedDraftRef = useRef("");
|
|
const draftRef = useRef("");
|
|
draftRef.current = draft;
|
|
const mountedRef = useRef(false);
|
|
const openOrAttachCountRef = useRef(0);
|
|
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
|
const onSessionIdRef = useRef(onSessionId);
|
|
onSessionIdRef.current = onSessionId;
|
|
const onConversationIdRef = useRef(onConversationId);
|
|
onConversationIdRef.current = onConversationId;
|
|
|
|
const supported = Boolean(
|
|
profile.structuredAdapter &&
|
|
agent.launchAgentChat &&
|
|
agent.reattachAgentChat &&
|
|
agent.sendAgentChat &&
|
|
agent.cancelAgentChat &&
|
|
agent.closeAgentChat,
|
|
);
|
|
const slashPrefix = slashCommandPrefix(draft);
|
|
const draftScope = useMemo<ChatDraftScope | null>(
|
|
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
|
[layoutId, nodeId, projectId],
|
|
);
|
|
|
|
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;
|
|
setDraft(text);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
const sid = sessionRef.current;
|
|
if (!sid) return;
|
|
void detachAgentChatRef.current?.(sid).catch((e) => {
|
|
if (!isNotFound(e)) {
|
|
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
|
|
}
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
const receive = useCallback((chunk: ReplyChunk) => {
|
|
scrollbackChunkCountRef.current += 1;
|
|
setTurns((prev) => foldChunk(prev, chunk));
|
|
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!currentSession) return;
|
|
chatSessionViewCache.set(currentSession, {
|
|
turns,
|
|
chunkCount: scrollbackChunkCountRef.current,
|
|
});
|
|
}, [currentSession, 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 ||
|
|
busy ||
|
|
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, busy, opening, slashPrefix, supported]);
|
|
|
|
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
|
if (!isSlashCommandAvailable(command)) return;
|
|
editDraft(`${command.name} `);
|
|
setSlashMenuOpen(false);
|
|
setSlashCommands([]);
|
|
setSlashActiveIndex(0);
|
|
requestAnimationFrame(() => composerRef.current?.focus());
|
|
}, [editDraft]);
|
|
|
|
const publishSessionId = useCallback((nextSessionId: string | null) => {
|
|
selfEmittedSessionIdRef.current = nextSessionId;
|
|
onSessionIdRef.current(nextSessionId);
|
|
}, []);
|
|
|
|
const reattachStructuredSession = useCallback(
|
|
async (sid: string, options: { applyScrollback?: boolean } = {}) => {
|
|
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 = await agent.reattachAgentChat(sid, receive);
|
|
} catch (e) {
|
|
console.error("[ticket149] reattachStructuredSession:error", {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: sid,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
console.debug("[ticket149] reattachStructuredSession:success", {
|
|
timestamp: new Date().toISOString(),
|
|
requestedSessionId: sid,
|
|
returnedSessionId: reattached.sessionId,
|
|
scrollbackCount: reattached.scrollback.length,
|
|
});
|
|
if (!mountedRef.current) return reattached.sessionId;
|
|
setCurrentSession(reattached.sessionId);
|
|
if (options.applyScrollback) {
|
|
const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0;
|
|
scrollbackChunkCountRef.current = Math.max(
|
|
scrollbackChunkCountRef.current,
|
|
reattached.scrollback.length,
|
|
);
|
|
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
|
|
const notice = reattachNotice(reattached.scrollback.length - cachedCount);
|
|
setTurns(notice ? [...replayed, notice] : replayed);
|
|
}
|
|
return reattached.sessionId;
|
|
},
|
|
[agent, receive],
|
|
);
|
|
|
|
const recoverStructuredSession = useCallback(
|
|
async (
|
|
options: {
|
|
applyScrollback?: boolean;
|
|
retryAttachNotFound?: boolean;
|
|
conversationId?: string | null;
|
|
} = {},
|
|
) => {
|
|
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
|
|
console.debug("[ticket149] recoverStructuredSession:start", {
|
|
timestamp: new Date().toISOString(),
|
|
currentSession: sessionRef.current,
|
|
conversationId,
|
|
options,
|
|
});
|
|
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
|
|
for (;;) {
|
|
const launchRequest = {
|
|
cwd,
|
|
rows: 24,
|
|
cols: 80,
|
|
conversationId:
|
|
options.conversationId === undefined
|
|
? conversationId ?? undefined
|
|
: options.conversationId ?? undefined,
|
|
nodeId,
|
|
};
|
|
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
request: launchRequest,
|
|
options,
|
|
retryAttachNotFound,
|
|
});
|
|
let launched: Awaited<ReturnType<NonNullable<typeof agent.launchAgentChat>>>;
|
|
try {
|
|
launched = await agent.launchAgentChat(projectId, agentId, launchRequest);
|
|
} catch (e) {
|
|
console.error("[ticket149] recoverStructuredSession:launchAgentChat:error", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
request: launchRequest,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:success", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
launched,
|
|
});
|
|
if (!mountedRef.current) return launched.sessionId;
|
|
scrollbackChunkCountRef.current = 0;
|
|
setCurrentSession(launched.sessionId);
|
|
publishSessionId(launched.sessionId);
|
|
if (launched.assignedConversationId) {
|
|
onConversationIdRef.current(launched.assignedConversationId);
|
|
}
|
|
try {
|
|
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
|
applyScrollback: options.applyScrollback,
|
|
});
|
|
console.debug("[ticket149] recoverStructuredSession:success", {
|
|
timestamp: new Date().toISOString(),
|
|
launchedSessionId: launched.sessionId,
|
|
recoveredSessionId,
|
|
});
|
|
return recoveredSessionId;
|
|
} catch (e) {
|
|
if (isNotFound(e) && retryAttachNotFound) {
|
|
retryAttachNotFound = false;
|
|
if (mountedRef.current) {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
}
|
|
continue;
|
|
}
|
|
console.error("[ticket149] recoverStructuredSession:reattach:error", {
|
|
timestamp: new Date().toISOString(),
|
|
launchedSessionId: launched.sessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
agent,
|
|
projectId,
|
|
agentId,
|
|
cwd,
|
|
nodeId,
|
|
conversationId,
|
|
reattachStructuredSession,
|
|
publishSessionId,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [turns]);
|
|
|
|
useEffect(() => {
|
|
if (selfEmittedSessionIdRef.current === sessionId) {
|
|
console.debug("[ticket149] openOrAttach:self-session-echo:skip", {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId,
|
|
});
|
|
selfEmittedSessionIdRef.current = undefined;
|
|
return;
|
|
}
|
|
setExternalSessionId(sessionId);
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!supported) return;
|
|
let cancelled = false;
|
|
|
|
async function openOrAttach() {
|
|
const execution = openOrAttachCountRef.current + 1;
|
|
openOrAttachCountRef.current = execution;
|
|
console.debug("[ticket149] openOrAttach:start", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
});
|
|
setOpening(true);
|
|
setError(null);
|
|
try {
|
|
if (externalSessionId) {
|
|
const cached = chatSessionViewCache.get(externalSessionId);
|
|
if (cached) {
|
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
|
setTurns(cached.turns);
|
|
}
|
|
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;
|
|
if (!cancelled) {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
}
|
|
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
}
|
|
}
|
|
await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
});
|
|
if (cancelled) return;
|
|
console.debug("[ticket149] openOrAttach:recover:success", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
receivedSessionId: externalSessionId,
|
|
});
|
|
} catch (e) {
|
|
console.error("[ticket149] openOrAttach:error", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
if (!cancelled) setError(describe(e));
|
|
} finally {
|
|
if (!cancelled) setOpening(false);
|
|
}
|
|
}
|
|
|
|
void openOrAttach();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
supported,
|
|
externalSessionId,
|
|
reattachStructuredSession,
|
|
recoverStructuredSession,
|
|
publishSessionId,
|
|
]);
|
|
|
|
const canSend = useMemo(
|
|
() =>
|
|
supported &&
|
|
(Boolean(draft.trim()) || attachments.length > 0) &&
|
|
!busy &&
|
|
!opening,
|
|
[supported, draft, attachments.length, busy, opening],
|
|
);
|
|
|
|
async function openProfileDialog() {
|
|
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
|
Boolean(candidate.structuredAdapter),
|
|
);
|
|
if (availableProfiles.length === 0) {
|
|
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
|
|
}
|
|
setDraft("");
|
|
setProfileDialog({
|
|
profiles: availableProfiles,
|
|
selectedProfileId:
|
|
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
|
|
availableProfiles[0].id,
|
|
confirmedReset: false,
|
|
busy: false,
|
|
error: null,
|
|
});
|
|
}
|
|
|
|
async function confirmProfileCommandChange() {
|
|
if (!profileDialog) return;
|
|
if (!agent.changeAgentProfile) {
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
|
|
);
|
|
return;
|
|
}
|
|
const selected = profileDialog.profiles.find(
|
|
(candidate) => candidate.id === profileDialog.selectedProfileId,
|
|
);
|
|
if (!selected) return;
|
|
|
|
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
|
|
try {
|
|
const { relaunchedSession } = await agent.changeAgentProfile(
|
|
projectId,
|
|
agentId,
|
|
selected.id,
|
|
24,
|
|
80,
|
|
);
|
|
setTurns([]);
|
|
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,
|
|
});
|
|
}
|
|
|
|
setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]);
|
|
setProfileDialog(null);
|
|
} catch (e) {
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, busy: false, error: describe(e) } : prev,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function executeSlashCommand(invocation: SlashCommandInvocation) {
|
|
if (!agent.executeSlashCommand) {
|
|
setError(`Commande ${invocation.name} indisponible dans ce runtime.`);
|
|
return;
|
|
}
|
|
setDraft("");
|
|
void clearPersistedDraft();
|
|
setError(null);
|
|
try {
|
|
const sid =
|
|
currentSession ??
|
|
(await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
}));
|
|
const result = await agent.executeSlashCommand(invocation.name, {
|
|
sessionId: sid,
|
|
...(invocation.arguments.length > 0
|
|
? { arguments: invocation.arguments }
|
|
: {}),
|
|
});
|
|
if (result.effect.kind === "help") {
|
|
const helpText = formatSlashCommandHelp(result.effect.commands);
|
|
setTurns((prev) => [
|
|
...prev,
|
|
{ role: "agent", text: helpText },
|
|
]);
|
|
return;
|
|
}
|
|
if (result.effect.kind === "cleanConversation") {
|
|
setTurns([]);
|
|
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,
|
|
);
|
|
setTurns((prev) => [
|
|
...prev,
|
|
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
|
]);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
|
setError(message);
|
|
setTurns((prev) => [...prev, { role: "error", text: message }]);
|
|
}
|
|
}
|
|
|
|
async function pickAttachment() {
|
|
const path = await system.pickFile();
|
|
if (path) {
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
label: pathBasename(path),
|
|
input: { path, sourceKind: "localFile" },
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
setAttachments((prev) => {
|
|
const known = new Set(
|
|
prev
|
|
.map((attachment) => attachment.dedupeKey)
|
|
.filter((key): key is string => Boolean(key)),
|
|
);
|
|
const unique = nextAttachments.filter((attachment) => {
|
|
if (!attachment.dedupeKey) return true;
|
|
if (known.has(attachment.dedupeKey)) return false;
|
|
known.add(attachment.dedupeKey);
|
|
return true;
|
|
});
|
|
return unique.length > 0 ? [...prev, ...unique] : prev;
|
|
});
|
|
if (filesFromEvent.length > 0 && pastedText) {
|
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
|
setDraft((prev) =>
|
|
prev.slice(0, selectionStart) +
|
|
pastedText +
|
|
prev.slice(selectionEnd),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
}
|
|
}
|
|
|
|
async function send() {
|
|
const text = draft.trim();
|
|
if (!canSend || !agent.sendAgentChat) return;
|
|
const slashInvocation = slashCommandInvocation(text);
|
|
if (slashInvocation) {
|
|
await executeSlashCommand(slashInvocation);
|
|
return;
|
|
}
|
|
const outgoingAttachments = attachments;
|
|
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
|
const displayText = text || "Pièce jointe";
|
|
setDraft("");
|
|
setAttachments([]);
|
|
void clearPersistedDraft();
|
|
setBusy(true);
|
|
setError(null);
|
|
setTurns((prev) => [
|
|
...prev,
|
|
{
|
|
role: "user",
|
|
text: displayText,
|
|
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
|
},
|
|
]);
|
|
const sendTurn = (sid: string) =>
|
|
attachmentInputs.length > 0
|
|
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
|
|
: agent.sendAgentChat!(sid, text, receive);
|
|
try {
|
|
const sid =
|
|
currentSession ??
|
|
(await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
}));
|
|
await sendTurn(sid);
|
|
} catch (e) {
|
|
if (isNotFound(e)) {
|
|
try {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
const recoveredSession = await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
});
|
|
await sendTurn(recoveredSession);
|
|
return;
|
|
} catch (recoveryError) {
|
|
setBusy(false);
|
|
setError(describe(recoveryError));
|
|
setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]);
|
|
return;
|
|
}
|
|
}
|
|
setBusy(false);
|
|
setError(describe(e));
|
|
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
|
|
}
|
|
}
|
|
|
|
async function stopAgent() {
|
|
const sid = sessionRef.current;
|
|
if (!sid || !agent.stopLiveAgent || stopping) return;
|
|
setStopping(true);
|
|
setError(null);
|
|
try {
|
|
const stopped = await agent.stopLiveAgent(projectId, agentId);
|
|
setBusy(false);
|
|
setOpening(false);
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
setTurns((prev) => [
|
|
...completeRunningProgress(prev),
|
|
{
|
|
role: "tool",
|
|
label:
|
|
stopped.kind === "structured"
|
|
? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés."
|
|
: "Agent arrêté.",
|
|
},
|
|
]);
|
|
} catch (e) {
|
|
if (isNotFound(e)) {
|
|
setBusy(false);
|
|
setOpening(false);
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
setTurns((prev) => [
|
|
...completeRunningProgress(prev),
|
|
{ role: "tool", label: "Session déjà arrêtée." },
|
|
]);
|
|
return;
|
|
}
|
|
setError(describe(e));
|
|
setTurns((prev) => [
|
|
...prev,
|
|
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
|
]);
|
|
} finally {
|
|
setStopping(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-testid="custom-agent-chat-view"
|
|
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
|
>
|
|
<div
|
|
role="toolbar"
|
|
aria-label="custom agent chat actions"
|
|
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
|
|
>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<div className="truncate text-sm font-medium">{agentName}</div>
|
|
<div className="truncate text-xs text-muted">
|
|
CLI custom · {profile.name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!supported && (
|
|
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
|
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{profileDialog && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={`profile-command-title-${nodeId}`}
|
|
className="m-3 rounded-md border border-border bg-surface p-3 shadow-lg"
|
|
>
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2
|
|
id={`profile-command-title-${nodeId}`}
|
|
className="text-sm font-semibold text-content"
|
|
>
|
|
Changer de profil
|
|
</h2>
|
|
<p className="mt-1 text-xs text-muted">
|
|
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={profileDialog.busy}
|
|
onClick={() => setProfileDialog(null)}
|
|
>
|
|
Fermer
|
|
</Button>
|
|
</div>
|
|
<div className="mt-3 flex min-w-0 flex-col gap-2">
|
|
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
|
|
Profil
|
|
<select
|
|
aria-label="profil cible"
|
|
value={profileDialog.selectedProfileId}
|
|
disabled={profileDialog.busy}
|
|
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
|
|
onChange={(event) =>
|
|
setProfileDialog((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
selectedProfileId: event.target.value,
|
|
confirmedReset: false,
|
|
}
|
|
: prev,
|
|
)
|
|
}
|
|
>
|
|
{profileDialog.profiles.map((candidate) => (
|
|
<option key={candidate.id} value={candidate.id}>
|
|
{candidate.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-xs text-content">
|
|
<input
|
|
type="checkbox"
|
|
checked={profileDialog.confirmedReset}
|
|
disabled={profileDialog.busy}
|
|
onChange={(event) =>
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
|
|
)
|
|
}
|
|
/>
|
|
<span>Je confirme que la session courante sera reset.</span>
|
|
</label>
|
|
{profileDialog.error && (
|
|
<p role="alert" className="text-xs text-danger">
|
|
{profileDialog.error}
|
|
</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={profileDialog.busy}
|
|
onClick={() => setProfileDialog(null)}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
disabled={!profileDialog.confirmedReset || profileDialog.busy}
|
|
loading={profileDialog.busy}
|
|
onClick={() => void confirmProfileCommandChange()}
|
|
>
|
|
Reset et changer le profil
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="custom-agent-chat-scroll"
|
|
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
|
>
|
|
{opening && turns.length === 0 ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted">
|
|
<Spinner size={14} />
|
|
<span>
|
|
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
|
</span>
|
|
</div>
|
|
) : turns.length === 0 ? (
|
|
<p className="m-auto text-xs text-muted">
|
|
Envoyez un message pour démarrer la conversation structurée.
|
|
</p>
|
|
) : (
|
|
turns.map((turn, index) => (
|
|
<ChatBubble key={index} turn={turn} requesterName={agentName} />
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
data-testid="custom-agent-chat-composer"
|
|
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
|
|
>
|
|
{attachments.length > 0 && (
|
|
<div className="flex min-w-0 flex-wrap gap-2">
|
|
{attachments.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
|
|
>
|
|
{item.previewUrl && (
|
|
<img
|
|
src={item.previewUrl}
|
|
alt=""
|
|
data-testid={`attachment-preview-${item.label}`}
|
|
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`retirer ${item.label}`}
|
|
className="shrink-0 text-content"
|
|
onClick={() =>
|
|
setAttachments((prev) =>
|
|
prev.filter((candidate) => candidate.id !== item.id),
|
|
)
|
|
}
|
|
>
|
|
Retirer
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{slashMenuOpen && slashCommands.length > 0 && (
|
|
<div
|
|
id={`slash-command-menu-${nodeId}`}
|
|
role="listbox"
|
|
aria-label="suggestions commandes slash"
|
|
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
|
|
>
|
|
{slashCommands.map((command, index) => {
|
|
const available = isSlashCommandAvailable(command);
|
|
const selected = index === slashActiveIndex;
|
|
return (
|
|
<button
|
|
key={command.name}
|
|
type="button"
|
|
id={`slash-command-${nodeId}-${index}`}
|
|
role="option"
|
|
aria-selected={selected}
|
|
aria-disabled={!available}
|
|
className={cn(
|
|
"flex w-full min-w-0 items-start gap-3 px-2.5 py-1.5 text-left text-xs",
|
|
selected ? "bg-primary/15 text-content" : "text-content",
|
|
!available && "cursor-not-allowed opacity-55",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => selectSlashCommand(command)}
|
|
>
|
|
<span className="shrink-0 font-semibold">{command.name}</span>
|
|
<span className="min-w-0 flex-1 truncate text-muted">
|
|
{command.shortDescription}
|
|
</span>
|
|
{command.requiresConfirmation && (
|
|
<span className="shrink-0 text-muted">confirm</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="flex min-w-0 items-end gap-2">
|
|
<textarea
|
|
ref={composerRef}
|
|
aria-label={`message CLI custom ${nodeId}`}
|
|
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
|
|
aria-activedescendant={
|
|
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
|
|
}
|
|
className={cn(
|
|
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
rows={2}
|
|
value={draft}
|
|
disabled={!supported || opening || busy}
|
|
placeholder="Message à l'agent…"
|
|
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 (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void send();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="shrink-0 whitespace-nowrap"
|
|
disabled={!supported || opening || busy}
|
|
onClick={() => void pickAttachment()}
|
|
>
|
|
Joindre
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={busy ? "danger" : undefined}
|
|
className="shrink-0 whitespace-nowrap"
|
|
disabled={
|
|
busy
|
|
? !supported || !currentSession || !agent.stopLiveAgent || stopping
|
|
: !canSend
|
|
}
|
|
loading={stopping}
|
|
onClick={() => (busy ? void stopAgent() : void send())}
|
|
>
|
|
{stopping ? "Arrêt en cours..." : busy ? "Stop" : "Envoyer"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatBubble({
|
|
turn,
|
|
requesterName,
|
|
}: {
|
|
turn: ChatTurn;
|
|
requesterName: string;
|
|
}) {
|
|
if (turn.role === "tool") {
|
|
return (
|
|
<div className="flex min-w-0 justify-center">
|
|
<span className="max-w-[80%] truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
|
|
{turn.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (turn.role === "progress") {
|
|
return (
|
|
<ProgressBubble
|
|
progress={turn.progress}
|
|
requesterName={requesterName}
|
|
/>
|
|
);
|
|
}
|
|
if (turn.role === "final") {
|
|
return (
|
|
<div className="flex min-w-0 justify-end">
|
|
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
|
<div className="mb-1 text-xs font-semibold text-success">Terminé</div>
|
|
<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 === "unknown" ? "Chunk inconnu reçu: " : ""}
|
|
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const user = turn.role === "user";
|
|
return (
|
|
<div className="flex min-w-0 justify-start">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
|
|
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
|
|
)}
|
|
>
|
|
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
|
{user && turn.attachments && turn.attachments.length > 0 && (
|
|
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
|
{turn.attachments.map((attachment) => (
|
|
<p key={attachment} className="truncate">
|
|
Fichier: {attachment}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!user && turn.pending && (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
|
<Spinner size={12} />
|
|
Réponse en cours
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function progressTitle(progress: ReplyProgress): string {
|
|
if (isDelegationProgress(progress)) return "Délégation";
|
|
if (progress.kind === "mcp") return "MCP";
|
|
if (progress.kind === "tool") return "Tool";
|
|
if (progress.kind === "message") return "Progress";
|
|
if (progress.kind === "turn") return "Tour";
|
|
return "Activité";
|
|
}
|
|
|
|
function progressSourceLabel(progress: ReplyProgress): string {
|
|
if (progress.source === "ideaLocal") return "IdeA";
|
|
return progress.provider ?? "Provider";
|
|
}
|
|
|
|
function progressStageLabel(stage: ReplyProgressStage): string {
|
|
switch (stage) {
|
|
case "started":
|
|
return "started";
|
|
case "delta":
|
|
return "running";
|
|
case "completed":
|
|
return "done";
|
|
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;
|
|
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,
|
|
excerpt: task
|
|
? previewText(task, 92)
|
|
: fallback
|
|
? previewText(fallback, 92)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const fourLineClamp = {
|
|
display: "-webkit-box",
|
|
WebkitBoxOrient: "vertical",
|
|
WebkitLineClamp: 4,
|
|
} as const;
|
|
|
|
function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
|
const stage = progressStageLabel(progress.stage);
|
|
const label = progress.label.trim() || "Tour";
|
|
return (
|
|
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
|
|
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
|
|
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
|
<span className="shrink-0 rounded-full border border-border bg-surface px-2 py-0.5">
|
|
{stage}
|
|
</span>
|
|
<span className="truncate">{label}</span>
|
|
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolProgressCard({
|
|
progress,
|
|
requesterName,
|
|
}: {
|
|
progress: ReplyProgress;
|
|
requesterName: string;
|
|
}) {
|
|
const stage = progressStageLabel(progress.stage);
|
|
const detail =
|
|
progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null;
|
|
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
|
const title = delegation
|
|
? delegation.target
|
|
? `${delegation.requester ?? requesterName} ${
|
|
progress.stage === "completed" ? "←" : "→"
|
|
} ${delegation.target}`
|
|
: "Appel inter-agent"
|
|
: progressTitle(progress);
|
|
return (
|
|
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[72%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
|
|
progressTone(progress),
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center justify-center gap-1.5">
|
|
<span className="shrink-0 font-semibold">{title}</span>
|
|
{!delegation && (
|
|
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
|
|
)}
|
|
<span className="shrink-0 text-muted">· {stage}</span>
|
|
</div>
|
|
{delegation?.excerpt && (
|
|
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
|
|
{delegation.excerpt}
|
|
</p>
|
|
)}
|
|
{delegation && (
|
|
<p className="mt-0.5 truncate text-center text-[11px] text-muted">
|
|
{compactToolName(progress)}
|
|
</p>
|
|
)}
|
|
{(progress.source || progress.nativeEvent || progress.provider) && (
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
|
<span>{progressSourceLabel(progress)}</span>
|
|
{progress.nativeEvent && (
|
|
<span className="truncate">event: {progress.nativeEvent}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{detail && (
|
|
<p
|
|
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-center text-[11px] text-content"
|
|
style={fourLineClamp}
|
|
>
|
|
{detail}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProgressBubble({
|
|
progress,
|
|
requesterName,
|
|
}: {
|
|
progress: ReplyProgress;
|
|
requesterName: string;
|
|
}) {
|
|
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
|
if (isCenteredToolProgress(progress)) {
|
|
return (
|
|
<ToolProgressCard
|
|
progress={progress}
|
|
requesterName={requesterName}
|
|
/>
|
|
);
|
|
}
|
|
const title = progressTitle(progress);
|
|
const stage = progressStageLabel(progress.stage);
|
|
const source = progressSourceLabel(progress);
|
|
const detail = progress.text ? previewText(progress.text, 180) : null;
|
|
return (
|
|
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[58%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
|
|
progressTone(progress),
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="shrink-0 font-semibold">{title}</span>
|
|
<span className="min-w-0 truncate text-muted">{progress.label}</span>
|
|
<span className="shrink-0 text-muted">· {stage}</span>
|
|
</div>
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
|
<span>{source}</span>
|
|
{progress.nativeEvent && (
|
|
<span className="truncate">event: {progress.nativeEvent}</span>
|
|
)}
|
|
</div>
|
|
{detail && (
|
|
<p
|
|
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-content"
|
|
style={fourLineClamp}
|
|
>
|
|
{detail}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|